diff --git a/.claude/skills/termina-tui-patterns.md b/.claude/skills/termina-tui-patterns.md new file mode 100644 index 000000000..f37d875cb --- /dev/null +++ b/.claude/skills/termina-tui-patterns.md @@ -0,0 +1,302 @@ +--- +name: termina-tui-patterns +description: How to do async work correctly in the Termina TUI (R3 + single-threaded render loop). Activate when editing anything under src/Netclaw.Cli/Tui/ that touches a network/disk probe, a background refresh, streaming output, spinners, or when you are tempted to write `.GetAwaiter().GetResult()` in a view-model. +--- + +# Termina TUI Patterns (async, R3, the render loop) + +## The myth that wastes hours + +> "Termina has no `SynchronizationContext`, so I can't `await` — I have to +> `.GetAwaiter().GetResult()` to stay on the loop thread." + +**This is wrong, and it is the single most common mistake agents make in this +codebase.** Blocking the loop thread on a network probe freezes input *and* +rendering for the entire round-trip (the spinner stops spinning, keys queue up). +"No SyncContext" does **not** mean "no async". It means async continuations +resume on arbitrary thread-pool threads, so the continuation must publish its +result through a thread-safe boundary before the Termina loop renders or handles +input from that state. + +The whole TUI already runs async the right way: `netclaw chat` streams live LLM +tokens to the screen, provider/search probes spin without blocking, and this +config editor resolves channel labels *after the page loads*. Copy those. Do not +reach for `GetResult()`. + +## How Termina actually works (the mental model) + +Termina (package `Termina` 0.12.1, which pulls `R3` 1.3.1) runs **one** loop: +`TerminaApplication.RunAsync` does `await foreach` over an **unbounded +`Channel`**, and after every dequeued event calls `RenderCurrentPage()`. +That loop is the single-threaded *serializer* — exactly one event is processed +and one render happens at a time. It runs on a thread-pool thread with **no +installed `SynchronizationContext`** (`TerminaHostedService` launches it via +`Task.Run`). + +Three consequences that define every correct pattern: + +1. **`RequestRedraw()` is a redraw signal, not a general UI-thread marshal.** It + is literally `_eventChannel.Writer.TryWrite(RedrawRequested.Instance)` and is + safe to call from any thread. The loop later dequeues it and renders. That + does not make unrelated mutable fields, dictionaries, lists, `ReactiveProperty` + fan-out, focus changes, navigation, or `DynamicLayoutNode.Invalidate()` safe to + perform from a background continuation. +2. **Input handlers run synchronously on the loop thread.** Input is delivered + inside the loop via R3 `Subject.OnNext` (a synchronous in-line fan-out, no + scheduler). So `Input.OfType().Subscribe(HandleKeyPress)` runs on + the loop thread — the *synchronous prefix* of your handler is on-loop. +3. **R3 `ReactiveProperty.Value = ...` is synchronous fan-out.** If a page + subscription invalidates a `DynamicLayoutNode`, changes focus, navigates, or + mutates Termina nodes, that work runs on the thread that set `.Value`. Setting + a reactive property from a background continuation is therefore an off-loop UI + mutation unless every subscriber is known to be thread-safe. +4. **Every background-to-UI handoff needs an explicit publication strategy.** Use + one of these, and document which one applies: locked snapshots, immutable + replacement values, `Volatile`/`Interlocked` for scalar flags and counters, or + a genuine loop-owned action processed by a Termina input/redraw path. Canceling + and awaiting a background task prevents stale writers, but it is not a memory + barrier for fields concurrently read by render/input. + +On ARM64 this distinction matters. x64's stronger memory ordering can hide plain +field races; Apple Silicon will not. A field written by a background continuation +and read by render/input must be synchronized even if every local x64 test passes. + +## The async shape to copy (with synchronized publish) + +Use this control flow for probes and refreshes: synchronous loop-owned setup, +tracked background task, cancellation check after the await, synchronized publish, +then `RequestRedraw()`. Do not copy older examples that publish plain fields or +reactive properties off-loop without auditing their subscribers. + +```csharp +private CancellationTokenSource? _probeCts; // owned CTS +private Task? _probeTask; // TRACKED task (never .GetResult() it) + +// Called from a synchronous (loop-thread) key/selection handler. +private void StartBackgroundProbe(/* inputs */) +{ + _probeCts?.Cancel(); + _probeCts?.Dispose(); + _probeCts = new CancellationTokenSource(); + + SetStatus("Validating…", ConfigStatusTone.Neutral); // 1. sync "working" state… + RequestRedraw(); // …painted on the loop thread + + _probeTask = RunProbeAsync(_probeCts.Token); // 2. fire-and-forget, TRACKED +} + +private async Task RunProbeAsync(CancellationToken ct) +{ + Result result; + try { result = await _probe.ProbeAsync(ct); } // 3. await OFF-loop (thread pool) + catch (OperationCanceledException) { return; } // superseded/abandoned → drop + + if (ct.IsCancellationRequested) return; // 4. re-check before publishing + // (a stale result must not clobber) + PublishProbeResult(result); // 5. synchronized publish; see below + RequestRedraw(); // 6. schedule render. NEVER navigate here. +} + +// Tests await this instead of Task.Delay / Thread.Sleep: +internal Task? PendingProbe => _probeTask; +``` + +The rules baked into that shape: + +- **Track the task in a field.** Fire-and-forget is fine, *untracked* is not — you + need it to cancel-and-await before a save (below) and to expose it to tests. +- **Own a `CancellationTokenSource`;** on restart, `Cancel()`+`Dispose()` the old + one. Re-check `ct.IsCancellationRequested` *after* the await, before you publish — + this is what stops a superseded probe from overwriting fresh state. +- **The continuation may only publish through a synchronized boundary and call + `RequestRedraw()`. It must NEVER navigate, change focus, invalidate layout nodes, + or set `ReactiveProperty` values with UI-mutating subscribers** off the loop. +- **If the published value is read by render/input, synchronize it.** Use a `lock` + around a mutable collection plus a snapshot method (copy `HealthCheckStepViewModel` + / `HealthCheckRunner`), replace the whole value with an immutable object, or use + `Volatile`/`Interlocked` for simple scalar state. +- **Do not assume `RequestRedraw()` orders every later read.** Even if the channel + enqueue/dequeue gives the redraw event an ordering edge, input events, timer + invalidations, existing subscriptions, and current renders can read the same state + outside that edge. +- **Expose the `Task`** (`PendingProbe`) so tests await it deterministically. No + `Task.Delay`/`Thread.Sleep` in tests (see CLAUDE.md Testing Guidelines). + +## The save-vs-background-write discipline + +When a background task can **write the same state** a save reads (e.g. the label +refresh normalizes names->ids and persists), the save must cancel-and-await it +first so it can't land a stale snapshot over the fresh save: + +```csharp +private async Task CancelAndAwaitLabelRefreshAsync() +{ + _labelResolutionCts?.Cancel(); + var inFlight = _labelRefreshTask; + if (inFlight is null) return; + await inFlight; // the refresh swallows its own exceptions + _labelRefreshTask = null; +} +// SaveAsync awaits this at its top, in an async method — NOT via .GetResult(). +``` + +Keep the *consumer* async too: the save path is an `async Task`, dispatched +fire-and-forget from the handler (`_ = ViewModel.SaveFromInputAsync();`) or via +`ConfigAutosave.RunAsync`. Do **not** re-block it with `.GetAwaiter().GetResult()`. + +This rule solves stale-writer ordering. It does **not** make the background task's +ordinary field writes safe while render/input can read them concurrently. Those +fields still need locks, immutable replacement, atomics, or loop-owned mutation. + +## Streaming (the chat reference) + +`netclaw chat` is the proof that async-to-front-end works. The daemon's +server-side `IAsyncEnumerable` arrives over SignalR as a callback push that +is mapped onto an R3 `Subject`, and the page subscribes and appends: + +- `DaemonClient.cs:78` — `_connection.On<…>("ReceiveOutput", dto => _outputSubject.OnNext(...))` +- `DaemonClient.cs:153` — `public Observable SessionOutput => _outputSubject.AsObservable();` +- `ChatPage.cs:78` — subscribe in `OnBound`; `ChatPage.cs:394-402` — append the delta to the + `StreamingTextNode`; `ChatPage.cs:493` — `RequestRedraw()`. + +Do not generalize this into "any off-loop mutation is fine." Chat streaming is a +dedicated push path whose page owns the append/redraw behavior. Before copying it, +verify the target node or subscriber is thread-safe, or publish into synchronized +state that the loop snapshots during render. + +## Publication patterns that are safe on ARM64 + +### Locked mutable collection + snapshot + +Use this when a background task appends or replaces items and the render path +enumerates them. + +```csharp +private readonly List _results = []; + +private void AddResult(HealthCheckItem item) +{ + lock (_results) + _results.Add(item); + RequestRedraw(); +} + +internal IReadOnlyList ResultsSnapshot() +{ + lock (_results) + return _results.ToArray(); +} +``` + +All readers and writers must use the same lock. Do not expose the mutable list as +the render surface unless callers are required to take the same lock. + +### Immutable replacement + +Use this when the background result is a complete value, not an incremental edit. +Build the value off-loop, then publish one immutable object/array. If the value is +read without a lock from another thread, publish/read via `Volatile` or another +explicit synchronization edge. + +```csharp +private ImmutableArray _rows = []; + +private void PublishRows(ImmutableArray rows) +{ + Volatile.Write(ref _rows, rows); + RequestRedraw(); +} + +internal ImmutableArray RowsSnapshot() => Volatile.Read(ref _rows); +``` + +### Atomic scalar state + +Use `Interlocked` for counters and task/CTS ownership; use `Volatile` for simple +single-writer flags. Never use `x++` on a cross-thread reactive version counter. + +```csharp +private int _version; + +private void PublishChanged() +{ + Interlocked.Increment(ref _version); + RequestRedraw(); +} + +internal int Version => Volatile.Read(ref _version); +``` + +If a `ReactiveProperty` is used only to wake page subscriptions, remember +that `.Value++` synchronously runs those subscriptions on the publishing thread. +Prefer a loop-owned invalidation path or a plain atomic version read by render. + +## Current audit flags + +These are not all necessarily bugs, but they are the fields/patterns that must be +checked before further TUI async work is considered safe: + +- `HealthCheckStepViewModel`: `Results` is lock-synchronized; keep using + `ResultsSnapshot()`. `ResultVersion`, `IsRunning`, `IsComplete`, `Succeeded`, + `_context.StatusMessage`, and `LaunchChat()` are written from async health-check + continuations and should not synchronously drive Termina invalidation/navigation + off-loop. +- `ChannelsConfigViewModel`: `RefreshChannelLabelsAsync` / `ReconcileResolvedChannels` + mutate `Step`, `_channelAudiences`, `Status`, `IsSaved`, and persisted config off-loop; + page callbacks invalidate nodes inline. Either move reconciliation onto a loop-owned + action or protect the shared state with a documented lock/snapshot discipline. +- `SkillSourcesConfigViewModel`: `RunProbeAsync` publishes `_pendingRemoteProbeResult`, + `_pendingRemoteProbeMessage`, `Status`, and `IsSaved` from a background continuation; + page subscriptions invalidate inline. Dispose cancels but does not drain `_probeTask`. +- `ProviderManagerViewModel`: eager probes mutate `DisplayProviders` rows and reactive + state from background continuations; `StateVersion.Value++` drives inline invalidation; + `_probeCts` ownership should use the `Interlocked.CompareExchange` pattern from + `ProviderStepViewModel` to avoid one probe disposing a newer probe's CTS. +- `ExposureModeStepViewModel`: currently appears loop-owned; do not add background + readers/writers without one of the publication strategies above. + +Tests for these paths must be bounded. Do not use an unbounded writer loop plus a +large snapshot loop; that creates a CPU/memory stress test instead of a race test. +Use finite handshakes, cancel in `finally`, and `WaitAsync` when awaiting background +writers. + +## Spinners and timers: let the node animate itself + +Do **not** hand-roll a frame ticker. `SpinnerNode` (via `SpinnerViews`) owns its +own animation timer and bubbles invalidation up the layout tree; `ReactivePage` +subscribes the root node's `Invalidated` and calls `RequestRedraw()` for you. A +hand-rolled spinner tick field is the bug from #1312. For a live elapsed counter, +copy `ElapsedTimeSegment` (an `IAnimatedTextSegment` whose timer fires +`Invalidated.OnNext`). See `src/Netclaw.Cli/Tui/SpinnerViews.cs:16-24`. + +## Anti-pattern: `.GetAwaiter().GetResult()` on the loop thread + +This **freezes input and rendering** for the whole operation. The "it can't +deadlock because there's no SyncContext" argument is a red herring — no-deadlock +is not the same as non-blocking. Every network-bound `GetResult()` on the loop is +a bug to fix, not a pattern to copy. + +If you find an old sync bridge in a TUI network/disk path, migrate it to the +tracked-task shape above. A bounded synchronous wait during disposal is a teardown +backstop, not an event-loop interaction pattern. + +## Checklist before you write TUI async code + +- [ ] Am I about to type `.GetAwaiter().GetResult()`? Stop. Use the tracked-task pattern. +- [ ] Is the network/disk await off-loop, with only the sync "working" setup on-loop? +- [ ] Owned CTS, cancelled+disposed on restart, re-checked after the await? +- [ ] Continuation publishes through a lock/immutable/atomic/loop-owned boundary, not plain fields? +- [ ] No off-loop `ReactiveProperty.Value` update has subscribers that touch Termina nodes? +- [ ] `RequestRedraw()` is used only to schedule a render, not as the only synchronization mechanism? +- [ ] No off-loop navigation, focus change, or `DynamicLayoutNode.Invalidate()`? +- [ ] Background task tracked in a field, exposed as `PendingX` for deterministic tests? +- [ ] Does any save read state this task writes? If so, cancel-and-await it before the save. + +## Key reference files + +- `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs` — useful probe shape, but audit its off-loop publication before copying +- `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs` — label-refresh/save ordering; do not copy its off-loop mutable-state publication without fixing synchronization +- `src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs` — probe + cosmetic timer (`StartProbe`, `:155-244`) +- `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs` — streaming results into a locked list + version-counter redraw +- `src/Netclaw.Cli/Tui/ChatPage.cs` / `ChatViewModel.cs` / `Daemon/DaemonClient.cs` — live streaming to the front end +- `src/Netclaw.Cli/Tui/SpinnerViews.cs` — self-animating spinner (don't hand-roll) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 8f1bb7380..00c7d981e 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -65,7 +65,37 @@ jobs: - name: "dotnet test" shell: bash - run: dotnet test -c Release + # blame-hang aborts + writes a Sequence file (naming the in-flight test) and a full process + # dump if a test stalls (no test activity) for 300s, so a hang fails fast (~minutes, not the + # 30-min job cap) and is diagnosable from the CI log + the uploaded dump instead of a silent + # timeout. The full dump carries every thread's stack — needed to confirm the suspected + # macOS/ARM64 weak-memory-ordering hang in the TUI view-models. + run: dotnet test -c Release --blame-hang-timeout 300s --blame-hang-dump-type full --results-directory ./TestResults + + # Diagnostic: surface the blame Sequence file (names the stuck test) in the CI log so a + # platform-specific hang can be pinpointed even without downloading the dump artifact. + - name: "Show hang sequence (if a test hung)" + if: always() + shell: bash + run: | + seq=$(find ./TestResults -name '*Sequence*.xml' 2>/dev/null || true) + if [ -n "$seq" ]; then + for f in $seq; do echo "===== HANG SEQUENCE: $f ====="; cat "$f"; echo; done + else + echo "No blame Sequence file — no test hang detected." + fi + + # Upload the hang dump + sequence file so a dedicated agent can open the dump (dotnet-dump + # analyze / lldb) and read the stalled thread stacks. Per-OS name; only the macOS run is + # expected to produce one today. + - name: "Upload hang dump" + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-hang-dump-${{ matrix.os }} + path: ./TestResults + if-no-files-found: ignore + retention-days: 14 - name: "Publish CLI (single-file, self-contained)" if: runner.os != 'Windows' diff --git a/AGENTS.md b/AGENTS.md index aa1626df4..c9fa3e1f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Read first: - `PROJECT_CONTEXT.md` - `TOOLING.md` +- `IMPLEMENTATION_PLAN.md` - `docs/prd/README.md` - `.opencode/skills/netclaw-*/SKILL.md` - `.claude/skills/ralph-*.md` @@ -88,14 +89,40 @@ task checkboxes in `openspec/changes/*/tasks.md` during RALPH iterations. Before coding a capability, discover in this order: -1. matching PRD in `docs/prd/` -2. matching engineering spec in `docs/spec/` -3. matching OpenSpec capability in `openspec/specs/` -4. active change plan in `openspec/changes//` +1. active task in `IMPLEMENTATION_PLAN.md` +2. matching PRD in `docs/prd/` +3. matching engineering spec in `docs/spec/` +4. matching OpenSpec capability in `openspec/specs/` +5. active change plan in `openspec/changes//` If planning and implementation artifacts conflict, fix planning artifacts first. If discovery artifacts conflict with each other, update them before implementing. +## Cross-Boundary Contract Rule + +When a change writes data consumed by another subsystem, identify the consumer +before implementation and verify the producer emits the consumer's canonical +representation. This applies to config editors, persistence records, actor +messages, protocol payloads, tool schemas, and security policy inputs. + +For configuration changes, tests must prove both: + +- invalid or unresolved values are rejected before persistence +- persisted values match what runtime ACL/routing/startup code expects + +Do not treat UI-level save success or schema validity as sufficient when runtime +behavior depends on provider IDs, canonical names, permissions, or security +policy keys. + +## Automation Floor + +Recent regressions define mandatory automated proof classes. TUI text input must +have headless typed-key coverage and native smoke coverage for critical flows. +Dynamic validation must have fake-failure tests proving save is blocked before +persistence. Legacy/new config paradigm changes must have load/round-trip tests +from the old shape to the runtime-consumed shape. Human manual testing is a +last-mile confidence check, not a substitute for these gates. + ## Configuration Schema Sync Rule When adding or changing properties on any `*Config` type in `Netclaw.Configuration`, diff --git a/BACKLOG_PARKING_LOT.md b/BACKLOG_PARKING_LOT.md new file mode 100644 index 000000000..3bb5f6850 --- /dev/null +++ b/BACKLOG_PARKING_LOT.md @@ -0,0 +1,32 @@ +# Backlog Parking Lot + +This file holds non-NOW work so autonomous loops do not accidentally bulldoze +deprioritized tasks. Move items into `IMPLEMENTATION_PLAN.md` only when the user +explicitly changes priority. + +## NEXT Candidates + +- Webhook service identity and inbound webhook hardening. +- Subagent explicit model selection. +- Subagent parent-context alignment. +- GitHub Copilot provider refinements. +- VLLM capability strategy and timing work. +- Fixed-length approval button labels and richer approval UI. +- Config hot-reload beyond startup-time configuration. +- Operator diagnostics refinements beyond current CLI/doctor/status work. + +## LATER Candidates + +- Ambient channel monitoring workflows. +- Delegated coding task orchestration. +- Browser automation as a first-class product feature. +- Split gateway/agent process architecture. +- Hosted/multi-tenant operator console. +- Delivery-policy tuning beyond the first Telemetry & Alerting config pass. + +## Parking Rule + +If a future task is interesting but not necessary for the active milestone, add +it here instead of expanding `NOW`. The implementation plan should stay small +enough that an agent can finish the selected task all the way through runtime +verification. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..421bfb466 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,758 @@ +# Netclaw Implementation Plan + +Last updated: 2026-06-01 + +This is the execution plan for Netclaw. Autonomous agents and RALPH-style loops +SHALL work from `NOW` by default. `NEXT` and `LATER` work belongs in +`BACKLOG_PARKING_LOT.md` unless the user explicitly reprioritizes it. + +## Operating Principle: Swing Through The Ball + +A task is not done when the local component accepts input, renders a screen, or +writes a file. A task is done when the downstream runtime path consumes the +produced artifact successfully, or bad input is rejected before it crosses the +boundary. + +Examples: + +- A config editor is done only when runtime startup/ACL/routing consumes the + saved shape it emits. +- A TUI flow is done only when typed input, paste input, persisted state, + re-entry, and semantic smoke assertions all agree. +- A tool or adapter is done only when policy denial, invalid credentials, + missing resources, and happy-path dispatch are all covered. +- A planning task is done only when PRD, spec, OpenSpec, tests, docs, and skill + guidance point at the same behavior. + +## Verification Levels + +Use the highest level required by the task. Higher levels include the lower +levels unless explicitly stated otherwise. + +| Level | Name | Required proof | +|-------|------|----------------| +| L0 | Planning-only | PRD/spec/docs updated; no runtime behavior changed. | +| L1 | Unit/contract | Targeted unit or contract tests prove pure behavior, serialization, validation, mapping, or policy decisions. | +| L2 | Integration | Component integration tests prove real persistence, DI, actor lifecycle, config binding, or fake-provider boundaries. | +| L3 | Interactive/smoke | Native smoke tape, CLI/TUI smoke, or equivalent real binary exercise proves the user-visible path. | +| L4 | Live/demo/e2e | Aspire demo, live provider, Docker image, or full runtime flow proves external/runtime wiring. | + +## Non-Negotiable Quality Gates + +These gates apply to every `NOW` task unless the task explicitly says why a gate +does not apply. + +- [ ] **Discovery gate:** Read the matching PRD, spec, OpenSpec capability, and + active change plan before coding. +- [ ] **Consumer gate:** Name the downstream consumer of any config, event, + actor message, persisted record, tool schema, or protocol payload the task + writes. +- [ ] **Canonical representation gate:** Prove the producer emits the exact + representation expected by the consumer, not merely a schema-valid value. +- [ ] **Negative-path gate:** Add at least one invalid/unresolved/denied test for + every security-relevant or routing-relevant input. +- [ ] **No silent fallback gate:** Misconfiguration fails visibly; fallback is + allowed only when partial failure is normal runtime behavior. +- [ ] **Schema gate:** Any `*Config` property change updates + `src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json`. +- [ ] **TUI gate:** Termina/init/config changes run the native smoke harness and + include semantic assertions, not just screen text. +- [ ] **Runtime gate:** If config drives runtime behavior, verify startup, + runtime binding, ACL, routing, or tool execution consumes the saved config. +- [ ] **Docs/spec gate:** Behavior changes update the relevant docs/specs and + any mapped system skill. +- [ ] **Repository gates:** Run `dotnet test`, `dotnet slopwatch analyze`, + `pwsh ./scripts/Add-FileHeaders.ps1 -Verify`, and `git diff --check` unless + the task explicitly scopes to docs-only work. + +## Automation-First QA Floor + +The human bare minimum should be priority calls, secrets/credentials for live +checks, and occasional high-risk UX spot checks. Agents are responsible for the +automatable proof below. + +| Recent bug class | Required automation | Human minimum | +|------------------|---------------------|---------------| +| Typed input does not reach a TUI field | Headless Termina test with `VirtualInputSource` covering typed characters, paste, Tab/focus movement, Enter/submit, Escape/back. Critical flows also need a native VHS smoke tape. | Run or review one live command only when a real terminal/TTY bug is suspected. | +| Dynamic validation does not run | Fake-provider failure test proving save is blocked, persistence is unchanged, and the visible error is shown. Tests must call the same public save path the UI uses. | Provide real provider credentials only for optional live probes. | +| Old config paradigm not ported to new editor | Load/round-trip tests from existing config and secrets into the new editor model, then back to disk. Tests must assert dormant values and secrets are preserved unless reset/delete is explicit. | Confirm whether stale fields should migrate, preserve, or fail. | +| Config shape accepted but runtime cannot consume it | Contract test between editor/init output and runtime options/ACL/routing/startup consumer. Assert canonical IDs/names/permissions, not just schema validity. | Decide behavior for ambiguous external API cases. | +| Smoke passes while semantic behavior is wrong | Smoke assertion script checks canonical persisted values, encrypted secrets, runtime-visible config, and error states. Screen text alone is not enough. | Review smoke artifact only if the assertion fails or UX changed substantially. | +| Async UI action fails silently | Public async method has direct tests; fire-and-forget handlers catch exceptions and surface status errors. Test fake exceptions from validation/save dependencies. | None by default. | +| Secret rotation/reset reintroduces old behavior | Tests cover blank-preserve, nonblank-replace, disable-preserve, reset-delete-immediate, and reopen-after-reset. | Confirm destructive copy in the UI. | + +Minimum automation by surface area: + +| Surface | Minimum gate | +|---------|--------------| +| Config editor | Static validation test, dynamic fake-failure test, existing-config round-trip test, config-to-runtime consumer test, native smoke for visible TUI paths. | +| Init wizard | Headless typed-input test for each prompt kind, native `init-wizard` smoke, existing-install path test, destructive-action double-confirm test. | +| Channel adapter | Options-binding test, ACL allow/deny tests, malformed/missing credential test, reply/routing integration or opt-in live smoke. | +| Tool/MCP | Schema generation test, schema coercion negative test, permission allow/deny/prompt tests, malformed metadata test. | +| Persistence/memory/session | Serialization round-trip, restart/recovery test, corrupt/missing state test, eval suite when prompt/memory behavior changes. | +| Packaging/demo | Install smoke, Docker image binary/version check, health endpoint check, opt-in demo smoke when runtime wiring changes. | + +Manual-only acceptance criteria are not allowed for `NOW` implementation tasks. +If something truly cannot be automated, the task must say why and must provide +the smallest repeatable manual script plus expected output. + +## Current Source Artifacts + +- Product: `PROJECT_CONTEXT.md`, `docs/prd/README.md`, `docs/prd/PRD-001-netclaw-mvp.md` +- CLI/config: `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/spec/SPEC-004-cli-contract.md`, `docs/spec/SPEC-007-guided-onboarding.md`, `openspec/specs/netclaw-config-command/spec.md`, `openspec/changes/netclaw-config-command/tasks.md` +- Security/gateway: `docs/prd/PRD-002-gateway-security-envelope.md`, `docs/spec/SPEC-001-runtime-boundaries.md`, `docs/spec/SPEC-003-acl-policy-and-security-controls.md`, `openspec/specs/netclaw-acl/spec.md`, `openspec/specs/netclaw-gateway-security/spec.md` +- Input adapters: `docs/prd/PRD-009-input-adapters-and-unified-input.md`, `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-slack-socket/spec.md`, `openspec/specs/netclaw-discord-socket/spec.md`, `openspec/changes/add-mattermost-channel/tasks.md` +- Models/providers: `docs/prd/PRD-005-model-provider-strategy.md`, `docs/spec/SPEC-008-model-provider-abstraction.md`, `openspec/specs/netclaw-model-providers/spec.md` +- MCP/tools: `docs/prd/PRD-006-mcp-tool-integration.md`, `openspec/specs/netclaw-mcp/spec.md`, `openspec/specs/netclaw-tools/spec.md`, `openspec/specs/tool-approval-gates/spec.md` +- Memory/personality: `docs/prd/PRD-007-agent-personality-and-local-memory.md`, `openspec/specs/netclaw-agent-memory/spec.md`, `openspec/specs/project-instructions/spec.md` +- Scheduling: `docs/prd/PRD-008-scheduling-and-periodic-tasks.md`, `openspec/specs/netclaw-scheduling/spec.md`, `openspec/specs/reminder-execution-history/spec.md` +- Testing: `docs/spec/SPEC-010-testing-and-smoke-strategy.md`, `TOOLING.md` + +## NOW + +### Phase 0: Execution Governance + +Purpose: prevent shallow local fixes from being mistaken for runtime-complete +work. + +#### Task 0.1: Enforce the cross-boundary contract rule + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md` +**Surface area:** cross-cutting +**Verification:** L0 + +Done when: + +- [x] `AGENTS.md` references `IMPLEMENTATION_PLAN.md` as a read-first artifact. +- [x] `AGENTS.md` includes the Cross-Boundary Contract Rule. +- [x] This plan is the default routing artifact for build work. +- [x] `BACKLOG_PARKING_LOT.md` exists for non-now work. + +#### Task 0.2: Add PRD/status traceability to the plan workflow + +**PRD:** `docs/prd/README.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md` +**Surface area:** docs +**Verification:** L0 + +Done when: + +- [x] Every `NOW` task has a `PRD` reference. +- [x] Tasks with stale, missing, or conflicting PRD coverage are blocked until + the PRD/spec is updated. +- [x] If a task changes OpenSpec-covered behavior, the corresponding OpenSpec + workflow is used rather than hand-editing change artifacts. + +#### Task 0.3: Add contract-test inventory for critical producer/consumer pairs + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md` +**Surface area:** cross-cutting +**Verification:** L1 + +Done when: + +- [x] Document the critical producer/consumer pairs in this plan or a linked + spec, including config editor -> runtime options, channel events -> ACL, + scheduler -> delivery gateway, tool schemas -> model/tool dispatcher, and + memory persistence -> prompt assembly. +- [x] For each pair, identify the canonical representation and the test file + that proves it. +- [x] Add missing tests or add explicit `NOW` tasks for gaps. + +Inventory: `docs/spec/SPEC-010-testing-and-smoke-strategy.md` -> Critical +Producer/Consumer Contract Inventory. Remaining proof gaps are assigned to +explicit `NOW` tasks 3.1, 4.2, and 5.2-5.3. + +#### Task 0.4: Automate recent regression classes + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-010-testing-and-smoke-strategy.md`, `openspec/specs/netclaw-config-command/spec.md` +**Surface area:** testing, TUI, config +**Verification:** L3 + +Done when: + +- [x] Every config/TUI task touching text input includes headless typed-input + tests for typed characters, paste, Tab, Enter, Escape, and re-entry when + applicable. +- [x] Every config leaf with dynamic validation has a fake-failure test proving + validation runs before persistence and leaves files unchanged. +- [x] Every config leaf ported from init/old editor paths has an existing-config + load/round-trip test covering dormant values and persisted secrets. +- [x] Every smoke tape with config writes has an assertion script that checks + canonical semantic output, not only screenshots or text. +- [x] Any async UI save/test action has a direct awaitable test path plus + fire-and-forget exception surfacing. + +#### Task 0.5: Add audit tests for plan-critical config editors + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/section-editor-abstraction/spec.md`, `openspec/specs/netclaw-config-command/spec.md` +**Surface area:** testing, config +**Verification:** L1 + +Done when: + +- [x] A registry/audit test lists config leaf editors and fails when a visible + editor lacks round-trip coverage. +- [x] The audit requires each visible editor to declare whether it has dynamic + validation and, if yes, the test class that covers fake-failure behavior. +- [x] The audit requires each editor that writes secrets to have blank-preserve, + nonblank-replace, and explicit-delete coverage. +- [x] The audit requires each editor that writes runtime-consumed config to name + the runtime consumer and contract test file. + +### Phase 1: Config Command And Channel Runtime Contracts + +Purpose: finish the active config work all the way through runtime semantics. + +`netclaw config` owns post-install tuning. It should cover ordinary changes an +operator might make after first run without re-entering bootstrap: + +- Providers and Models route to their dedicated editors. +- Channels, Search, Security & Access, Exposure Mode, Skill Sources, + Telemetry & Alerting, Workspaces Directory, Inbound Webhooks, and Browser + Automation must not remain root-dashboard placeholders before this phase + closes. +- Identity/personality re-entry remains `netclaw init` / identity-owned work; + config may expose the Workspaces Directory because operators can move project + discovery roots after first run without regenerating identity files. +- Per-session project switching is runtime state owned by the + `set_working_directory` tool and the Audience Profiles `Change workspace` + permission, not a global config editor. +- General MCP server/permission editing remains `netclaw mcp`; Browser + Automation config may add/remove the canonical browser MCP profile, then route + grants to `netclaw mcp permissions`. +- Inbound webhook route-file authoring remains `netclaw webhooks` / route files + for this pass; config owns global enablement, execution timeout, route-count + visibility, and loud diagnostics when enabled with no routes. +- Advanced session tuning, logging verbosity, tool hard-deny overrides, and + low-level tool execution ceilings are not init-owned, but stay out of this + config-command close unless explicitly promoted. + +#### Task 1.1: Complete Channels provider-backed validation and canonical persistence + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/channel-audience-tui/spec.md`, `openspec/specs/netclaw-input-adapters/spec.md` +**Surface area:** UI, config, runtime contract +**Verification:** L3 + +Done when: + +- [x] Slack channel names entered in config are resolved through Slack before + persistence. +- [x] Slack `AllowedChannelIds` persists canonical Slack channel IDs (`C...` or + `G...`) and never unresolved display names. +- [x] Slack channel audience keys are remapped to resolved channel IDs. +- [x] Discord channel IDs are checked through `IDiscordProbe.ResolveChannelIdsAsync` + before save. +- [x] Mattermost channel IDs are checked through a Mattermost config-time probe + before save. +- [x] Unresolved Slack, Discord, and Mattermost channel targets block save with + visible errors. +- [x] Existing configured secrets can be used for validation without prompting + on re-entry. +- [x] Tests cover Slack name -> ID resolution, Slack unresolved name rejection, + Discord unresolved ID rejection, Mattermost unresolved ID rejection, and secret + preservation. +- [x] Native smoke `./scripts/smoke/run-smoke.sh config-channels` passes with + semantic assertions on canonical persisted values. +- [x] Docker POC image rebuild/relaunch was not used for this task's verification; + native smoke provided the L3 gate. + +#### Task 1.2: Finish generalized config leaf validation + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/section-editor-abstraction/spec.md` +**Surface area:** config, UI, cross-cutting +**Verification:** L3 + +Done when: + +- [x] Every `netclaw config` leaf has typed structural validation before save. +- [x] Runtime/probe validation is run where the leaf writes values consumed by + runtime startup, ACL, transport, tools, or daemon exposure. +- [x] Structurally invalid config is a hard block. +- [x] `Save anyway` exists only for transient runtime/probe failures, never for + schema violations, missing required security fields, or unresolved canonical + IDs. +- [x] Tests prove invalid path, URI, auth, binary, local-reference, and + reachability failures where those concepts apply. +- [x] Smoke assertions check semantic preservation and canonical output, not + byte-identical JSON. + +#### Task 1.3: Complete `Security & Access` config area + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/security-posture-tui/spec.md`, `openspec/specs/netclaw-acl/spec.md` +**Surface area:** UI, config, security +**Verification:** L3 + +Done when: + +- [x] `Security & Access` contains Security Posture, Enabled Features, Audience + Profiles, and Exposure Mode. +- [x] Security Posture remains distinct from runtime Enabled Features and + Audience Profiles. +- [x] Team/Public posture continues into Enabled Features; Personal posture does + not force that continuation. +- [x] Audience Profiles expose only curated high-level controls: Tool Access + (non-MCP), File Access, Incoming Attachments, Reset to posture default. +- [x] Reset to posture default resets the full underlying audience profile, + including hidden MCP and approval settings. +- [x] MCP permissions route to `netclaw mcp permissions`; they are not recreated + in this editor. +- [x] Tests cover round-trip, hidden-field reset semantics, and ACL consumer + expectations. +- [x] Native config smoke covers at least one posture change and one audience + profile reset with semantic assertions. + +#### Human Review Checkpoint: Security & Access config editor + +- [x] Completed 2026-06-01: human smoke passed in rebuilt + `netclaw-config-poc-local` container at commit `547c2c3`; no `401 + Unauthorized` after enabling Reverse Proxy and entering MCP permissions. + +Stop here after Task 1.3 is completed, verified, and committed. Do not continue +into Task 1.4 until a human has spot-checked the live `netclaw config` Security +& Access experience in a real terminal. + +Human smoke focus: + +- Security Posture reads clearly and continues to Enabled Features for Team and + Public, but not for Personal. +- Audience Profiles expose only curated controls and route MCP grants to + `netclaw mcp permissions`. +- Reset overrides visibly restores the posture baseline and the persisted JSON + clears hidden MCP and approval overrides. +- If Reverse Proxy is enabled from this TUI session, immediately entering MCP + permissions must not return `401 Unauthorized`; the local daemon client must + use the bootstrap `DeviceToken` written by the exposure-mode save. +- Exposure Mode is visible from Security & Access, but deeper Exposure Mode + behavior remains Task 1.4 work. + +Human smoke finding 2026-06-01: enabling Reverse Proxy and then navigating into +MCP permissions in the same `netclaw config` process produced `401 Unauthorized`. +Treat this as a config/runtime credential refresh regression, not an acceptable +manual workaround. Regression coverage belongs with daemon-client authentication +tests because the config TUI reuses the same `DaemonApi` instance after exposure +mode writes a fresh bootstrap `DeviceToken`. Fixed by commit `547c2c37` and +confirmed by human retest in the rebuilt validation container. + +#### Task 1.4: Complete Exposure Mode config leaf + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `docs/spec/SPEC-006-gateway-exposure-and-remote-access.md`, `openspec/specs/daemon-exposure/spec.md`, `openspec/specs/device-pairing/spec.md` +**Surface area:** UI, config, daemon exposure +**Verification:** L3 + +Done when: + +- [x] Explicit modes are Local, Reverse Proxy, Tailscale Serve, Tailscale + Funnel, and Cloudflare Tunnel. +- [x] `Daemon.ExposureMode` is the single active selector; no per-mode active + flags are introduced. +- [x] Inactive old values are preserved and ignored while inactive. +- [x] Each non-local mode has a mode-specific dialog; Local requires no extra + setup. +- [x] First non-local enablement auto-pairs the current configuring client when + no bootstrap/pairing state exists. +- [x] Orphaned or mismatched bootstrap state blocks with actionable guidance to + `netclaw doctor`, docs, and the tracked issue. +- [x] Tests prove config merge semantics and daemon exposure consumer binding. +- [x] Native config smoke covers at least one non-local mode and one return to + Local. + +#### Task 1.5: Complete Workspaces, Inbound Webhooks, and Browser Automation config areas + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-006-mcp-tool-integration.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/netclaw-mcp/spec.md`, `docs/spec/configuration.md` +**Surface area:** UI, config, workspaces, webhooks, MCP/browser tools +**Verification:** L3 + +Done when: + +- [x] Workspaces Directory is editable from `netclaw config`, validates as a + local directory path, persists `Workspaces.Directory`, and preserves existing + identity files. +- [x] Tests prove `NetclawPaths.WorkspacesDirectory`, project discovery, and + prompt/workspace consumers read the saved `Workspaces.Directory` value. +- [x] Inbound Webhooks root entry routes to an implemented editor, not a + placeholder. +- [x] Inbound Webhooks editor controls `Webhooks.Enabled` and + `Webhooks.ExecutionTimeoutSeconds`; route-file editing stays in + `netclaw webhooks` / `~/.netclaw/config/webhooks/*.json` for this pass. +- [x] Enabling inbound webhooks with no valid routes fails loudly through doctor + or visible diagnostics; no dummy route is created silently. +- [x] Browser Automation root entry routes to an implemented editor, not a + placeholder. +- [x] Browser Automation detects required local runtime pieces, refuses enablement + when prerequisites are missing, and prints manual install guidance instead of + shelling out from the TUI. +- [x] Browser Automation persists/removes the canonical browser MCP server profile + (`browser_playwright` or `browser_chrome_devtools`) using the same shape runtime + MCP loading consumes. +- [x] Browser Automation grants route to `netclaw mcp permissions`; raw MCP grant + editing is not recreated in this editor. +- [x] Native smoke covers at least one successful save path and one blocked or + guidance-only path across these areas. + +#### Task 1.6: Complete Skill Sources and Telemetry & Alerting config areas + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-006-mcp-tool-integration.md` +**Spec:** `openspec/specs/netclaw-config-command/spec.md`, `openspec/specs/netclaw-mcp/spec.md` +**Surface area:** UI, config, operations +**Verification:** L3 + +Done when: + +- [x] Skill Sources contains External Skills and Skill Feeds. +- [x] Skill Source validation covers paths, URIs, auth, and reachability where + relevant. +- [x] Telemetry & Alerting contains Telemetry and Outbound Webhooks only in this + pass. +- [x] Delivery-policy tuning stays parked. +- [x] Tests prove semantic round-trip, secret preservation, invalid URI/path + rejection, and runtime consumer binding where applicable. +- [x] Smoke tapes exercise both areas or document why an existing smoke covers + the route. + +#### Human Review Checkpoint: Complete config surface + +Stop here after Tasks 1.4, 1.5, and 1.6 are completed, verified, and committed. +Do not continue into Task 1.7 until a human has spot-checked the live +`netclaw config` experience in the rebuilt validation container. + +Human smoke focus: + +- Exposure Mode can switch to a non-local mode and back to Local without stale + runtime-active fields or missing local auth. +- Workspaces Directory, Inbound Webhooks, Browser Automation, Skill Sources, + Telemetry, and Outbound Webhooks are implemented pages, not root-dashboard + placeholders. +- Each page rejects structurally invalid values before persistence and preserves + unrelated config/secrets. +- Browser Automation creates/removes the canonical browser MCP profile and routes + grants to `netclaw mcp permissions`. +- Inbound Webhooks global enablement remains separate from route-file authoring; + no dummy route is silently created. +- `./scripts/smoke/run-smoke.sh light` has passed or any local blocker is + documented with evidence. + +#### Task 1.7: Close the `netclaw config` OpenSpec change + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/changes/netclaw-config-command/tasks.md` +**Surface area:** planning, config +**Verification:** L3 + +Done when: + +- [ ] `openspec/changes/netclaw-config-command/tasks.md` accurately reflects + completed and incomplete implementation work. +- [ ] `openspec validate netclaw-config-command --type change` passes. +- [ ] `./scripts/smoke/run-smoke.sh light` passes on a clean runner or a local + blocker is documented with evidence. +- [ ] `/opsx-verify netclaw-config-command` passes. +- [ ] Spec deltas are synced or the change remains explicitly active with only + real unfinished tasks. + +### Phase 2: Init Bootstrap Split + +Purpose: keep first-run setup simple and move post-install editing to config. + +#### Task 2.1: Simplify first-run `netclaw init` + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-007-guided-onboarding.md`, `openspec/changes/simplify-netclaw-init/tasks.md` +**Surface area:** TUI, config bootstrap +**Verification:** L3 + +Done when: + +- [ ] Planning and code remove all `netclaw init --force` assumptions. +- [ ] First-run init contains bootstrap-owned steps only. +- [ ] Posture values remain `Personal`, `Team`, `Public`. +- [ ] Identity remains init-owned. +- [ ] Post-flight messaging points users to `netclaw chat` and `netclaw config`. +- [ ] Init smoke `./scripts/smoke/run-smoke.sh init-wizard` passes. +- [ ] Full light smoke passes or local blockers are documented with evidence. + +#### Task 2.2: Implement existing-install init menu and destructive reset flow + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-007-guided-onboarding.md`, `openspec/changes/simplify-netclaw-init/tasks.md` +**Surface area:** TUI, config bootstrap, destructive actions +**Verification:** L3 + +Done when: + +- [ ] Existing install shows exactly: `Redo identity setup`, `Open configuration + editor`, `Start over from scratch`, `Cancel`. +- [ ] `Open configuration editor` routes to `netclaw config`. +- [ ] `Redo identity setup` routes only into init-owned identity flow. +- [ ] Start-over dialog shows exactly: `Reset setup only`, `Full reset`, + `Cancel`. +- [ ] Both destructive actions require double confirmation. +- [ ] Tests cover refusal, menu routing, double confirmation, and preserved vs + deleted files. +- [ ] Smoke coverage exercises existing-install menu and start-over cancellation. + +#### Task 2.3: Close the `simplify-netclaw-init` OpenSpec change + +**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/changes/simplify-netclaw-init/tasks.md` +**Surface area:** planning, TUI +**Verification:** L3 + +Done when: + +- [ ] `openspec validate simplify-netclaw-init --type change` passes. +- [ ] `/opsx-verify simplify-netclaw-init` passes. +- [ ] Init smoke and light smoke pass. +- [ ] Docs and skill guidance no longer describe stale init behavior. + +### Phase 3: Runtime Adapter Contract Hardening + +Purpose: prove each channel adapter accepts, denies, responds, and reports health +according to the same security envelope. + +#### Task 3.1: Add adapter config-to-runtime contract tests + +**PRD:** `docs/prd/PRD-009-input-adapters-and-unified-input.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-slack-socket/spec.md`, `openspec/specs/netclaw-discord-socket/spec.md`, `openspec/specs/netclaw-acl/spec.md` +**Surface area:** runtime, config, ACL +**Verification:** L2 + +Done when: + +- [ ] Slack, Discord, and Mattermost options bind from the config shape emitted + by init/config editors. +- [ ] Allowed channel IDs and user IDs are consumed by runtime ACL in canonical + provider form. +- [ ] Denied channel, denied user, allowed channel, and DM policy cases are + covered per adapter. +- [ ] Misconfigured required tokens or server URLs fail closed for the affected + channel without enabling permissive ingress. +- [ ] Tests name the producer and consumer for each contract. + +#### Task 3.2: Add runtime reply-path smoke for local/demo adapters + +**PRD:** `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-testing/spec.md` +**Surface area:** runtime, smoke +**Verification:** L4 + +Done when: + +- [ ] Mattermost demo smoke posts a user message and proves the daemon routes it + to a session and attempts a reply. +- [ ] Discord and Slack live smoke remain opt-in and credential-gated; absence + of credentials skips with clear output, not failure. +- [ ] Runtime logs expose enough detail to diagnose allowed/denied/routed/reply + states without leaking secrets. +- [ ] `TOOLING.md` documents the exact invocation and expected artifacts. + +#### Task 3.3: Normalize channel diagnostics and doctor output + +**PRD:** `docs/prd/PRD-003-operator-ux-ops-console.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `docs/spec/SPEC-005-operator-ui-contract.md`, `openspec/specs/netclaw-operator-ui/spec.md` +**Surface area:** CLI, daemon diagnostics, operations +**Verification:** L2 + +Done when: + +- [ ] `netclaw status` or doctor output distinguishes disconnected, + misconfigured, denied-by-policy, and healthy per channel. +- [ ] Slack/Discord/Mattermost health outputs use consistent terms. +- [ ] Tests cover status mapping from runtime channel health to CLI/doctor + display. +- [ ] Runbooks mention the deny and misconfiguration diagnostics operators + should look for. + +### Phase 4: Model Provider And Tool Execution Contracts + +Purpose: keep model/provider/tool execution reliable and diagnosable across +provider differences. + +#### Task 4.1: Harden provider/model config-to-runtime binding + +**PRD:** `docs/prd/PRD-005-model-provider-strategy.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `docs/spec/SPEC-008-model-provider-abstraction.md`, `openspec/specs/netclaw-model-providers/spec.md`, `openspec/specs/netclaw-model-capabilities/spec.md` +**Surface area:** config, runtime, providers +**Verification:** L2 + +Done when: + +- [ ] Provider and model editors emit config that runtime provider selection + consumes without hidden defaults. +- [ ] Invalid provider IDs, missing model IDs, unsupported auth modes, and stale + capability metadata fail visibly. +- [ ] Tests cover config editor output -> provider registry/model selection + consumption. +- [ ] Eval suite is run if model/provider defaults or capability logic changes. + +#### Task 4.2: Prove tool schema and permission contracts end-to-end + +**PRD:** `docs/prd/PRD-006-mcp-tool-integration.md`, `docs/prd/PRD-002-gateway-security-envelope.md` +**Spec:** `openspec/specs/netclaw-tools/spec.md`, `openspec/specs/netclaw-mcp/spec.md`, `openspec/specs/tool-call-metadata/spec.md`, `openspec/specs/mcp-schema-coercion/spec.md` +**Surface area:** tools, MCP, security +**Verification:** L2 + +Done when: + +- [ ] Tool schemas generated for models match dispatcher expectations. +- [ ] MCP schema coercion has negative tests for invalid/coercion-impossible + inputs. +- [ ] Tool approval and grant decisions are tested for allow, deny, prompt, and + malformed metadata. +- [ ] No tool can bypass audience/profile policy because a field is missing or + has a stale name. + +#### Task 4.3: Keep streaming/progress execution contract coherent + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-006-mcp-tool-integration.md` +**Spec:** `openspec/changes/streaming-tool-call-execution/tasks.md`, `openspec/changes/progress-aware-turn-loop/tasks.md`, `openspec/specs/session-state-machine/spec.md` +**Surface area:** runtime, actors, tools +**Verification:** L2 + +Done when: + +- [ ] Tool-call streaming, progress reporting, session phase transitions, and + persistence snapshots agree on the same state names. +- [ ] Actor tests prove progress events survive normal tool completion, + tool failure, cancellation, and session recovery. +- [ ] No turn loop can report success while a tool result is still pending. +- [ ] Logs/traces correlate model call, tool call, approval, and session turn. + +### Phase 5: Memory, Identity, Scheduling, And Persistence Contracts + +Purpose: ensure autonomous behavior survives restarts and carries the right +identity/context. + +#### Task 5.1: Prove identity file and system prompt assembly contracts + +**PRD:** `docs/prd/PRD-007-agent-personality-and-local-memory.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/project-instructions/spec.md`, `openspec/specs/netclaw-agent-memory/spec.md` +**Surface area:** identity, prompt assembly, evals +**Verification:** L2 plus eval suite + +Done when: + +- [ ] Init writes identity files in the exact paths prompt assembly reads. +- [ ] Prompt assembly rejects missing or malformed required identity assets + visibly. +- [ ] Tests cover first-run, existing-install identity redo, missing file, and + malformed file cases. +- [ ] Eval suite passes when identity grounding rules change. + +#### Task 5.2: Prove memory recall and compaction persistence contracts + +**PRD:** `docs/prd/PRD-007-agent-personality-and-local-memory.md` +**Spec:** `openspec/specs/netclaw-agent-memory/spec.md`, `openspec/specs/netclaw-session/spec.md`, `openspec/specs/thread-history-backfill/spec.md` +**Surface area:** persistence, memory, session actors +**Verification:** L2 plus eval suite + +Done when: + +- [ ] Memory recall inputs, persisted observations, compaction summaries, and + prompt assembly use compatible serialization-safe types. +- [ ] Tests cover fresh session, resumed session, compacted session, and corrupt + or missing memory state. +- [ ] Eval suite passes for memory pipeline and compaction changes. + +#### Task 5.3: Prove scheduling delivery contracts + +**PRD:** `docs/prd/PRD-008-scheduling-and-periodic-tasks.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `openspec/specs/netclaw-scheduling/spec.md`, `openspec/specs/reminder-execution-history/spec.md` +**Surface area:** scheduling, actors, channel delivery +**Verification:** L2 + +Done when: + +- [ ] Reminder targets resolve to channel gateways using canonical provider IDs. +- [ ] Current-session delivery routes through the existing session gateway chain + without re-running inbound ACL checks. +- [ ] Future scheduled delivery uses policy appropriate for the stored target. +- [ ] Tests cover immediate reminder, periodic reminder, missed execution, + failed delivery, restart recovery, and invalid target. +- [ ] `TimeProvider` is used for all scheduling time. + +### Phase 6: Release Readiness And Packaging + +Purpose: keep install, Docker, demo, and CI aligned with product behavior. + +#### Task 6.1: Keep Docker image and install artifacts contract-tested + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-004-cli-onboarding-and-config.md` +**Spec:** `openspec/specs/daemon-container/spec.md`, `openspec/specs/manifest-signature-verification/spec.md` +**Surface area:** packaging, install, Docker +**Verification:** L3 + +Done when: + +- [ ] Docker image contains matching CLI and daemon binaries from the same + source build. +- [ ] Container default config path, health check, entrypoint, and self-update + behavior match docs. +- [ ] Install smoke passes for Linux/macOS/Windows stand-in archives. +- [ ] Manifest signature verification negative paths are covered. +- [ ] Local POC rebuild instructions are documented and reproducible. + +#### Task 6.2: Maintain demo AppHost as the local end-to-end proof + +**PRD:** `docs/prd/PRD-001-netclaw-mvp.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Spec:** `openspec/changes/netclaw-demo-apphost/tasks.md`, `TOOLING.md` +**Surface area:** demo, runtime, smoke +**Verification:** L4 + +Done when: + +- [ ] Demo AppHost boots Mattermost, Ollama, and daemon to healthy. +- [ ] Seeded Mattermost user can post into the configured channel. +- [ ] Daemon logs prove message routing into a session and model invocation. +- [ ] Slow CPU inference remains documented as latency caveat, not hidden as a + failed wiring assertion. +- [ ] Opt-in demo integration test remains skipped by default and passes with + `NETCLAW_RUN_DEMO_SMOKE=1` on a suitable Docker host. + +## NEXT + +NEXT tasks are important but not eligible for autonomous execution unless moved +to `NOW` by the user. + +- Webhook service identity and inbound webhook route hardening beyond the config + enablement/timeout editor. +- Subagent explicit model selection and parent-context alignment. +- GitHub Copilot provider refinements and VLLM capability strategy. +- Approval button label refinement and richer interactive approval UX. +- Config hot-reload beyond current startup/configure flows. +- Operator UX/Ops Console beyond CLI/TUI diagnostics. + +## LATER + +LATER tasks are product-direction items and should stay out of execution loops. + +- Ambient monitoring workflows. +- Delegated coding task orchestration. +- Browser automation as a first-class feature beyond config-time MCP profile + enablement. +- Split gateway/agent process architecture. +- Hosted SaaS / multi-tenant operator console. + +## Required Session Closure Checklist + +Before declaring any implementation session done, record the closure state in +the final response and, if a task remains incomplete, leave a concrete follow-up +in this plan. + +- [ ] Which `IMPLEMENTATION_PLAN.md` task was worked. +- [ ] Producer/consumer contract identified. +- [ ] Positive behavior verified. +- [ ] Negative behavior verified. +- [ ] Runtime/smoke/eval validation completed or explicitly blocked. +- [ ] Docs/spec/skill updates completed or explicitly not applicable. +- [ ] Commands run and results reported. +- [ ] Worktree state reported. diff --git a/TOOLING.md b/TOOLING.md index a96abc466..8ae5c5582 100644 --- a/TOOLING.md +++ b/TOOLING.md @@ -50,6 +50,11 @@ TUI code SHOULD run the harness before declaring a change done. `NETCLAW_SMOKE_DAEMON` if exported), installs `vhs`, starts a native `ollama serve`, and pulls the smoke models automatically. +Config-writing flow tapes (`init-wizard`, `provider-add`, `provider-rename`, +and `config-*`) must have executable semantic assertion scripts under +`tests/smoke/assertions/`. `run-native-tape.sh` fails these tapes when the +assertion is missing or non-executable. + When a tape fails, `smoke-logs/tapes//` collects: a debug GIF of the last frame, the combined tape file, daemon logs, and the produced `NETCLAW_HOME`. CI uploads the `smoke-logs` directory as a job artefact. diff --git a/docs/incidents/2026-06-17-macos-arm64-tui-test-hang.md b/docs/incidents/2026-06-17-macos-arm64-tui-test-hang.md new file mode 100644 index 000000000..a8b6f2eed --- /dev/null +++ b/docs/incidents/2026-06-17-macos-arm64-tui-test-hang.md @@ -0,0 +1,185 @@ +# Incident: `Test-macos-26` hangs in `Netclaw.Cli.Tests` (macOS / ARM64 only) + +- **Status:** ROOT CAUSE FOUND for CI hang; broader off-loop R3/Termina publication audit remains open. +- **Date opened:** 2026-06-17 +- **Affected:** PR #1368 (`docs/netclaw-validated-ui-components`), CI job `Test-macos-26` (`macos-26`, Apple Silicon / ARM64). +- **Not affected:** `Test-ubuntu-latest`, `Test-windows-latest` (both x64), and local Linux x64 runs. + +## Summary + +After the config-TUI async rewrite + hardening landed on PR #1368, the `Test-macos-26` CI job +**hangs**: `dotnet test` reaches the `Netclaw.Cli.Tests` assembly, prints +`A total of 1 test files matched the specified pattern.`, and then produces **no further output** +until the 30-minute job timeout kills it (`##[error]The operation was canceled.`). Every other test +assembly in the same run completes normally (e.g. `Netclaw.Daemon.Tests`: 738 passed in 46s). The +ubuntu and windows test jobs pass in ~11–14 min. So a test in `Netclaw.Cli.Tests` hangs **only on +macOS**. + +Because xunit waits for *all* tests in an assembly before reporting results, a **single** test that +never completes stalls the whole assembly — this looks like one hung test, not broad slowness. + +## 2026-06-17 investigation result + +The instrumented PR run `27711731116` uploaded `test-hang-dump-macos-26` and a blame-hang sequence +XML. Linux `dotnet-dump` cannot analyze the macOS ARM64 Mach-O dump, but the sequence XML names the +active unfinished test directly: + +```xml + +``` + +That test is a new PR-side regression test for the `HealthCheckStepViewModel.ResultsSnapshot()` lock +discipline. The production `Results` path uses one monitor consistently (`HealthCheckRunner.Add`, +`UpdateLast`, `AllPassed`, and `HealthCheckStepViewModel.ResultsSnapshot()`), so the sequence does not +prove a production Termina render-loop deadlock. + +The test itself was pathological on macOS ARM64 CI: it started an unbounded writer that appended to +`Results` until cancellation, then performed 50,000 snapshots before canceling the writer. Since each +snapshot copies the full list under the same lock, the work grows with every writer append and can turn +into a CPU/memory/monitor-contention stress test. The uploaded artifact was ~1.8GB, consistent with a +run that ballooned before blame-hang killed it. + +Fix: bound the writer-side list growth, run the writer as a dedicated long-running task, and await it +with `WaitAsync` after cancellation. The test still races concurrent `HealthCheckRunner.Add()` calls +against `ResultsSnapshot()`, but no longer creates an unbounded list-copy workload. + +Separate finding: the original weak-memory-ordering concern is still valid as a **guidance and audit** +issue. `.claude/skills/termina-tui-patterns.md` incorrectly blessed background continuations mutating +plain fields / `ReactiveProperty` values and then calling `RequestRedraw()`. R3 property setters fan out +synchronously on the publishing thread, and several page subscriptions invalidate `DynamicLayoutNode`s +inline, so `RequestRedraw()` is not a general marshal to the Termina loop. The skill has been rewritten +to require locked snapshots, immutable/atomic publication, or genuine loop-owned mutation for any state +read by render/input. + +## Remaining hypothesis: macOS/ARM64 weak memory ordering (vs x64 TSO) + +x86/x64 implements a strong memory model (Total Store Order): a write by one thread becomes visible +to other threads in program order without explicit barriers. **ARM64 has a weak/relaxed memory +model** — a plain field write on thread A is **not guaranteed visible** to thread B without an +explicit barrier (`Volatile.Read/Write`, `Interlocked`, `lock`, or a `MemoryBarrier`). + +The Termina TUI view-models lean on a pattern that is *safe on x64 by accident of TSO* but may be +**unsound on ARM64**: + +- The render loop runs on a thread-pool thread with **no `SynchronizationContext`**. +- Async probe/label-refresh/save continuations therefore resume on **arbitrary thread-pool threads**, + not the loop thread. +- Those continuations **mutate plain view-model fields** (not just `Task`s) and then call + `RequestRedraw()`; the loop thread later **reads those same fields** for rendering and input + handling — **with no lock or barrier between the cross-thread write and the read** (other than + whatever `RequestRedraw` → `Channel.Writer.TryWrite` happens to provide). + +On x64 the stale-read window doesn't exist (TSO). On ARM64 the loop can read a **stale** field value +— e.g. a "work done / task cleared / state ready" flag that was set by a pool-thread continuation but +not yet visible — and **wait forever** for a condition that has, in fact, already occurred. That remains +a plausible mechanism for future ARM64-only TUI bugs, but it is not the proven root cause of the +`27711731116` CI hang named above. + +> NOTE: `Task` completion/continuation handoff *is* memory-safe (the TPL inserts barriers), so +> awaiting a `Task` across threads is fine. The risk is **plain non-`Task` fields / collections** +> read on one thread and written on another without synchronization. + +## Specific directive — AUDIT THE TUI SKILLS I WROTE + +A dedicated agent should audit the agent-facing skill **`.claude/skills/termina-tui-patterns.md`** +(authored during this work) against the ARM64 memory model. It currently asserts, as blessed +guidance: + +1. *"'No SyncContext' does not mean 'no async' — it means async continuations resume on the thread + pool, which is fine, because Termina's marshaling primitive (`RequestRedraw`) is thread-safe."* +2. *"You do not marshal continuations back to the loop. You mutate `ReactiveProperty`/field state from + the thread-pool continuation, then `RequestRedraw()`."* +3. *"Cross-write races are handled by **cancel-and-await of the background task, not by locks or + marshaling**."* +4. The "save-vs-background-write discipline" (cancel-and-await before a save). + +**Audit questions to answer:** + +- Does `RequestRedraw()` (i.e. `_eventChannel.Writer.TryWrite(...)`) establish a **release** barrier, + and does the loop's dequeue (`await foreach` over the `Channel`) establish a matching **acquire** + barrier, such that a field written *before* `RequestRedraw` is guaranteed visible to the loop + *after* it dequeues that redraw event? If yes, the redraw path may be safe **for fields read only + during a redraw**. If the loop reads those fields on **other** paths (an independently-delivered + keypress, a timer tick, a different event) there is no such ordering — flag every such read. +- Is guidance #2/#3 sound on ARM64 at all, or does it need to be rewritten to require + `Volatile.Read/Write` / `Interlocked` / `lock` on any field shared between a pool-thread + continuation and the loop thread? +- Enumerate the concrete cross-thread fields and decide each. Known candidates in + `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs`: `_labelRefreshTask`, `_channelAudiences` + (`Dictionary`, mutated by `ReconcileResolvedChannels` off-loop and read/written by on-loop + handlers), the `Step` channel state (`SetChannelIds` / `RemapChannelAudiences` off-loop), + `_channelRowIndex`, `IsSaved`/`Status`/`Screen` (`ReactiveProperty` — check R3's own thread-safety), + and `_pendingConfigWrite` (believed loop-only, but `Dispose` reads it — confirm Dispose runs on the + loop thread). Other VMs: `SkillSourcesConfigViewModel` (`_probeTask`, status), `ProviderManager`, + the wizard `HealthCheckStepViewModel` (its `Results` list **is** lock-synchronized — a good model to + generalize from), `ExposureModeStepViewModel`/device-pairing. +- Also audit any *other* skill authored in this work for the same x64-only assumption. + +**Deliverable of the audit:** a corrected `termina-tui-patterns.md` that is ARM64-correct (require +explicit synchronization on cross-thread shared state, or genuinely marshal mutations onto the loop), +plus a list of the specific VM fields that need `Volatile`/`Interlocked`/`lock` or on-loop marshaling. + +## What has been ruled out + +- **Sync-over-async deadlock (the original theory):** fixed and proven. The four + `.GetAwaiter().GetResult()` bridges in `ChannelsConfigViewModel` are gone; a deterministic + single-worker-`SynchronizationContext` regression test deadlocks the old code and passes the new. + `grep` confirms zero unbounded sync-over-async in production TUI. **Yet macOS still hangs** — so + this was at most one cause, or never the CI culprit. +- **Real-network probe without a timeout:** the only real-`HttpClient` fallback is + `SearchConfigEditorViewModel.CreateHttpClient()` (`?? new HttpClient()`), but every probe-triggering + Search test injects a stub/gated factory — none hit real network. +- **HealthCheck/daemon polls:** bounded (90s reload, 5-min overall) and use stub HTTP handlers. +- **Local reproduction (x64):** the full `Netclaw.Cli.Tests` suite passes in 5–14s under a forced + single-worker and 2-worker `MaxConcurrencySyncContext` on Linux x64. Consistent with the hang being + ARM64-architecture-specific rather than a generic SC-saturation deadlock. + +## How to get the hang-dump evidence + +CI was instrumented in this PR (`.github/workflows/pr_validation.yml`, the `dotnet test` step): +`--blame-hang-timeout 300s --blame-hang-dump-type full --results-directory ./TestResults`, plus a +"Show hang sequence" step and an **"Upload hang dump"** artifact step. On a hang the macOS job now: +(a) aborts after 300s of no test activity, (b) writes a **full process dump** + a **`*.Sequence.xml`** +(names the in-flight test) into `./TestResults`, (c) **fails fast** (~minutes, not 30), and +(d) uploads the artifact `test-hang-dump-macos-26`. + +To collect and analyze: + +1. **Find the run + read the test name from the log (no download needed):** + ```bash + gh run list -R netclaw-dev/netclaw --branch docs/netclaw-validated-ui-components --workflow pr_validation -L 5 + # open the failed Test-macos-26 job; the "Show hang sequence" step prints the stuck test name + gh run view -R netclaw-dev/netclaw --job --log | grep -A20 "HANG SEQUENCE" + ``` +2. **Download the dump artifact:** + ```bash + gh run download -R netclaw-dev/netclaw -n test-hang-dump-macos-26 -D ./hangdump + ls ./hangdump # *.Sequence.xml (names the test) + *.dmp (full process dump) + ``` +3. **Analyze the dump** (managed thread stacks — find the thread blocked in a TUI view-model): + ```bash + dotnet tool install -g dotnet-dump # if needed + dotnet-dump analyze ./hangdump/*.dmp + # at the prompt: + # clrthreads # list managed threads + # parallelstacks # grouped stacks — look for the stalled one + # clrstack -all # or: setthread ; clrstack on the suspect thread + # Look for a thread parked in ChannelsConfigViewModel / a Termina render loop / a spin or wait on a + # plain field, and for a continuation thread whose field write should have unblocked it. + ``` + > A full dump from an Apple Silicon runner is an arm64 Mach-O core. If `dotnet-dump` struggles, + > `lldb` with the SOS plugin on an arm64 host works too. +4. **Reproduce on hardware:** run the named test (or the whole assembly) on an Apple Silicon Mac / + arm64 runner: `dotnet test src/Netclaw.Cli.Tests --blame-hang-timeout 120s`. If it reproduces, + bisect the specific field/await; consider building a stress harness that hammers the suspect + continuation↔loop field handoff under `DOTNET_TieredCompilation=0` and on arm64 to surface the + ordering bug deterministically. + +## Relevant artifacts + +- PR: #1368. Commits: `67142a61` (async migration), `8621887b` (lifecycle hardening), + `43c0e3b9` (slopwatch SW003), + the CI blame-hang/artifact instrumentation commit. +- Follow-up issue for the (separate) off-loop-mutation-race deferral: netclaw-dev/netclaw#1426. +- Skill to fix: `.claude/skills/termina-tui-patterns.md`. +- First observed hang run (pre-fix): actions run `27668131402`. Post-rebase hang: run `27705574077`, + job `81953063966`. diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index 91260aaa8..41c7b8234 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -9,6 +9,8 @@ commands, Cocona + Termina frameworks) - Revised: 2026-02-23 (daemon + thin client split, daemon management commands, offline vs daemon-required command categorization) +- Revised: 2026-05-24 (bootstrap-only `init`, domain-oriented `config`, + init-owned identity re-entry, explicit reset flow) - Depends on: `PRD-001`, `PRD-002` ## Goal @@ -44,42 +46,70 @@ Netclaw ships as two binaries (see PRD-001 for full architecture): - Configuration files contain API keys/secrets — config read/write commands operate on local files directly, never query config over the wire -## Two-Phase Onboarding +## Bootstrap and Ongoing Configuration -### Phase 1: CLI Wizard (`netclaw init`) +### Bootstrap: `netclaw init` -Technical setup, no LLM required. `netclaw init` runs as a **lightweight mode** -— no Akka actor system, no persistence, no SignalR. Only config services are -booted. Provider testing uses direct DI service calls (`ChatClientFactory`), -not REST endpoints. +Technical setup, no daemon required. `netclaw init` runs as a lightweight +offline mode: no Akka actor system, no SignalR, no runtime session host. +Provider testing uses direct DI service calls and local validation. -The wizard is **reentrant** — re-running `netclaw init` detects existing config -and shows a section dashboard with status per section. Each section is -independently enterable for modification. First-run guides linearly through -all steps. +`netclaw init` is bootstrap-first and intentionally short. -Steps: +Fresh-install flow: 1. LLM provider configuration (endpoint URL, API key or OAuth device flow, - model selection, connectivity test via direct HTTP to provider) -2. Slack app setup (bot token, app token for Socket Mode) -3. ACL bootstrap (owner identity, initial channel rules) -4. MCP server configuration (optional — Memorizer recommended) -5. Exposure mode selection (local-only default) -6. Health check (verify Slack connection, LLM reachability, MCP connectivity) + model selection, connectivity test) +2. Identity setup (workspaces directory, user name, timezone) with init-owned + regeneration of `SOUL.md` and `TOOLING.md` +3. Security posture (`Personal`, `Team`, `Public`) +4. Enabled Features for `Team` and `Public` only +5. Final validation / health check / next steps -### Phase 2: Conversational Personality Bootstrap (first `netclaw chat`) +Existing-install flow: -Agent-driven setup, requires running LLM: +1. `Redo identity setup` +2. `Open configuration editor` +3. `Start over from scratch` +4. `Cancel` -1. "Hi, I'm Netclaw. Let me learn about you and your setup." -2. Ask about projects to register (repo paths on disk) -3. Discover environment capabilities (scan for installed tools) -4. Write PERSONALITY.md, USER.md, environment inventory -5. Confirm readiness +`Start over from scratch` is owned by the existing-install init menu, not a +hidden flag. It opens a scope selector: -Phase 2 is triggered automatically on first `netclaw chat` if personality files -don't exist. It can also be re-triggered via CLI (`netclaw personality reset`). +1. `Reset setup only` +2. `Full reset` +3. `Cancel` + +Both destructive paths require double confirmation. + +`Reset setup only` archives and recreates setup-owned state while preserving +working data such as the SQLite database, logs, projects, schedules, +environment, and skills. `Full reset` wipes the entire Netclaw home except the +installed binary payload. + +### Ongoing Settings: `netclaw config` + +`netclaw config` is the main post-install settings surface. It is a +domain-oriented Termina TUI, not a flat dump of raw config sections. + +Top-level domains: + +1. `Inference Providers` +2. `Models` +3. `Channels` +4. `Inbound Webhooks` +5. `Skill Sources` +6. `Search` +7. `Browser Automation` +8. `Telemetry & Alerting` +9. `Security & Access` + +Command ownership stays explicit: + +1. `netclaw init` owns bootstrap and identity re-entry +2. `netclaw config` owns normal post-install tuning +3. `netclaw provider` and `netclaw model` remain their canonical standalone + entrypoints and may be routed to from `netclaw config` ## Command Surface (MVP) @@ -101,12 +131,16 @@ don't exist. It can also be re-triggered via CLI (`netclaw personality reset`). ### TUI-Interactive Commands (Termina, offline) -- `netclaw init` — guided first-time setup wizard (7-step TUI wizard). Reads - and writes local config files directly. No daemon required. +- `netclaw init` — guided bootstrap wizard plus rare existing-install + identity/reset re-entry. Reads and writes local config files directly. No + daemon required. +- `netclaw config` — domain-oriented post-install settings dashboard. Reads and + writes local config files directly. No daemon required. +- `netclaw provider` — bare invocation launches interactive provider manager. +- `netclaw model` — bare invocation launches interactive model manager. ### Onboarding and Configuration (Plain CLI, offline) -- `netclaw config show|validate` — display/validate current configuration - `netclaw personality reset` — re-trigger conversational personality setup - `netclaw project list|add|remove` — project registry management (local files) - `netclaw environment scan|show` — capability self-discovery (scans local system) @@ -155,11 +189,22 @@ Onboarding captures all Phase 1 setup items in a stepwise flow. `netclaw init` SHALL support an interactive guided onboarding flow that: 1. Captures LLM provider configuration (OpenRouter default, OAuth or API key) -2. Configures Slack Socket Mode credentials (bot token + app token) -3. Scaffolds ACL in default-deny mode with owner identity -4. Optionally configures MCP servers (Memorizer recommended) -5. Selects exposure mode (local default) -6. Runs final validation and prints next-step run commands +2. Captures init-owned identity settings and regenerates `SOUL.md` / + `TOOLING.md` +3. Selects security posture (`Personal`, `Team`, `Public`) +4. Continues into Enabled Features when posture is `Team` or `Public` +5. Runs final validation and prints next-step run commands + +### CLI-001B Post-Install Configuration + +`netclaw config` SHALL be the primary post-install settings surface. It SHALL: + +1. Launch a domain-oriented dashboard +2. Route providers/models to their dedicated interactive managers +3. Group `Security Posture`, `Enabled Features`, `Audience Profiles`, and + `Exposure Mode` under `Security & Access` +4. Refuse with a plain non-zero message directing the operator to + `netclaw init` when no install exists ### CLI-002 Validation @@ -192,10 +237,12 @@ and active tool grants for the session. Commands default to read-only behavior unless explicit write/apply flags are provided. -### CLI-007 Onboarding Resume +### CLI-007 Existing-Install Re-entry -The onboarding flow SHALL be resumable and indicate which setup steps are -completed, pending, or invalid. +When `netclaw init` runs on an existing install, it SHALL present an explicit +action menu rather than silently re-entering the full bootstrap flow. Identity +re-entry remains init-owned; all normal configuration edits route to +`netclaw config`. ### CLI-008 Project Registration @@ -218,9 +265,11 @@ Results are persisted to the environment inventory file. ### CLI-010 TUI Commands -`netclaw init` and `netclaw chat` SHALL use Termina 0.5.1 for interactive TUI -rendering. All other commands SHALL use plain console output. TUI commands SHALL -launch Termina as a hosted service within the mode-selected host builder. +`netclaw init`, `netclaw config`, and `netclaw chat` SHALL use Termina 0.5.1 +for interactive TUI rendering. Bare `netclaw provider` and `netclaw model` +SHALL also use Termina. All other commands SHALL use plain console output. TUI +commands SHALL launch Termina as a hosted service within the mode-selected host +builder. ### CLI-011 Chat Thin Client @@ -267,7 +316,8 @@ endpoints. No TUI rendering. This is the primary production entry point. 2. Every high-risk command has confirmation or explicit `--yes` semantics. 3. Error output includes remediation guidance. 4. Fresh install reaches a runnable baseline in one guided flow. -5. Personality bootstrap triggers automatically on first conversation. +5. Existing installs can re-enter identity setup or open `netclaw config` + without replaying full bootstrap. 6. Environment scan discovers and persists capability inventory. 7. Project registration persists project registry to disk. diff --git a/docs/reviews/2026-06-config-tui-deep-review.md b/docs/reviews/2026-06-config-tui-deep-review.md new file mode 100644 index 000000000..d2ab3e191 --- /dev/null +++ b/docs/reviews/2026-06-config-tui-deep-review.md @@ -0,0 +1,717 @@ +# Deep C# implementation review — config/init TUI + +_85 findings (15 high, 17 medium, 53 low) from 12 Sonnet reviewers + adversarial verify. {'raw': 129, 'unique': 108, 'verifiedHighMed': 51, 'lowsCarried': 34, 'returned': 85}_ + +Verdicts: CONFIRMED = verified against code; PLAUSIBLE = real mechanism, runtime-dependent trigger; UNVERIFIED = low-severity, carried without a verify pass. + +## HIGH (15) + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1094` +**Concurrent config file writes: background label normalizer races with SaveAsync** + +NormalizeSlackChannelNamesToIds (called from the background label-refresh task) calls WriteChannelConfigToDisk at line 1094. SaveAsync also calls WriteChannelConfigToDisk at line 188. SaveAsync never cancels _labelResolutionCts, so a live background task can be writing netclaw.json at the same time a user-triggered Save is writing it. ConfigFileHelper.WriteConfigFile uses File.WriteAllText, which is not atomic. Concurrent writes produce file corruption or silent last-writer-wins overwrites. The ct.IsCancellationRequested guard at line 1039 only fires when StartChannelLabelResolution explicitly replaces the CTS — a normal Save never triggers that cancellation. + +_Fix:_ Cancel and await the background label refresh inside SaveAsync before writing to disk: call _labelResolutionCts?.Cancel() at the start of the private SaveAsync overload, then await the outstanding task (track the Task returned by RefreshChannelLabelsAsync) before proceeding to WriteChannelConfigToDisk. Alternatively, serialize writes through a dedicated lock or channel. + +_Verifier:_ SaveAsync must cancel and await the outstanding label-resolution task (by tracking the Task returned from RefreshChannelLabelsAsync) before writing to disk; the current fire-and-forget pattern leaves no handle to await or cancel from the Save path. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1042` +**Background task races on shared mutable adapter view-model state** + +RefreshSlackChannelLabelsAsync captures `slack = Step.GetAdapterViewModel()` at line 1033, then awaits the probe. After the await (line 1042), it writes slack.LastChannelResolution and at line 1092 calls SetChannelIds which mutates slack.ChannelNamesInput. Concurrently, SaveAsync -> Step.OnEnter -> _mapper.ApplyToStep -> ApplySlack (line 1874) resets slack.BotToken = null, slack.HasPersistedBotToken, slack.ChannelNamesInput, etc. on the same object. No lock or volatile guard exists on these plain auto-properties. The result: the background normalizer can overwrite a freshly-reloaded channel list with a stale pre-probe snapshot (line 1092: SetChannelIds([.. normalized...])), or LastChannelResolution is written after the view-model was reset and the stale result drives the next render. + +_Fix:_ Keep a snapshot of channelIds inside RefreshSlackChannelLabelsAsync before the await and verify after the await that the in-memory channel list still matches the snapshot (if not, the save raced — abandon the normalizer write). Long-term, move the disk-write path to an exclusive lock or a sequential async pipeline. + +_Verifier:_ The race window is small but real: it requires a slow Slack probe to straddle a concurrent save, after which `NormalizeSlackChannelNamesToIds` overwrites the view-model reset done by `ApplySlack` and persists the stale channel list via `WriteChannelConfigToDisk`. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1310` +**Blocking sync-over-async on the UI thread for every autosave action** + +`AutosaveCompletedAction` is the save path for every in-page mutation — add channel, remove channel, rotate credentials, toggle enable, update audience, DMs, allowed users. All of them funnel through `() => SaveAsync(successMessage).GetAwaiter().GetResult()`. `SaveAsync` itself does network I/O (Slack/Discord/Mattermost channel probes via `ValidateChannelAccessAsync`). Blocking the UI/render thread on a potentially multi-second HTTP call freezes the TUI and, if the calling thread is part of a thread-pool, can cause thread-pool starvation under concurrent autosaves. The pattern at lines 157-158 (`Save()` → `SaveAsync().GetAwaiter().GetResult()`) and 1312 (`SaveAsync(…).GetAwaiter().GetResult()`) are the concrete sites. + +_Fix:_ Make all autosave paths properly async: expose `AutosaveCompletedActionAsync` returning `Task`, make the callers (`ApplyAddChannel`, `ApplyAllowedUsers`, `ApplyDirectMessages`, `ApplyCredentials`, `RemoveSelectedChannel`, `ApplyAudienceSelection`, `SetActiveAdapterEnabled`) async, and have the Page `await` them (or fire-and-forget with an unobserved-exception handler). The `Save()` sync wrapper on line 157 should be removed or clearly marked internal-to-tests-only. + +_Verifier:_ Every in-page mutation triggers a blocking sync-over-async call that holds the TUI thread through up to three sequential HTTP probes (Slack, Discord, Mattermost), confirmed by the code path from `AutosaveCompletedAction` through `SaveAsync` → `ValidateChannelAccessAsync`; an async overload (`ConfigAutosave.RunAsync`) already exists and is used by the non-mutation save path at line 231, so the fix is straightforward. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:55` +**Shared mutable `Results` list written from async task, read from UI render thread without synchronisation** + +`Results` is a plain `public List` (line 55). `RunHealthCheckCoreAsync` (line 147) — running on a threadpool thread — adds items via `runner.Add(...)` and mutates `Results[^1]` (lines 293, 337, 342, 362). The render thread reads `Results` whenever `ResultVersion` emits a new value (page subscriber at InitWizardPage line 124). `List` is not thread-safe; concurrent reads and writes of the list's internal array can produce torn reads, `IndexOutOfRangeException`, or silently wrong output. + +_Fix:_ Either use an `ImmutableList` snapshot that the async task replaces atomically (assign via a `ReactiveProperty>`), or collect results into a thread-local list and publish the snapshot on each `NotifyChanged` call. The `HealthCheckRunner` could hold the list and expose a read-only snapshot property. + +_Verifier:_ The race is live on every health-check run: the async task writes to `Results` on a threadpool thread while Termina's render loop reads `Results` via `foreach` on its dispatcher thread, with no synchronisation between them. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs:173` +**CTS self-nulling race: `finally { CancelProbe(); }` inside `ProbeProviderAsync` destroys the CTS it was started with** + +`ProbeProviderAsync` (line 173) creates `_probeCts = new CancellationTokenSource()` and captures `ct = _probeCts.Token`. Its `finally` block calls `CancelProbe()` (lines 163-168), which cancels, disposes, and sets `_probeCts = null`. If a second `StartProbe()` call races in (e.g. from the OAuth success callback at lines 279, 291), it calls `CancelProbe()` first (destroying the first probe's CTS before the first probe's `finally` runs) and then creates a new `_probeCts`. When the first probe later reaches its `finally`, it now finds and destroys the *second* probe's CTS, cancelling the live probe silently. The `StartOAuthFlow` / `StartBrowserOAuthFlow` paths both call `StartProbe()` from their `onSuccess` callback which runs from an async continuation — exactly the reentrant path. + +_Fix:_ Capture the local CTS reference before the async work and dispose only that instance in `finally`: `var localCts = _probeCts = new CancellationTokenSource(); try { ... } finally { localCts.Cancel(); localCts.Dispose(); if (ReferenceEquals(_probeCts, localCts)) _probeCts = null; }`. This removes the self-nulling hazard. + +_Verifier:_ The `finally { CancelProbe(); }` block in `ProbeProviderAsync` has no reference-equality guard, so when an OAuth-success-triggered `StartProbe()` races in and replaces `_probeCts` before the original probe's `finally` runs, the first probe's cleanup silently cancels the second probe's live CTS — exactly the scenario the existing comment at `OAuthFlowCoordinator.cs:409-411` tried (and failed) to prevent. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:489` +**Left/right audience toggle on ChannelPermissions silently discards changes on Esc** + +ChangeSelectedChannelAudience (called by ←/→ on ChannelPermissions, page line 503-508) calls SetChannelAudience which mutates _channelAudiences in memory (line 489) but never calls AutosaveCompletedAction. Every other mutation on the same screen (RemoveSelectedChannel, ApplyAddChannelAsync) does autosave. If the operator presses ←/→ to change a channel's audience and then presses Esc, GoBackWithinManagement fires (line 1281) with no save. The next SaveAsync or load resets _channelAudiences from disk (line 1348), silently discarding the change. The key-binding strip '[←/→] Audience' gives no indication the edit is ephemeral. The DM row (Id='dm') is equally affected when it is focused on the ChannelPermissions list. + +_Fix:_ Call AutosaveCompletedAction immediately after SetChannelAudience in ChangeSelectedChannelAudience, matching the pattern used by RemoveSelectedChannel, or add a 'save' key to ChannelPermissions and display a pending-changes indicator so the operator knows unsaved edits exist. + +_Verifier:_ The ←/→ audience toggle on the ChannelPermissions screen is the only mutation in that screen group that skips AutosaveCompletedAction, making in-place audience edits silently ephemeral whenever the operator navigates away without pressing Enter through the EditAudience flow. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:195` +**WriteConfig() called unconditionally before health-check results are evaluated** + +orchestrator.WriteConfig() (which writes netclaw.json, secrets.json, identity files, provider credentials, and bootstrap device) is called on line 195 inside the try block, before the allPassed evaluation on line 211. If any step's health check emits a failing result (e.g., the LLM provider returns a bad status), the config is still written and committed to disk. A running daemon's ConfigWatcherService then picks up that config and performs an in-process restart onto potentially incomplete or invalid settings. The comment on line 207 implies writing config is the restart trigger, so this is load-bearing: writing before confirming all checks pass means a failed-validation run corrupts an existing working config. + +_Fix:_ Evaluate allPassed (runner.AllPassed) after RunHealthChecksAsync completes and before the WriteConfig call. Only proceed to write config if allPassed is true. The existing comment 'Writing config already triggered a running daemon' should describe this as intentional-only-on-pass. + +_Verifier:_ The unconditional write at line 195 is the load-bearing defect: a failed provider health check still commits potentially invalid credentials to disk and fires the daemon's config-reload restart via `ConfigWatcherService`, which can replace a working config with a broken one before `allPassed` is ever consulted. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:115` +**Unexpected exception in RunHealthCheckCoreAsync permanently wedges the wizard in IsRunning=true / IsComplete=false** + +RunWithOrchestrator catches only OperationCanceledException from its own overallCts. If RunHealthCheckCoreAsync throws any other exception (for example an I/O error in a step's ContributeHealthChecksAsync, or an unexpected failure not covered by the inner try/catch blocks), the outer async task faults and returns. IsRunning is never reset to false and IsComplete is never set to true. The wizard is permanently stuck: the health-check step appears to still be running, the operator cannot advance or go back (GoNext checks !IsRunning && !IsComplete before calling StartWithOrchestrator), and there is no visible error message. The bug is a missing catch-all handler in RunWithOrchestrator that sets IsRunning=false, IsComplete=true and surfaces an error. + +_Fix:_ Wrap the body of RunWithOrchestrator in a general try/catch that ensures IsRunning=false and IsComplete=true are set in all exit paths, e.g.: +```csharp +catch (Exception ex) +{ + Results.Add(new HealthCheckItem($"Health check failed: {ex.Message}", false)); + IsRunning.Value = false; + IsComplete.Value = true; + NotifyChanged(); +} +``` + +_Verifier:_ The unguarded `await orchestrator.RunHealthChecksAsync(runner, ct)` at line 157 is the concrete trigger: any exception from a step's health-check contribution escapes both RunHealthCheckCoreAsync and RunWithOrchestrator's single narrow catch, leaving IsRunning=true/IsComplete=false with no recovery path. + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:650` +**DaemonConfig.ParseExposureMode throws on unknown strings and is called unguarded from the render path** + +`ReadExposureModeSummary` (line 646–661) calls `DaemonConfig.ParseExposureMode(value?.ToString())` without a try-catch. `ParseExposureMode` throws `InvalidOperationException` for any unrecognised string (line 157–159 in `DaemonConfig.cs`). `ReadExposureModeSummary` is called from `BuildItems()`, which is the body of the `Items` property (line 127). `Items` is accessed synchronously in the Termina render path (`BuildSecurityMenu`, line 68 of `SecurityAccessPage.cs`) and also in `MoveSelection`/`ActivateSelected`. A hand-edited or migrated config with an unsupported `Daemon.ExposureMode` value will therefore crash the render and leave the Security & Access page permanently broken with an unhandled exception. + +_Fix:_ Wrap the `ParseExposureMode` call in `ReadExposureModeSummary` with a try-catch and return a fallback label (e.g., `value?.ToString() ?? "Unknown"`) so the page degrades gracefully. The same guard is also missing in `ExposureModeStepViewModel.ReadExistingMode` (line 558) which is called from `TryPrefillFromExisting` during wizard entry. + +_Verifier:_ Any hand-edited or migrated config with an unrecognized `Daemon.ExposureMode` string will throw an unhandled `InvalidOperationException` on every render frame of the Security & Access page, permanently breaking that page; the fix pattern already exists in `ExposureModeDoctorCheck.cs` and just needs to be applied to both `ReadExposureModeSummary` and `ExposureModeStepViewModel.ReadExistingMode`. + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:1935` +**Config file write exceptions propagate unhandled to the TUI event loop** + +`SaveExternalConfig` (line 1926) and `SaveSkillFeedsConfig` (line 1938) both call `ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, root)` with no surrounding try/catch. `WriteConfigFile` calls `File.WriteAllText` which throws `IOException`, `UnauthorizedAccessException`, or `PathTooLongException` on disk full, permission denied, or path issues. All callers of these two methods — `ToggleEnabled`, `ToggleLocalSymlinks`, `CycleRemoteSyncInterval`, `RemoveRemoteToken`, `SaveRename`, `SaveLocalPathChange`, `SaveRemoteUrlChange`, `SaveRotatedRemoteToken`, `SaveNewLocalSource`, `SaveNewRemoteSource`, `RemoveSource` — are similarly unguarded. An IO error here will surface as an unhandled exception in the TUI event loop. The same issue exists in `WorkspacesConfigViewModel.Save()` at line 148: the `ConfigFileHelper.WriteConfigFile` call sits outside the existing `try/catch` block. + +_Fix:_ Wrap `WriteConfigFile` calls in both `SaveExternalConfig` and `SaveSkillFeedsConfig` with a `try/catch` for `IOException or UnauthorizedAccessException or PathTooLongException` and surface the error via `SetStatus`. Apply the same fix to `WorkspacesConfigViewModel.Save()` at line 148. This matches the existing pattern used for `Directory.CreateDirectory` in the same file. + +_Verifier:_ All callers of `SaveExternalConfig` and `SaveSkillFeedsConfig` go through `CommitStructural`/`CommitSourceAction` which have no exception handling, and `WorkspacesConfigViewModel.Save()` line 148 is outside the only try/catch in that method — any disk-write IO error crashes the TUI event loop rather than surfacing via `SetStatus`. + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:115` +**Unhandled exception in RunWithOrchestrator leaves IsRunning=true, UI permanently frozen** + +RunWithOrchestrator catches only OperationCanceledException when overallCts.IsCancellationRequested. Any other exception propagating from RunHealthCheckCoreAsync — for example a TaskCanceledException not matching the filter, an ObjectDisposedException, or an unexpected exception from WriteConfig — escapes unhandled. Because IsRunning and IsComplete are set in RunHealthCheckCoreAsync's body (not a finally block), they stay at IsRunning=true / IsComplete=false. The task stored in HealthCheckCompletion carries the unobserved exception. The UI is stuck: the guard at InitWizardViewModel.GoNext (line 161) checks `!healthStep.IsRunning.Value && !healthStep.IsComplete.Value`, which never becomes true again, so the user can never retry. The only exit is Ctrl+Q. + +_Fix:_ Wrap RunHealthCheckCoreAsync in a try/catch-all inside RunWithOrchestrator, or move `IsRunning.Value = false; IsComplete.Value = true; NotifyChanged();` into a finally block at the bottom of RunHealthCheckCoreAsync. All exit paths through that method should set IsComplete=true. + +_Verifier:_ The bug is real: any non-cancellation exception from `RunHealthCheckCoreAsync` (most likely from `RunHealthChecksAsync` or `StartIfNeededAndPollAsync`) leaves `IsRunning=true/IsComplete=false` permanently, freezing the UI; the fix is a `finally` block setting both flags or a catch-all in `RunWithOrchestrator`. + +### [CONFIRMED] security — `src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs:533` +**BuildAllowedServerList mutates the live in-memory profile object** + +`BuildAllowedServerList` (called from `SaveServerAccess`) directly mutates `profile.McpServersMode = ToolProfileMode.Allowlist` (line 533) and `profile.AllowedMcpServers = serverList` (line 539) on the in-memory `ToolAudienceProfile` object returned by `ResolveProfile`. `ResolveProfile` returns one of `Profiles.Public`, `Profiles.Team`, or `Profiles.Personal` — the same object used by `IsServerAllowed`, `IsToolGranted`, and `GetEffectiveMode` for access-control decisions. Mutating it mid-save means subsequent query calls see the post-save (allowlist) mode even if `Save()` is interrupted by an exception after the mutation but before the file write. On a multi-server save loop this also means the second server's `BuildAllowedServerList` call reads a profile that was already coerced to Allowlist, losing the original All-mode expansion for any servers beyond the first. + +_Fix:_ Work on a local copy instead of the live profile object. Read `profile.McpServersMode` and `profile.AllowedMcpServers` into local variables at the start, compute the new list, then write those values directly to the serialization dictionary (`audienceSection["McpServersMode"]` and `audienceSection["AllowedMcpServers"]`) without touching the in-memory profile at all. + +_Verifier:_ Both defects are real: exception-after-mutation leaves the in-memory ACL in a coerced allowlist state, and the multi-server save loop reads an already-mutated profile for every entry beyond the first of the same audience. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:634` +**Silent fallback to Personal posture when DeploymentPosture cannot be parsed** + +`ReadPosture` (line 634–643) silently returns `DeploymentPosture.Personal` when `Enum.TryParse` fails (unrecognised string, numeric out-of-range, etc.). `Personal` posture is the most permissive: `SavePosture` maps it to `ShellExecutionMode.HostAllowed` (line 469–470). The consequence is that a config whose stored posture string is unrecognised (e.g., a stale value from a renamed enum member, or a hand-edited file) will be displayed as `Personal` in the Security & Access menu and — if the operator happens to re-save audience profiles — will silently overwrite the profiles with the widest defaults. The daemon's own `TrustContextPolicy.ResolveDeploymentPosture` (line 337) correctly falls back to `DeploymentPosture.Public` when `strictDefaults: true`, so the UI and the runtime are using opposite safe-failure directions. CLAUDE.md forbids silent fallbacks on security-relevant paths. + +_Fix:_ Surface a parse failure as an explicit error rather than silently assuming Personal. One option: return `DeploymentPosture?` (nullable) and render a visible warning ('Unknown posture — verify your config') in place of the posture label. Alternatively mirror the runtime fallback and return `DeploymentPosture.Public` to stay fail-closed. + +_Verifier:_ The silent `Personal` fallback in `ReadPosture` is directly contradicted by the runtime's own `TrustContextPolicy.ResolveDeploymentPosture` which defaults to the most restrictive `Public` posture, creating an exploitable mismatch: a hand-edited or stale-enum config value would be treated as maximally permissive by the UI but maximally restrictive by the daemon until the operator re-saves through the UI and locks in `HostAllowed`. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:519` +**Fix-credentials writes secrets to disk before probe succeeds** + +In `SubmitFixCredentials`, the updated API key is written to `secrets.json` (line 536–545) and the updated endpoint is written to `netclaw.json` (lines 547–558) before `StartProbe()` is called and before the probe result is checked. If the probe fails the user is left with an invalid credential on disk that replaces the old one, with no rollback. The config write path in ProviderManagerViewModel for normal add flows correctly defers the write to `WriteProviderConfig()` after `result.Success` (line 966–969). The fix-credentials path bypasses that guard entirely. This means a typo in the new API key permanently clobbers the old secret. + +_Fix:_ Defer the secrets/config write from `SubmitFixCredentials` to the `IsFixFlow` success branch inside `ProbeProviderAsync` (around line 955). Capture `FixApiKey` and `FixEndpoint` to local state, then write only when `result.Success` is true. This matches the existing pattern for the normal add flow. + +_Verifier:_ The fix-credentials path overwrites `secrets.json` and `netclaw.json` before the probe runs and has no rollback, permanently clobbering the old credential if the user types a bad API key. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs:392` +**Non-atomic write to devices.json corrupts the paired-device registry on interrupted saves** + +`WritePairedDevices` (called from `EnsureCurrentClientPaired`) uses `File.WriteAllText` (line 392) — not a write-to-temp-then-rename pattern. If the CLI process is killed or the machine loses power between truncation and the completed write, `devices.json` is left empty or partially written. `ReadPairedDevices` then returns `[]` on a `JsonException`, so the daemon starts with zero valid paired devices and rejects all inbound connections. The identical non-atomic pattern occurs in `WriteBootstrapDevice` (line 472). The security impact is an accidental self-lockout: after a power failure during `netclaw config`, no client can reach the daemon until `netclaw doctor --fix` or a manual device-pair is performed. + +_Fix:_ Write to a sibling temp file (e.g. `devices.json.tmp`) and then `File.Move(..., overwrite: true)` to replace atomically. Apply the same pattern in `WriteBootstrapDevice`. `File.SetUnixFileMode` can be applied to the temp file before the rename. + +_Verifier:_ Both write sites use `File.WriteAllText` with no temp-file-then-rename guard, and `ReadPairedDevices` silently swallows a `JsonException` from a torn write, so a process kill or power loss during the narrow write window leaves `devices.json` corrupted and the daemon self-locked out. + +## MEDIUM (17) + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs:161` +**async void subscribe leaks exceptions from RetryValidation path** + +BuildProbeWarningDialog subscribes with `Subscribe(async selected => { ... await ViewModel.SubmitCurrentConfigurationAsync(); ... })`. In R3, Subscribe with an async lambda compiles to an async void delegate. SubmitCurrentConfigurationAsync has no top-level try/catch — only ProbeAsync (line 467) does. Any IOException from SaveWithoutProbeOverride (called inside RunDynamicValidationAsync on persist success), or an InvalidOperationException from CommitField with an unexpected path, propagates through the async void context and escapes to the thread pool, crashing the process. The Enter-key path is guarded by SubmitCurrentConfigurationFromInputAsync's try/catch (line 306), but the RetryValidation dialog path is not. + +_Fix:_ Wrap the async lambda body in try/catch(Exception ex) that sets Status.Value to an error message and calls RequestRedraw(), mirroring the pattern in SubmitCurrentConfigurationFromInputAsync. Alternatively, call SubmitCurrentConfigurationFromInputAsync (which already wraps) instead of SubmitCurrentConfigurationAsync. + +_Verifier:_ The fix is to call `SubmitCurrentConfigurationFromInputAsync` instead of `SubmitCurrentConfigurationAsync` in the RetryValidation case (line 173), which already has the required try/catch wrapper and mirrors the Enter-key path. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:748` +**Synchronous blocking on async task (`ValidateAddRemoteTokenReachabilityAsync(...).AsTask().GetAwaiter().GetResult()`) can stall the UI thread** + +Both `CommitAddRemoteToken` (line 748) and `CommitChangeLocation` (line 775) call `ValidateAddRemoteTokenReachabilityAsync` and `ValidateChangeLocationReachabilityAsync` via `.AsTask().GetAwaiter().GetResult()`. These are called from the UI key-handler path (i.e., from within the Termina render/input loop). The underlying reachability probe uses `HttpClient.Send` (blocking, at line 44 of the `SkillFeedReachabilityProbe`), but wrapping it in a `ValueTask` and then blocking with `.GetResult()` on the UI thread still freezes the terminal UI for the full probe timeout (up to 10 seconds per `timeoutSeconds`). This is a UX correctness issue that also risks starvation under probe load. + +_Fix:_ Run the reachability probe on a background thread explicitly (e.g., `await Task.Run(() => _probe.Probe(...))`) and make the commit methods async, or show a progress indicator and defer the commit result to an async path. + +_Verifier:_ The finding's attribution of the block to .GetAwaiter().GetResult() is technically imprecise — the blocking occurs inside ValidateAddRemoteTokenReachabilityAsync via synchronous HttpClient.Send before ValueTask.FromResult is called — but the UI-thread freeze of up to 10 seconds is fully confirmed. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:714` +**RevalidateAsync is fire-and-forget with CancellationToken.None and no cancellation path** + +`RevalidateDetailProvider` (line 714) launches `RevalidateAsync` as a fire-and-forget task (`_ = RevalidateAsync(DetailProvider)`). Unlike the main probe path (`ProbeProviderAsync`) which uses a tracked `_probeCts` that `CancelProbe()` / `GoBackToList()` / `Dispose()` cancels, `RevalidateAsync` uses `CancellationToken.None` throughout and stores no task reference. If the user navigates away (triggers `GoBackToList`) or the view model is disposed while a re-validation is in flight, the task continues running and then calls `NotifyStateChanged()` on the disposed VM — which mutates `StateVersion.Value` on a disposed `ReactiveProperty`. Unhandled exceptions from the empty `catch {}` block are also silently discarded. + +_Fix:_ Store `RevalidateAsync` in a tracked `Task?` field (similar to `ProbeCompletion`/`EagerProbeCompletion`) and pass `_probeCts.Token` (or a separate dedicated CTS) so `GoBackToList` and `Dispose` can cancel it. Add a null-guard or disposed-flag check before calling `NotifyStateChanged()` in the continuation. + +_Verifier:_ The race is real and reproducible whenever the user navigates away while a revalidation probe is in flight: `NotifyStateChanged()` at line 743 sits outside the try-catch, so the `ObjectDisposedException` from writing to the disposed `StateVersion` ReactiveProperty escapes into the fire-and-forgot task and is silently lost. + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs:300` +**Data race on LastChannelResolution and ChannelEntry.DisplayName between background Task.Run and UI thread** + +`StartBackgroundChannelResolution()` writes `LastChannelResolution = result` (line 308) and mutates `entry.DisplayName = resolved.ToDisplayName()` (line 339) from a thread-pool thread. The UI thread reads `LastChannelResolution` in `ContributeHealthChecksAsync` (lines 244, 256) and `OnLeave` (line 178), and reads `entry.DisplayName` during render. `LastChannelResolution` is a plain auto-property field with no `volatile`, `Interlocked`, or lock. `ChannelEntry.DisplayName` is a plain mutable `string` property. There is no memory barrier or synchronization between producer (Task.Run) and consumers. This is a real C# memory-model race: the UI thread can observe a torn or stale reference. + +_Fix:_ Replace the background fire-and-forget with an `await`-able prefetch, or guard `LastChannelResolution` with `volatile` and protect the `ChannelEntries` list mutation with a lock or a marshal back to the UI thread (e.g. capture the `SynchronizationContext` before `Task.Run` and post the mutation back). The simplest safe fix: remove the background prefetch and do resolution entirely inside `ContributeHealthChecksAsync`, which already runs serially on the health-check phase. + +_Verifier:_ The race is architecturally real with no formal synchronization, but practical impact is low: the background prefetch starts when the user advances past sub-step 2, and the conflicting reads only occur when the user actively navigates to OnLeave or triggers the health-check step — a substantial human-paced time gap that makes a torn read unlikely in practice, but not impossible (e.g., a fast network response racing the user's immediate next keypress). + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:582` +**Single() in ApplyAddChannelAsync throws when resolved channel ID equals "dm" and DMs are enabled** + +After AddChannelAsync resolves and stores the new channel (line 579), it calls GetChannelRows().Single(entry => entry.row.Id == channelId) at line 582-585 to position _channelRowIndex. GetChannelRows() includes a DM row with Id='dm' whenever AllowDirectMessages is true (line 411-417). If the probe returns a channelId of exactly "dm" — possible for a Mattermost channel with that internal ID, or for a Discord guild channel that coincidentally resolves to that string — Single() finds two matching rows and throws InvalidOperationException, crashing the AddChannel flow with an unhandled exception propagated through ApplyAddChannel -> GetAwaiter().GetResult() (line 526). + +_Fix:_ Replace Single() with FirstOrDefault() on non-action, non-DM rows, or match explicitly against `!row.IsDirectMessage && !row.IsAction && row.Id == channelId`. Also guard the result against null/not-found rather than assuming the channel is always present in the rows list. + +_Verifier:_ The bug is real but requires the improbable combination of a user entering "dm" as a channel ID, the probe accepting it, and AllowDirectMessages being true — medium severity is correct. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:477` +**Arrow-key audience changes on the channel-permissions list are never persisted** + +`ChangeSelectedChannelAudience` (called from the page on LeftArrow/RightArrow at `ChannelsConfigPage.cs:504,507`) mutates `_channelAudiences` and calls `NotifyContentChanged()` but does NOT call `AutosaveCompletedAction` or `WriteChannelConfigToDisk`. Every other mutation in the channel-permissions screen (`RemoveSelectedChannel`, `ApplyAudienceSelection`, `OpenChannelPermissionsAfterInitialSetup`) autosaves. If the user presses ←/→ to cycle the audience and then navigates away without pressing Enter (which would open `EditAudience` screen and call `ApplyAudienceSelection`), the audience change is silently lost on the next `SaveAsync` reload (which calls `LoadAudienceDrafts(savedDraft)`, clobbering in-memory state with the persisted state). + +_Fix:_ Either call `AutosaveCompletedAction(...)` at the end of `ChangeSelectedChannelAudience` (matching every other mutation), or remove the ←/→ shortcut from the channel-permissions screen and rely solely on the Enter → EditAudience → Enter flow that does save. + +_Verifier:_ Every other mutation on the channel-permissions screen calls `AutosaveCompletedAction`; `ChangeSelectedChannelAudience` is the sole exception, so its audience change is silently lost on the next `SaveAsync` reload. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:29` +**Blocking HTTP probe freezes the TUI input loop for up to 10 seconds** + +`SkillFeedReachabilityProbe.Probe()` calls `client.Send(request, cts.Token)` — the synchronous blocking overload — clamped to a 10-second timeout. This method is invoked directly on the TUI event-loop thread from `ProbePendingRemoteThenReview()` (line 1138), `TestSource()` (line 1332), `ValidateChangeLocationReachabilityAsync` (line 1462), `ValidateAddRemoteTokenReachabilityAsync` (line 1096/1103), and both `SaveRemoteUrlChange` / `SaveRotatedRemoteToken`. During the probe the entire TUI is frozen: no render, no keypress, no Ctrl+Q. A server that holds the TCP connection open without responding will stall the UI for the full 10-second window. The `ValueTask.AsTask().GetAwaiter().GetResult()` wrappers in `CommitAddRemoteToken` (line 748) and `CommitChangeLocation` (line 775) look like async code but those methods return `ValueTask.FromResult(...)` synchronously — the probe itself is the blocking call. + +_Fix:_ Move `SkillFeedReachabilityProbe.Probe` to a true async implementation using `client.SendAsync` and switch `ISkillFeedReachabilityProbe` to return `Task`. Run the probe on a background thread via `Task.Run` so the TUI event loop stays responsive, updating the status bar with a 'Testing...' indicator while the probe is in-flight. Alternatively, cap the probe timeout to 3–4 seconds for UI flows specifically. + +_Verifier:_ Every probe call site is synchronous on the TUI event-loop thread with no background-thread offload, freezing rendering and input for up to 10 seconds (the effective clamp in `Probe()`, line 33); the finding is accurate and the medium severity is appropriate since the freeze is bounded by the clamp rather than user-configured timeouts which can be 30–60 s. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs:289` +**Editing a webhook without re-entering the auth header silently preserves the old header; there is no way to intentionally clear it** + +In SaveWebhookForm (line 278-298), `newAuth = !string.IsNullOrWhiteSpace(authDraft)`. When editing an existing webhook with HasAuthHeader=true and leaving the auth field blank, newAuth=false so `target.Headers = ...` is skipped (line 289-295). The persisted `target.Headers` from the freshly loaded JSON is left unchanged — the old header is preserved. This is the intended "preserve" behavior documented by EditingHasPersistedAuthHeader. However, there is no mechanism to intentionally remove a persisted auth header: entering a blank value preserves it, and there is no "clear header" gesture. A user who wants to remove an auth header has no path to do so through the TUI. + +_Fix:_ Add an explicit removal gesture (e.g., entering a single hyphen `-` in the auth field, or a dedicated "[D] Delete header" keybinding). When the removal signal is present, set `target.Headers = null` or `new Dictionary<...>()` before persisting. + +_Verifier:_ A user who has set an auth header on a webhook has no TUI path to remove it — blank input silently preserves the old header and no deletion gesture exists. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs:299` +**BuildChannelAudiences uses channel name as ChannelAudiences key when channel resolution failed — runtime ACL cannot look up by name** + +`ResolveChannelAudienceKey(entry)` (line 315) returns `entry.Id` (the channel name, e.g. `general`) when `LastChannelResolution is null` or when the name cannot be matched to a resolved ID. `ContributeConfig` writes this as the key into `SlackConfigSection.ChannelAudiences`. The Slack runtime adapter resolves channel IDs, not names — it expects the canonical Slack channel ID (e.g. `C012AB3CD`) as the audience map key. When health check was skipped or channel resolution failed, the wizard silently writes name-keyed entries that the runtime ACL will never match, effectively dropping the audience configuration without any error. The CLAUDE.md constitution prohibits silent fallbacks on security paths. + +_Fix:_ If `LastChannelResolution` is null or contains unresolved channels, either (a) block the wizard from proceeding (require successful channel resolution before config is written), or (b) write an explicit warning to the health-check results and omit `ChannelAudiences` from the config so the runtime falls back to posture defaults rather than silently using a dead key. + +_Verifier:_ The mechanism is real — name-keyed `ChannelAudiences` entries are silently written when resolution is skipped, and the runtime ACL key-lookup exclusively uses Slack channel IDs; the effective impact is partially mitigated because `AllowedChannelIds` is also null in the same path (blocking all channels anyway), but the silent, dead config write on a security path still violates the constitution's no-silent-fallbacks rule. + +### [CONFIRMED] design — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:127` +**Items property triggers a full disk read on every access, including every key press and every render** + +`public IReadOnlyList Items => BuildItems()` (line 127) is a computed property with no caching. `BuildItems()` calls `ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath)` which reads and deserialises the config file from disk. In `SecurityAccessPage`, `ViewModel.Items` is accessed in `BuildSecurityMenu` (render path) and also in `MoveSelection` (line 140) and `ActivateSelected` (line 160). A single ↑/↓ key press in the menu therefore triggers two full file reads (one in `MoveSelection`, one in the triggered render). Similarly `CurrentPosture` (line 135) is a property that reads disk; it is called four to six times inside a single `BuildAudienceProfile` render. The cumulative overhead is perceptible on slow filesystems or NFS homes. + +_Fix:_ Cache the loaded config dictionary for the duration of a single render cycle, or snapshot it in `OpenPostureEditor`/`OpenAudienceList` and invalidate the snapshot on explicit saves. At minimum, local variables should be used inside methods that call `CurrentPosture` or `Items` more than once. + +_Verifier:_ Every call to `Items` or `CurrentPosture` unconditionally reads and deserialises the config file from disk; a single ↑/↓ keypress in the menu triggers at least two full file reads, and the posture/audience render paths each trigger four or more, with no caching anywhere in the call chain. + +### [CONFIRMED] design — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:161` +**God-object: `SkillSourcesConfigViewModel` mixes two disjoint persistence backends, inline config serialization, probing, and all 11 screen transitions — 2,267 lines** + +The viewmodel directly owns: (1) `ExternalSkillsConfig` (local folder) JSON read/write — `LoadExternalConfig`, `SaveExternalConfig`, `BuildExternalSkillsSection`; (2) `SkillFeedsConfigDocument` (remote feeds) JSON read/write — `LoadSkillFeedsSection`, `SaveSkillFeedsConfig`, `BuildSkillFeedsSection`; (3) `ISkillFeedReachabilityProbe` with a `_saveAnywayFingerprint` probe-bypass mechanism; (4) inline decryption (`TryDecryptExistingApiKey`) and encryption (`ProtectApiKeyForConfig`); (5) the entire add-local / add-remote multi-screen wizard (11 `SkillSourcesScreen` values); (6) display-string formatting helpers. The two persistence backends are not abstracted at all — both `SaveExternalConfig` and `SaveSkillFeedsConfig` rebuild the entire config root from disk, mutate it, and write it back, leading to a read-modify-write per operation (6 disk reads in a single `ToggleEnabled` for a remote source). + +_Fix:_ Extract a `LocalSkillSourceRepository` and `RemoteSkillFeedRepository` for the two config backends. Move the add-flow state machine (`_pendingLocalPath`, `_pendingRemoteUrl`, `_pendingRemoteAuthMode`, `_pendingRemoteApiKey`, `_pendingRemoteTimeoutSeconds`, `_saveAnywayFingerprint`, `_editingAction`) into a `SkillSourceAddFlowState` struct. Move probe/validation methods to a `SkillSourceValidator`. The viewmodel becomes a thin coordinator. + +_Verifier:_ The "6 disk reads in a single ToggleEnabled" claim is an overcount — the actual path issues 3 redundant reads (load, save, reload), not 6 — but the core god-object design finding is entirely accurate and the redundant read-modify-write pattern is confirmed at every mutation site. + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:552` +**ConvertConfigObject throws unguarded from every audience-profile mutation path** + +`LoadAudienceProfiles` (line 552–558) calls `ConvertConfigObject`, which throws `InvalidOperationException` when the stored JSON cannot be deserialised (line 857–862). This method is called from `ToggleToolGroup`, `CycleFileAccess`, `CycleIncomingAttachments`, `GetSelectedProfile`, and `AudienceHasOverrides`. None of those callers catch the exception. If `Tools.AudienceProfiles` is present in the config but has a schema mismatch (e.g., after a migration that changed the shape of `ToolAudienceProfiles`), every keystroke on the Audience Profile sub-page will throw, crashing the render loop. The same unguarded `ConvertConfigObject` path exists in `ReadAudienceProfilesSummary` (line 621) and `AudienceProfilesCustomized` (line 567). + +_Fix:_ Catch `InvalidOperationException` in `LoadAudienceProfiles` and fall back to `BuildPostureProfiles(ReadPosture(config))` with a status warning to the operator. Do the same in `AudienceProfilesCustomized` (treat as uncustomised on failure) and `ReadAudienceProfilesSummary`. + +_Verifier:_ A stale or migrated `Tools.AudienceProfiles` JSON blob would cause every keystroke on the Audience Profile sub-page — and every render of the Audience List page — to throw an unhandled `InvalidOperationException`, crashing the TUI render loop. + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs:245` +**SkillSourcesSummary and TelemetrySummary call LoadSection unconditionally — throws on malformed config during dashboard layout render** + +`SkillSourcesSummary` calls `ConfigFileHelper.LoadSection(config, "ExternalSkills").Sources.Count` and `LoadSection(config, "SkillFeeds").Feeds.Count` (lines 247–248) without any exception handling. `LoadSection` deserializes the raw JSON section via `JsonSerializer.Deserialize` — if the section exists but is malformed (e.g. `Sources` is a number instead of a list after a hand-edited config), deserialization throws a `JsonException`. This exception propagates out of `Summarize`, through `StatusFor`, and into `BuildLayout` / `BuildList` in `ConfigDashboardPage`. `BuildLayout` is called from the Termina render loop, so an unhandled exception here can crash the dashboard page entirely. The same applies to `TelemetrySummary` at line 272 via `LoadSection`. All other summary methods use `TryGetPathValue` which returns false on type mismatches. + +_Fix:_ Wrap the `LoadSection` calls in `try/catch (Exception)` and return a fallback string like `"– config error"` on failure. Alternatively, refactor to use `TryGetPathValue` for the count fields, consistent with how other summaries read config. + +_Verifier:_ A hand-edited config with e.g. `"Sources": 42` instead of an array will throw a `JsonException` from `JsonSerializer.Deserialize` inside `DeserializeSection`, propagate unhandled through the render loop, and crash the dashboard page — no guard exists anywhere in the call chain. + +### [CONFIRMED] resource-leak — `src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs:24` +**_contentSubscriptions CompositeDisposable is never disposed on page teardown** + +SearchConfigEditorPage declares `private readonly CompositeDisposable _contentSubscriptions = []` (line 24) and populates it via `.DisposeWith(_contentSubscriptions)` inside BuildProbeWarningDialog (line 180). The DynamicLayoutNode lambda calls `_contentSubscriptions.Clear()` on each rebuild (line 74), which disposes outstanding subscriptions — correct for the rebuild cycle. However, the page has no Dispose() override, and ReactivePage.Dispose() (confirmed by decompiling Termina 0.12.1) only disposes its own private `_subscriptions` and `_layoutSubscriptions` fields. When the framework disposes the page, _contentSubscriptions itself is never disposed. If the page is torn down while a ProbeWarning dialog subscription is live (e.g., the user quits during the dialog), that subscription is leaked. + +_Fix:_ Add `protected override void Dispose(bool disposing)` (or override `Dispose()`) in SearchConfigEditorPage and call `_contentSubscriptions.Dispose()` there. Pattern: `public override void Dispose() { _contentSubscriptions.Dispose(); base.Dispose(); }`. + +_Verifier:_ The leak is real but bounded to the ProbeWarning dialog subscription lifetime — it terminates when the upstream observable completes (page/process shutdown), so in practice this is a short-lived leak on normal Ctrl+Q quit, not an indefinite one. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigViewModel.cs:292` +**IsServerEnabled falls back to true for unrecognized JSON shapes — violates default-deny posture** + +IsServerEnabled (line 275-296) returns true in two fallback branches: when raw is a JsonElement that is an Object but lacks an Enabled property (line 293), and when raw is any other type (line 296). This means an MCP server entry that exists in the config file but omits the Enabled field is treated as enabled=true. In a default-deny repository, the invariant should be: absent = disabled. A hand-edited or externally synthesized config entry without Enabled could silently activate a browser MCP server without the operator ever explicitly enabling it via the TUI. This is the 'silent fallback to permissive default' anti-pattern prohibited by CLAUDE.md. + +_Fix:_ Change both fallback branches to return false. A server entry must have an explicit `"Enabled": true` to be considered enabled. If the intent is backward compatibility (entries created before Enabled was added), document that explicitly and add a note to the migration guidance rather than silently enabling. + +_Verifier:_ Any hand-edited or externally generated config entry for the Playwright or ChromeDevTools MCP server that omits the `Enabled` field will be silently treated as enabled=true, violating the repo's explicit no-silent-fallback and default-deny rules; both fallback `return true` branches (lines 293 and 296) are reachable in practice. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:2111` +**Plaintext API keys in config are silently accepted and used without any user notification** + +`TryDecryptExistingApiKey` at line 2116 checks `ISecretsProtector.IsEncrypted(apiKey)` and, if the key is not prefixed with `"ENC:"`, returns the raw value as `plaintext` with no error or warning. A manually edited or migrated `netclaw.json` with a plaintext bearer token will be probed and used without informing the operator that the credential is stored unprotected. This contradicts the CLAUDE.md rule: 'No silent fallbacks... on security-relevant paths'. The token is subsequently used in the Authorization header (line 42 in the probe) and is exposed in `Draft.Value` (a `public ReactiveProperty`) during the RotateToken flow. + +_Fix:_ When `TryDecryptExistingApiKey` detects a non-encrypted key, set a status warning (via the existing `SetStatus` path) informing the user that the stored credential is unencrypted and recommend rotating it. Alternatively, opportunistically re-encrypt it on next read by calling `ProtectApiKeyForConfig` and writing the encrypted value back before use. At minimum, add a log or status message: 'Skill server token is stored as plaintext; use Rotate token to re-encrypt.' + +_Verifier:_ The plaintext fallback in `TryDecryptExistingApiKey` is genuinely silent: `error` stays `string.Empty` so none of the three callers' `SetStatus` guards fire, and no other warning path exists for the unencrypted case — violating the CLAUDE.md "no silent fallbacks on security-relevant paths" rule. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs:163` +**DM trust-audience derives from AllowedUserIds count but RestrictToSpecificUsers state is not the authoritative gate** + +In `OnLeave()` at line 163, the DM audience is computed via `ChannelAudienceDefaults.ForDirectMessage(posture, ParseUserIds(AllowedUserIdsInput).Count)`. The count of parsed user IDs is used as the discriminant (`count == 1` → `Personal`, otherwise posture-based). If the user sets `RestrictToSpecificUsers = true` and enters 2+ user IDs, the DM audience becomes posture-based (potentially `Team` or `Public`) despite restriction being chosen — an implicit security escalation. The same pattern exists identically in `DiscordStepViewModel.cs:166` and `MattermostStepViewModel.cs:204`. The count-based discrimination was designed for the `allowedUserCount == 1` = personal-use case, but when a user explicitly picks "restrict to specific users" with 2+ IDs, `Team` or `Public` audience is inconsistent with their intent. This is a trust-level mismatch, not just a UI mismatch. + +_Fix:_ When `RestrictToSpecificUsers = true`, force the DM audience to `Personal` regardless of user count, since the user has expressed an explicit restriction intent. The current `ForDirectMessage` signature conflates two orthogonal axes (posture and restriction intent) via a count heuristic. Either add a `bool restrict` overload or apply `TrustAudience.Personal` directly when `RestrictToSpecificUsers` is true. + +_Verifier:_ The trust escalation (`Personal` → `Team`) is bounded to the explicitly allow-listed users (unauthenticated senders are still denied by `IsAllowedUser`), so this is a privilege escalation within the trusted set, not an open-access bypass — medium severity is correct. + +## LOW (53) + +### [CONFIRMED] concurrency — `src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs:184` +**Fire-and-forget `RunProbeTimerAsync` task with no error surface** + +`_ = RunProbeTimerAsync(ct)` is used at lines 184, 281, 293 (and also in `ModelManagerViewModel` line 347 and `ProviderManagerViewModel` lines 474, 491, 907). `RunProbeTimerAsync` loops on `Task.Delay(1000, ct)` and writes `ProbeElapsedSeconds.Value++`. If `ProbeElapsedSeconds` (a `ReactiveProperty`) is disposed before the timer task observes cancellation — e.g. the user navigates away and the ViewModel is disposed before the CTS fires — the write to the disposed `ReactiveProperty` will throw `ObjectDisposedException`. Because the task is fire-and-forget, this exception is unobserved and silently terminates the timer. In R3, writing to a disposed `ReactiveProperty` throws immediately. + +_Fix:_ Await `RunProbeTimerAsync` as part of the probe sequence (cancel and await it in `CancelProbe`/`finally`), or guard the write with a null/disposed check. At minimum, wrap the `ProbeElapsedSeconds.Value++` write in a try/catch for `ObjectDisposedException`. + +_Verifier:_ The race window is very narrow (between `Task.Delay` completion and the next line), the result is a silently swallowed `ObjectDisposedException` in a fire-and-forget task rather than any data corruption or user-visible failure, making this low-severity despite the mechanism being real. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs:282` +**Webhook edit silently becomes a no-op when index is out of range at persist time** + +SaveWebhookForm (line 280-307) captures `editing = _editingWebhookIndex` before calling PersistWebhooks. Inside PersistWebhooks the webhook list is reloaded from disk (line 350). If the file was externally modified between BeginEditWebhook and SaveWebhookForm — reducing the webhook count — then `editing is { } index && index < webhooks.Count` (line 282) evaluates false, producing `new WebhookTarget()`. But the guard at line 297 is `if (editing is null)`, which is false (editing = 0), so the new target is never appended. The entire edit is silently discarded: PersistWebhooks writes the unchanged webhook list, ReloadState reports success with message "Webhook X updated. Saved.", and the UI shows the result as saved — but the user's changes are gone. The same race applies to RemoveSelectedWebhook, though there the stale-index case correctly removes a different webhook rather than silently doing nothing. + +_Fix:_ When `editing is { } index && index >= webhooks.Count`, treat this as an explicit error: set Status to an error message ("Webhook list changed unexpectedly; reload and retry."), return without calling ConfigFileHelper.WriteConfigFile, and set saved = false. Do not silently report success. + +_Verifier:_ The race requires an external process to shorten the webhook list between BeginEditWebhook and SaveWebhookForm — an unlikely but real scenario; severity is medium in theory but low in practice for a local single-user CLI tool. + +### [CONFIRMED] correctness — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:777` +**GoBack from Loading or List state routes to dashboard/exit instead of staying — user can accidentally quit during eager probe** + +`GoBack()` handles `AddSelectType`, `AddName`, `AddSelectAuth`, `AddOAuthDeviceFlow`, `AddBrowserOAuthFlow`, `AddCredentials`, `AddValidating`, `AddComplete`, `Details`, `FixCredentials`, `RemoveConfirm`, and `RenameProvider` explicitly. `ProviderManagerState.Loading` and `ProviderManagerState.List` are not handled and fall through to the `default` branch (line 830), which in the embedded-config scenario immediately navigates to `/config` and in the standalone scenario calls `Shutdown()`. If the user presses Esc during the eager probe startup (state = `Loading`), or from the normal list (state = `List`), the outcome is correct for `List` (user wants to leave) but during `Loading` the eager `ProbeAllConfiguredAsync` task is still running with `CancellationToken.None` against each provider. The tasks are not cancelled and continue posting `NotifyStateChanged()` to a view model that may be disposed. + +_Fix:_ Add an explicit `case ProviderManagerState.Loading:` that cancels the eager probe (set a CTS for `ProbeAllConfiguredAsync`) and then falls through to the existing dashboard/exit logic. The existing `CancelProbe()` only covers `_probeCts`, not `EagerProbeCompletion`. + +_Verifier:_ The `Loading`→`default` routing during eager probe is confirmed, and the tasks genuinely continue posting to a potentially-disposed view model, but the observable impact is a background-task orphan with post-disposal write attempts rather than a security or data-loss bug — real but low urgency. + +### [CONFIRMED] design — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:22` +**God-object: `ChannelsConfigViewModel` owns parsing, validation, probing, persistence, rendering state, and a full embedded channel picker — 2,283 lines** + +The viewmodel owns: (1) the entire `ChannelPickerStepViewModel` sub-wizard (with its own sub-steps, validation, and adapter sub-VMs); (2) `ChannelsConfigPersistenceMapper` (nested class, 500+ lines doing config load/build); (3) `_channelAudiences` state duplicated beside the `Step` sub-VM's own channel-list state; (4) `ChannelResolveOutcome`, `ChannelAccessValidation`, `ChannelAccessOutcome` private records; (5) static helpers (`IsSlackChannelId`, `Clamp`, `Wrap`, `Pluralize`, `Normalize`, `NormalizeChannelId`); (6) background label-resolution lifecycle; (7) screen-machine state (`_activeAdapterType`, `_managementMenuIndex`, eight screen enum values, five credential staging fields). The `ChannelsConfigPersistenceMapper` and its draft types (`ChannelsConfigDraft`, `SlackChannelDraft`, etc.) are defined at the bottom of the same file. Split responsibility: the mapper+drafts belong in their own file; validation types belong in their own file; screen-routing logic belongs in a coordinator. + +_Fix:_ Extract `ChannelsConfigPersistenceMapper` and the draft/record types to a separate file. Extract the multi-screen state machine (ManageChannels, AddChannel, AllowedUsers, DirectMessages, RotateCredentials, ResetConfirm navigation) into a `ChannelsManagementCoordinator`. Separate the channel-probing logic (`ValidateChannelAccessAsync`, `ResolveSingleChannelAsync`, label refresh) into a `ChannelProbeService`. This brings the viewmodel down to a thin coordinator that wires the pieces together. + +_Verifier:_ The god-object characterization is accurate — the file genuinely owns parsing, persistence, probing, screen routing, validation types, and draft types in one 2,282-line file — but this is a pure maintainability/cohesion concern with no correctness or security impact, which makes "medium" an overstatement; "low" is appropriate for a design smell that carries no runtime risk. + +### [CONFIRMED] design — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:135` +**Repeated config-file reload per property access causes TOCTOU and performance issues** + +`CurrentPosture` (line 135) re-reads and deserializes `netclaw-config.json` from disk on every access (`ReadPosture(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath))`). This property is called in `BuildItems()` (which is a computed property called from the page on every layout invalidation), `ApplySelectedPosture()`, `IsSystemDefaultAudience()`, `AudienceHasOverrides()`, `ResetSelectedAudienceProfile()`, and `SavePosture()`. Multiple calls within a single user interaction (e.g., `ApplySelectedCascadeOption` at line 269 checks `_pendingPosture`, then `SavePosture` calls `CurrentPosture` twice) may see different values if the config file is modified externally between calls — a TOCTOU condition. On SSD this is also slow: a layout redraw calls `ConfigFileHelper.LoadJsonDict` multiple times per frame. + +_Fix:_ Cache the loaded config in a private field, invalidated only after a save operation. Read once at the start of each operation and pass it through, rather than re-reading per property access. + +_Verifier:_ The TOCTOU risk is theoretical in this single-user local TUI (no realistic concurrent writer), so the real impact is repeated synchronous disk I/O per render frame rather than a security or data-corruption hazard; severity should be low (performance/design smell), not medium. + +### [CONFIRMED] design — `src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs:169` +**AddSectionEditor registers duplicate SectionEditorRegistry descriptors on every call** + +Each call to `AddSectionEditor` unconditionally calls `services.AddSingleton()`. With 3 calls (config path) or 4 calls (init path), 3–4 `ServiceDescriptor` entries are accumulated for the same type. `GetRequiredService()` resolves to the last descriptor (MS DI convention), which instantiates one registry with all `SectionEditorRegistration` entries in scope — correct today. But the N-1 dead descriptors make `GetServices()` return N instances (each receiving all registrations), meaning any consumer that iterates the open-generic enumerable gets N duplicated registries with overlapping duplicate-ID collisions and N editor lifecycles. A future audit test or framework introspection will trigger the `InvalidOperationException` guard in the constructor on the second instance. + +_Fix:_ Replace `services.AddSingleton()` with `services.TryAddSingleton()` (requires `using Microsoft.Extensions.DependencyInjection.Extensions`). This ensures exactly one descriptor is registered regardless of how many times `AddSectionEditor` is called, while still receiving all accumulated `SectionEditorRegistration` entries because resolution is lazy. + +_Verifier:_ The defect is structurally real but the blast radius is limited to the latent path — no current code calls `GetServices()`, so the `InvalidOperationException` cannot fire today; severity is lower than medium because the trigger requires a future code change, not a runtime condition or existing code path. + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:160` +**ValidateChannelAccessAsync runs all three adapter probes sequentially even when an earlier probe blocks save** + +ValidateChannelAccessAsync at line 866 awaits ValidateSlackChannelsAsync, then ValidateDiscordChannelsAsync, then ValidateMattermostChannelsAsync in sequence. Each probe can involve a network round-trip. If the Slack probe returns a blocking issue (line 936), the function still performs two additional network-bound probes unnecessarily. More importantly, if a Slack probe call throws an unhandled exception after the ct check (e.g., a malformed probe result), the function does not short-circuit: the Discord and Mattermost probes still run, and the Slack error may be buried in the result list. The structurally parallel probes are never run in parallel (Task.WhenAll), so a UI that sets Status.Value to 'Validating...' during sequential multi-second probes will appear unresponsive. + +_Fix:_ Short-circuit after the first blocking issue: if `slack.BlockingIssue is not null`, skip the remaining probes and return immediately. For the non-blocking (unresolved-only) path, consider Task.WhenAll for all three probes to reduce wall-clock time. + +_Verifier:_ The bug is real but the practical impact is low: in the common case only one adapter is enabled (making the others return None immediately), and even in multi-adapter setups the wasted probes merely add latency without corrupting data or silently suppressing errors; the exception sub-claim is wrong (an exception aborts the method immediately). + +### [CONFIRMED] error-handling — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:267` +**Silent exception swallow in `ProbeAllConfiguredAsync` concurrent probe tasks** + +The `probeTasks` lambda at line 267 wraps the probe call in a bare `catch { item.Health = ProviderHealthStatus.Unhealthy; }` with no exception type filter and no logging. This catches `OperationCanceledException`, `OutOfMemoryException`, and every other exception class. Because the tasks are composed with `Task.WhenAll`, an `OutOfMemoryException` or similar fatal exception would be silently swallowed and the item would simply display as 'Unhealthy' with no stack trace, error message, or diagnostic. The outer `EagerProbeCompletion` task is stored but never awaited or observed for exceptions either (see `OnActivated`, line 188). + +_Fix:_ Change `catch` to `catch (Exception ex)` and log `ex` to `ProbeDiagnosticsLog` or at minimum capture the exception in `ProbeResult.ErrorMessage`. At minimum re-throw `OutOfMemoryException` and `StackOverflowException` (via `ExceptionDispatchInfo`). + +_Verifier:_ The bare catch is a real diagnostic gap — exceptions produce no log or error message — but fatal exceptions like OOM/SOE would crash the process before reaching the catch anyway, so the actual impact is missing diagnostic context on network/probe errors rather than hidden fatal crashes; severity is low rather than medium. + +### [CONFIRMED] resource-leak — `src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs:496` +**HttpClient created with `new HttpClient()` is never disposed when factory is absent** + +CreateHttpClient() (line 496-497) returns `_httpClientFactory?.CreateClient(string.Empty) ?? new HttpClient()`. When _httpClientFactory is null (the default in tests and when injected as null), each call to ProbeAsync creates a fresh HttpClient and passes it to BraveSearchBackend, SearXngBackend, or DuckDuckGoBackend (lines 472-479). None of those backend types implement IDisposable, so the HttpClient they hold is never disposed. Each "test" or "retry" press leaks an HttpClient and its underlying socket. While the production path injects an IHttpClientFactory (safe), the constructor default is null, making the leak the common path in unit tests and in any deployment that skips factory injection. + +_Fix:_ Either (a) require IHttpClientFactory — remove the nullable and make it a hard dependency so the factory path is always used; or (b) track the created HttpClient in a field, dispose it in SearchConfigEditorViewModel.Dispose(), and never create a new one mid-flight. Option (a) is safer and matches the rest of the codebase pattern. + +_Verifier:_ The leak is real but confined to the null-factory path, which is not exercised in production (factory is always DI-injected there) and not triggered by current tests that omit the factory; severity is lower than medium because the production path is safe, but the null default is a latent trap. + +### [CONFIRMED] resource-leak — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigPage.cs:140` +**_contentSubscriptions is not registered with the page-level Subscriptions disposable** + +`_contentSubscriptions` (line 20) is a standalone `CompositeDisposable` that is only ever `Clear()`-ed inside the `DynamicLayoutNode` rebuild callback (line 74). It is never added to the page-level `Subscriptions` via `DisposeWith`. When the page is torn down, `Subscriptions.Dispose()` is called, but any live subscription in `_contentSubscriptions` — specifically the `SelectionConfirmed` subscription added at line 140 for the validation dialog list — is not disposed. If a validation dialog is open at the moment navigation away from the page occurs, the `SelectionConfirmed` subscription on the `SelectionListNode` remains live, keeping a closure over `HandleValidationDialogAction` (and thus the page and viewmodel) reachable until the node is GC'd. `_pickerSubscriptions` is correctly registered at line 35. + +_Fix:_ Add `_contentSubscriptions.DisposeWith(Subscriptions);` in `OnBound()` alongside the existing `_pickerSubscriptions.DisposeWith(Subscriptions)` at line 35. Change the in-callback `_contentSubscriptions.Clear()` to `_contentSubscriptions.Dispose(); _contentSubscriptions = new CompositeDisposable();` — or use a fresh local per rebuild — so the field remains valid after Dispose. + +_Verifier:_ The leak is real but practically narrow: it only bites when the user navigates away while a validation dialog is open (a rare transient state), and the retained objects are a single closure and a UI node that will be GC'd once no other live reference holds them — making this a temporary retention rather than a permanent leak, which lowers severity from medium to low. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1136` +**GetEffectiveSecret reads secrets.json from disk on every probe call with no caching** + +GetEffectiveSecret (line 1136-1146) calls ConfigFileHelper.ReadDecryptedSecret when the draft value is blank and hasPersistedSecret is true. ReadDecryptedSecret loads and deserializes secrets.json on each call, then decrypts the stored value (line 246-252). During ValidateChannelAccessAsync, this can be called up to 5 times per save (Slack bot, Discord bot, Mattermost bot, and again during label refresh). Each call reads the secrets file and runs the decryption primitive. While this is not a leak in the classic sense, repeatedly decrypting and loading the plaintext token into short-lived locals without pinning or zeroing the memory extends the window the cleartext token is accessible in GC-managed heap memory. Additionally, if the file is read between a write by another process, partially-written secrets may be deserialized silently. + +_Fix:_ Cache the decrypted token for the lifetime of the save/validate operation (pass it as a parameter into the probe methods rather than re-reading secrets each call). Use SecureString or at minimum zero the string after use where the platform permits. This is defense-in-depth given the token is already in managed memory elsewhere. + +_Verifier:_ The redundant reads/decryptions are real and confirmed, but this is a performance/code-quality defect rather than a security vulnerability: the tokens are already resident in managed memory throughout the TUI session, there is no attacker-controlled race window, and `SecureString` is deprecated by Microsoft for managed code; the appropriate fix is caching the secret within the save/validate operation, not memory pinning. + +### [CONFIRMED] security — `src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs:326` +**EnsureCurrentClientPaired reads devices.json twice on the orphaned-token path, enabling a window for a stale device list** + +`EnsureCurrentClientPaired` calls `DeviceRegistryInspector.Read(paths)` at line 325, which internally reads `devices.json` to produce the snapshot including `LocalTokenMatchesDevice`. Then, when the token is present but not matched (`HasLocalDeviceToken && !LocalTokenMatchesDevice`), the code re-reads `devices.json` at line 353 via `ReadPairedDevices`. Between these two reads, another process could update `devices.json` — for example, the daemon accepting a new pair request. The second read would then produce a different device list than the snapshot used for the matching decision. The new device entry would be appended at line 354 alongside any devices added by the external write, which is safe in isolation. However, if the external write has already paired the local token (fixed the orphan), the guard at line 326 won't catch it on the second read, and a duplicate device entry is written for the same underlying token. + +_Fix:_ Read `devices.json` exactly once in `EnsureCurrentClientPaired`, pass the device list to a helper that performs both the token-match check and the append operation, then write once. This eliminates the TOCTOU and the deduplication gap. + +_Verifier:_ The TOCTOU window is real and the duplicate-entry outcome is confirmed by code, but the practical trigger requires a concurrent daemon write to devices.json during the wizard save path — an unlikely race in normal single-user self-hosted use — and the impact is data redundancy (two valid entries for one token), not an authentication bypass or privilege escalation, so medium severity overstates the risk. + +### [PLAUSIBLE] concurrency — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1633` +**Fire-and-forget `_ = RefreshChannelLabelsAsync(...)` with unobserved exceptions** + +`StartChannelLabelResolution` at line 1625 launches `RefreshChannelLabelsAsync` as a discarded task (`_ = RefreshChannelLabelsAsync(type, _labelResolutionCts.Token)`). The method itself catches `OperationCanceledException` and generic `Exception` and routes them to `Status.Value`, which is correct. However, if the method itself throws before those handlers (e.g., a `NullReferenceException` in `NotifyContentChanged()` or a framework assertion), the exception is silently swallowed by the discard. Additionally, the CTS replacement pattern (`_labelResolutionCts?.Cancel(); _labelResolutionCts?.Dispose(); _labelResolutionCts = new CancellationTokenSource()`) at lines 1630-1632 has a race: if the background task reads `_labelResolutionCts.Token` concurrently after `Cancel()` but before `Dispose()`, an `ObjectDisposedException` can emerge from the token. Should use `CancelAsync()` + defer dispose after the new CTS is created, or use a local captured reference before disposal. + +_Fix:_ Capture the old CTS in a local before assigning the new one, cancel it, then dispose it after the new token is captured. Use `#pragma warning disable CS4014` + `_ =` only after wrapping with `.ContinueWith(t => { if (t.IsFaulted) logger.Error(t.Exception); })`. Alternatively, store the Task and await it on disposal. + +_Verifier:_ The fire-and-forget discard is real but the blast radius is narrow: the pre-try guard code at lines 294-299 would need to throw for an exception to escape the catch, and the CTS dispose race is theoretical given single-threaded TUI dispatch; downgrade from medium to low. + +### [PLAUSIBLE] correctness — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:246` +**ApplySelectedPosture reads CurrentPosture twice with a TOCTOU window around the 'already active' guard** + +`ApplySelectedPosture` (line 246) reads `CurrentPosture` at line 249 to check whether the new selection matches the current value. `CurrentPosture` is a property that reads `netclaw.json` from disk on every call (line 135). If another process (e.g., a daemon restart, `netclaw doctor --fix`) writes the config between the `OpenPostureEditor` call (line 238, which reads `CurrentPosture` for the initial selection) and the `ApplySelectedPosture` call (line 249), the 'already active' message could fire when the selection actually differs from the on-disk value, or — more critically — the wrong posture could pass the `posture == CurrentPosture` guard and proceed to `SavePosture`. This is a TOCTOU race on a security-critical config value. + +_Fix:_ Snapshot the config at the start of `OpenPostureEditor` and reuse the snapshot throughout the posture selection flow, rather than re-reading from disk at each step. Alternatively, pass the loaded config dictionary into `ApplySelectedPosture` as a parameter to make the read explicit and singular. + +_Verifier:_ The TOCTOU mechanism is real but the finding overstates the security impact — the guard at line 249 is a UX shortcut, not a security gate, and SavePosture always writes the user-selected value, so a stale CurrentPosture read cannot cause a wrong posture to be persisted. + +### [PLAUSIBLE] correctness — `src/Netclaw.Cli/Tui/ConfigDashboardPage.cs:71` +**rows.IndexOf lookup can match the wrong item when two dashboard rows share a prefix after status formatting** + +`BuildList` constructs `rows` by formatting each `ConfigDashboardItem` as `"{item.Label,-22} {status}"` when the item has a non-empty status. The `SelectionConfirmed` subscriber then does `rows.IndexOf(selected[0])` (line 71) and the `Invalidated` subscriber does `rows.IndexOf(highlighted.Value)` (line 86) to map back to the item. `List.IndexOf` uses `string.Equals` (ordinal by default), so this is a linear scan for exact string equality. Because the formatted string includes both the padded label AND the live status text, two items with different labels could only collide if their formatted strings are identical — which is unlikely in practice. However, if the status text is empty for multiple items (e.g. two terminal-row items both format as their bare label), the first match is always returned. Currently both "Run Full Doctor" and "Quit" have empty status and different labels so no collision exists today, but this is fragile: adding a new terminal item that starts with the same label prefix as an existing non-terminal item with exactly 22 chars of label padding will silently select the wrong item. + +_Fix:_ Map selections by index rather than by string value. Either use `_entryList.HighlightedIndex` if the API exposes it, or maintain a parallel `List` alongside `rows` so the callback uses index directly from `rows` and looks up `ViewModel.Items[index]` without a secondary search. + +_Verifier:_ No collision exists in the current item set — all labels are distinct and the status reader returns semantically unique strings — so this is a latent fragility rather than an active bug, warranting low rather than medium severity. + +### [PLAUSIBLE] correctness — `src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs:38` +**SectionEditorStateAction.Set with null Value is silently accepted, persists JSON null, and returns a confusing type on readback** + +`SectionEditorStateAction` is a positional record with `object? Value = null` and no constructor guard. When `Action == Set` and `Value == null`, line 38 executes `section[action.Key] = action.Value!` (null-forgiving suppresses the compiler warning) storing `null` into the in-memory dictionary. `WriteState` then serializes it as `"key": null` in JSON. On the next `LoadState`, the entry deserializes as a `JsonElement` with `ValueKind == JsonValueKind.Null`. `NormalizeValue` has no arm for `JsonValueKind.Null`, so it falls through to `_ => value`, returning the boxed `JsonElement` (not `null`). A caller that checks `value is null` after `TryGetValue` returns `true` will see `false` and proceed with a `JsonElement` object instead of the expected `null`. Contrast with `SectionSecretAction`, which throws `ArgumentNullException` when `Action == Set && value == null`. + +_Fix:_ Add the same guard to `SectionEditorStateAction` — either use a validating constructor (like `SectionSecretAction`), or add an arm `JsonElement element when element.ValueKind == JsonValueKind.Null => null` to `NormalizeValue`. Also add `JsonElement element when element.ValueKind == JsonValueKind.Object => JsonSerializer.Deserialize>(element.GetRawText())` to handle nested object state values correctly. + +_Verifier:_ The defect is real but dormant — no current production caller triggers Set with a null value; the severity is low (not medium) because exploiting it requires a new caller that violates the existing factory pattern. + +### [PLAUSIBLE] correctness — `src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs:79` +**NormalizeValue has no arm for JsonValueKind.Null or JsonValueKind.Object — returns raw JsonElement** + +When state written as a nested object (e.g. an array-of-objects or a sub-dict) is read back and the value's `ValueKind` is `Object` or `Null`, `NormalizeValue` falls through to `_ => value` and returns the `JsonElement` as-is. A caller expecting `Dictionary` (for an object) or `null` (for null) receives a `JsonElement` instead. The `ConfigFileHelper.NormalizeNodeValue` at `ConfigFileHelper.cs:272` already handles both cases correctly and could be reused or consulted as the pattern. + +_Fix:_ Add two missing arms to the switch: `JsonElement element when element.ValueKind == JsonValueKind.Null => null` and `JsonElement element when element.ValueKind == JsonValueKind.Object => JsonSerializer.Deserialize>(element.GetRawText())`. Align with `ConfigFileHelper.NormalizeNodeValue` to avoid the same drift in the future. + +_Verifier:_ The missing switch arms are real but the current production call sites (`TryReadHost` via `?.ToString()` and `ReadTrustedProxies` via `_ => []`) both accidentally tolerate a raw `JsonElement` fallback, so there is no observable misbehavior today; severity is lower than rated because impact is limited to future callers that store objects or nulls. + +### [PLAUSIBLE] design — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:33` +**Duplicated channel state between `_channelAudiences` and `ChannelPickerStepViewModel` sub-VM fields can desync** + +`_channelAudiences` (line 33, `Dictionary>`) stores audience assignments in the viewmodel. The canonical channel IDs are stored separately in the sub-VMs accessed via `GetChannelIds` (which calls `ChannelCsv.ParseCsv(Step.GetAdapterViewModel<...>().ChannelNamesInput, ...)`). This means channel IDs live in the wizard sub-VM string fields, and audiences live in the parent VM dictionary — two different representations of one logical entity. `SetChannelIds` mutates the sub-VM fields; `SetChannelAudience` mutates `_channelAudiences`. When the Slack name-normalization path (`NormalizeSlackChannelNamesToIds`) rewrites a channel name to its ID, it must update both: `SetChannelIds` and `RemapChannelAudiences`. If any path forgets the remap (or if a future path adds a channel without adding a default audience), the audience map silently diverges from the ID list, causing a channel to silently fall through to the posture default. After `SaveAsync` the state is reloaded and re-synced via `LoadAudienceDrafts`, but between edits within a save boundary the two are loosely coupled. + +_Fix:_ Introduce a per-adapter `ChannelEntry` list (ID + audience) as the single mutable model, replacing both the sub-VM string fields and `_channelAudiences`. The `ChannelsConfigPersistenceMapper` serializes and deserializes to/from this list. Audience assignment and ID list operations then operate on one collection, eliminating the remap step. + +_Verifier:_ The split is real and the remap/fallback mechanism is correctly described, but the actual risk is latent — all current paths maintain the invariant, the consequence of desync is falling back to the posture default (not an open channel), and `SaveAsync` re-syncs state from disk after every save, limiting the blast radius to the in-session window. + +### [PLAUSIBLE] resource-leak — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1633` +**Fire-and-forget `_ = RefreshChannelLabelsAsync(...)` drops exceptions and its result** + +`StartChannelLabelResolution` at line 1625–1634 creates a new `CancellationTokenSource`, then launches `_ = RefreshChannelLabelsAsync(type, _labelResolutionCts.Token)`. Because the `Task` is discarded, any unhandled exception thrown by the async method (e.g. from internal logic after the `catch (OperationCanceledException)` and `catch (Exception ex)` blocks in `RefreshChannelLabelsAsync`) will be an unobserved task exception. More critically, the method mutates `slack.LastChannelResolution`, writes to `Status`, and calls `NotifyContentChanged()` — all of which interact with the TUI thread. If the `ViewModel` is disposed before the background task completes, `_labelResolutionCts` is cancelled and disposed (line 1269–1271 of `Dispose`), but the async continuation may still execute a frame later and access the disposed `Status` `ReactiveProperty`. + +_Fix:_ Store the task and observe it (e.g., assign to a tracked field and await in a try/catch), or ensure the ViewModel does not touch disposed reactive properties by capturing a cancellation guard before the `await` continuation executes. + +_Verifier:_ The primary claim of unobserved exceptions from async logic is refuted by the blanket catch; the real (but narrow) risk is a secondary ObjectDisposedException thrown from within that catch's Status.Value assignment after Dispose races past the CTS cancellation guard, which is only confirmable by inspecting Termina.Reactive's ReactiveProperty dispose behavior. + +### [UNVERIFIED] concurrency — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:1625` +**Label-resolution CTS replace-cancel-dispose is not atomic; concurrent calls can double-dispose** + +`StartChannelLabelResolution` (line 1625) does: `_labelResolutionCts?.Cancel(); _labelResolutionCts?.Dispose(); _labelResolutionCts = new CancellationTokenSource(); _ = RefreshChannelLabelsAsync(...)`. If the UI receives two rapid adapter-open events on the same thread this sequence is safe (single-threaded). However, the identical pattern is in `Dispose()` (lines 1269-1270). If `Dispose()` races with a late-arriving UI callback that also calls `StartChannelLabelResolution`, the CTS can be disposed twice. The pattern is also fragile because the fire-and-forget task captures `_labelResolutionCts.Token` *before* the field is potentially replaced by a subsequent call; the cancellation check at `ct.IsCancellationRequested` inside `RefreshChannelLabelsAsync` correctly uses the captured token, but exceptions after `_labelResolutionCts` is replaced-and-disposed raise against an already-disposed CTS. + +_Fix:_ Adopt the local-capture pattern: `var cts = _labelResolutionCts = new CancellationTokenSource(); _ = RefreshChannelLabelsAsync(type, cts.Token);` and in Dispose, `var old = Interlocked.Exchange(ref _labelResolutionCts, null); old?.Cancel(); old?.Dispose();`. + +### [UNVERIFIED] concurrency — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:188` +**EagerProbeCompletion fire-and-forget task from ProbeAllConfiguredAsync uses CancellationToken.None for all concurrent provider probes** + +`OnActivated` calls `EagerProbeCompletion = ProbeAllConfiguredAsync()` as a fire-and-forget (line 188). Inside `ProbeAllConfiguredAsync`, each provider probe is launched as `await _probe.ProbeAsync(item.Entry, CancellationToken.None)` (line 272). `Dispose()` calls `CancelProbe()` (line 1096) which only cancels `_probeCts` (the single-provider probe CTS) — it has no effect on the eager concurrent probes. If the view model is disposed while N concurrent `ProbeAsync` calls are in flight (each with `CancellationToken.None`), they all continue running and then call `item.Health = ...` and `NotifyStateChanged()` on the disposed object, incrementing a disposed `ReactiveProperty` and triggering `RequestRedraw()` on a detached view model. In practice the probes are short HTTP requests, but for a slow or unreachable self-hosted provider this can be a multi-minute leak. + +_Fix:_ Create a dedicated `CancellationTokenSource _eagerProbeCts` in `OnActivated`, pass its token to each `ProbeAsync` call in `ProbeAllConfiguredAsync`, and cancel+dispose it in `Dispose()`. Alternatively, add a `_disposed` volatile bool flag and check it before calling `NotifyStateChanged()` in the probe continuations. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:2110` +**ReadConfiguredChannels loads DefaultChannelName with '#' prefix that is not deduplicated against AllowedChannelIds** + +ReadConfiguredChannels at line 2122-2123 loads the legacy Slack.DefaultChannelName field and normalizes it by prepending '#' if missing. The AllowedChannelIds array is loaded at line 2113 without any '#' prefix. Distinct(StringComparer.Ordinal) at line 2128 is case- and character-sensitive, so 'general' (from AllowedChannelIds) and '#general' (from DefaultChannelName) are considered different and both appear in the result. When ApplyToStep stores this list as 'general, #general' in vm.ChannelNamesInput, and GetChannelIds re-parses with trimHash:true, both become 'general' and deduplicate to one entry. The editor renders only one row, but the intermediate vm.ChannelNamesInput state contains the redundant '#general' entry. This is benign today because every downstream read calls trimHash, but it means the raw ChannelNamesInput string carries a spurious '#general' until the next save. + +_Fix:_ In ReadConfiguredChannels, normalize the defaultChannelName entry by stripping '#' before adding it (matching the trimHash behavior downstream), so Distinct(Ordinal) deduplicates correctly: `channels.Add(defaultChannelName.TrimStart('#'));`. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs:129` +**Enter key in non-saved state passes through to the StepView even when GoNext is the intended action for Enter** + +`HandleKeyPress` (line 120) handles Escape explicitly, then checks `IsSaved.Value && Enter` (line 129). When `IsSaved` is false and Enter is pressed, control falls through to `ViewModel.StepView.HandleKeyPress(key)` (line 135). The step view's sub-step inputs (text inputs and selection lists) already handle Enter to advance the sub-step via `callbacks.AdvanceStep`. This dual-path is intentional and works for sub-step inputs. However, the top-level `GoNext` call at line 43–51 in the ViewModel (which checks validation and writes config) is ONLY reached via `StepViewCallbacks.AdvanceStep` when the step view is not in the `IsSaved` state. The mode-selection list's `confirmed` callback (line 93 of `ExposureModeStepView.cs`) calls `callbacks.AdvanceStep()` which is wired to `ViewModel.GoNext`. This means the Enter key at the mode-selection sub-step correctly reaches the ViewModel's `GoNext` through the step view, but validation is checked inside `GoNext` only after `_orchestrator.GoNext()` returns false (lines 51–64). If a future sub-step type fails to wire `AdvanceStep`, Enter would be silently swallowed. + +_Fix:_ This is low risk with the current step implementations but the layered wiring (page → step view → callback → GoNext) makes the control flow non-obvious. Adding a comment in `HandleKeyPress` that explains why Enter in non-saved state is forwarded to the step view (rather than directly to GoNext) would reduce maintenance risk. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs:527` +**ParseBackend silently falls back to DuckDuckGo for unrecognized backend string** + +The private ParseBackend (line 527-533) uses a default arm `_ => SearchBackend.DuckDuckGo`. If a user has configured a valid-but-future backend string (e.g., a typo or a backend added in a newer schema), CommitField('Search.Backend', 'typo') silently resets their backend to DuckDuckGo, saves it on the next Enter, and they get no error. The same pattern applies in SearchEditorPersistenceMapper.ParseBackend (line 130-136). CLAUDE.md prohibits silent fallbacks: "When something fails or is misconfigured, fail loudly." + +_Fix:_ Return null from ParseBackend for unrecognized values (make it return SearchBackend?) and propagate a validation error when null is returned, or throw InvalidOperationException with the unrecognized value to surface it immediately. The persistence mapper's ParseBackend should default gracefully (DuckDuckGo is a safe config read default) but the UI path that accepts user input should reject unrecognized strings. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:182` +**`Activate` dispatches on string label equality — fragile coupling between menu and action routing** + +The security access menu dispatch at lines 183-196 uses `switch (item.Label)` with literal strings `"Security Posture"`, `"Enabled Features"`, `"Audience Profiles"` to route to editors. `BuildItems()` produces these labels and must stay in exact sync with the switch. If a label is changed for localisation or UX reasons, the routing silently falls through to the fallback `Navigate?.Invoke(item.Route)` which is `null` for those items, causing a no-op instead of navigation. Additionally, a typo in either location produces the same silent fallback. + +_Fix:_ Replace the string-comparison dispatch with a typed discriminator: introduce a `SecurityAccessAction` enum on `SecurityAccessItem` (or use the existing `Route` field plus null to distinguish navigate-vs-in-place items), and switch on the enum. The `BuildItems()` and `Activate` methods are then refactored to be in lockstep without relying on string equality. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:1541` +**Save-anyway fingerprint for URL change uses API key length, allowing same-length new key to skip re-probe** + +In `SaveRemoteUrlChange` (line 1541), the save-anyway fingerprint is constructed as `$"change-url|{source.Name}|{normalizedUrl}|{apiKey?.Length ?? 0}"`. Similarly, `SaveRotatedRemoteToken` at line 1590 uses `$"rotate-token|{source.Name}|{feed.Url}|{token.Length}"`. A user who enters a bad token of length N, sees a probe failure ('Press Enter again to save anyway'), then edits the token to a different value of the same length N (which calls `MarkDirty()` clearing the fingerprint) and re-enters — the fingerprint is cleared by `MarkDirty`, so the probe re-fires correctly. However, if the user presses Enter twice in rapid succession without editing (same Draft.Value), the second Enter in `SaveRotatedRemoteToken` matches the fingerprint and bypasses the probe, saving the unverified token. This is the documented 'save anyway' escape hatch, but the intent of two presses is to override a known-failing probe, not to silently skip the probe on repeated submission of the same value. + +_Fix:_ This is the documented escape-hatch behavior. Add a comment at the fingerprint construction sites to make clear that same-length token repetition is intentionally treated as 'save anyway'. If true bypass-prevention is needed, include the Draft value's hash (not the raw token) in the fingerprint rather than just its length. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs:325` +**Save() rebuilds the Telemetry section from scratch, discarding any unknown fields in the original** + +Save() (line 313-339) replaces `root["Telemetry"]` with a hard-coded dictionary containing only Enabled and Otlp.Endpoint (line 327-334). Any additional fields a future version of TelemetryOptions might write — or fields that a user manually added for experimentation — are silently erased on the next save. Currently TelemetryOptions only has these two fields so this is not a live bug, but it creates a maintenance trap: adding a field to TelemetryOptions without updating this writer will silently wipe user values on the first TUI save. The PersistWebhooks path (line 352) correctly uses ConfigFileHelper.LoadSection to preserve unmanaged delivery-policy fields, so there is precedent for the safe pattern. + +_Fix:_ Instead of constructing a fresh dictionary, load the existing Telemetry section as a Dictionary (using LoadRawSection which already exists at line 505), mutate only Enabled and Otlp.Endpoint, then write it back. This is consistent with how PersistWebhooks handles the Notifications section. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs:235` +**RemoveSelectedWebhook clamped row may land on AddRowIndex, a non-webhook row, after deleting the last webhook** + +After PersistWebhooks succeeds in RemoveSelectedWebhook (line 234-235), ReloadState is called which sets Webhooks.Value to the shorter list. ListRowCount is then AddRowIndex (OtlpRowCount + 0) + 1 = 3. Math.Clamp(SelectedRow.Value, 0, 2) when SelectedRow was 2 (the only webhook at rowIndex 2) produces 2, which equals the new AddRowIndex (the "+ Add webhook" row). This is acceptable UX — focus lands on Add — but if the next key press is Delete (which calls RemoveSelectedWebhook), IsWebhookRow(AddRowIndex) returns false and nothing happens. The real issue is that no visible feedback is given that focus moved from a webhook row to the Add row; the status bar is set to the success message which overrides any navigation hint. + +_Fix:_ After deletion, explicitly clamp to min(SelectedRow - 1, AddRowIndex - 1) (i.e., the previous webhook, or OtlpRowCount if the list is now empty) to ensure focus lands on a webhook or an OTLP row rather than the Add row. This gives a more predictable post-delete position. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:816` +**GoBack from AddComplete calls ConfirmAdd which writes provider config a second time** + +`GoBack()` has `case ProviderManagerState.AddComplete: ConfirmAdd(); break;` (line 816–818). `ConfirmAdd()` (line 577) checks `!_newProviderPersisted` before calling `WriteProviderConfig()` to avoid a double write. However, when the user reaches `AddComplete` via the normal probe-success path, `WriteProviderConfig()` was already called inside `ProbeProviderAsync` (line 969) and `_newProviderPersisted` was set to `true`. So the guard correctly suppresses the second write. The subtlety is that `ConfirmAdd` also calls `ClearAddState()` which sets `_newProviderPersisted = false` (line 1075). If a future code path reaches `AddComplete` without going through the probe success branch (e.g., a test seam or a state jump), the guard would not fire and `WriteProviderConfig` would be called with stale `NewApiKey`/`NewProviderType`. This is a latent correctness hazard rather than a current bug given the existing state machine, but the semantics of Esc-from-AddComplete ("confirm and go back") are non-intuitive. + +_Fix:_ Document clearly in a comment on the `AddComplete` case that Esc from the success screen is treated as an implicit confirm. Consider changing the key hint on the AddComplete screen from Esc to Enter-to-confirm-and-return to make the UX intent explicit and avoid the unusual `GoBack = ConfirmAdd` semantic. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs:244` +**Health check reuses cached LastChannelResolution from background prefetch without verifying channel IDs match current input** + +At line 244, `if (LastChannelResolution is { Success: true })` uses the cached background-resolution result directly without checking whether `ChannelIdsInput` has changed since the result was produced. `StartBackgroundChannelResolution()` is triggered in `TryAdvance` at sub-step 2, capturing `ChannelIdsInput` at that moment. If the user navigates back, edits `ChannelIdsInput` at sub-step 2 (triggering a new background task), and then advances to health check before the new task completes, `LastChannelResolution` may still hold the old result from the previous channel set. The early return on line 249 then reports wrong channel names and skips re-resolution. + +_Fix:_ Either snapshot `ChannelIdsInput` inside `LastChannelResolution` (include it in the result type) and compare at line 244, or clear `LastChannelResolution = null` whenever `ChannelIdsInput` changes in the view. The `ResetConfig()` path already nulls it, but mid-flow edits via `SyncInputToViewModel` do not. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:220` +**`Results[^1]` index access after `UpdateLast` can panic if `Results` list is empty** + +`HealthCheckRunner.UpdateLast` at line 42 in HealthCheckRunner.cs guards with `if (Results.Count > 0)`, so that call site is safe. However, in `RunHealthCheckCoreAsync` at line 220, the code reads `Results[^1].Passed` directly after `StartIfNeededAndPollAsync` returns — a method that itself directly writes `Results[^1]` at lines 293, 337, 342, and 362 without guard. If `Results` is somehow empty at that point (e.g., if `runner.Add(new HealthCheckItem(ProgressLabel(wasRunning), null))` at line 214 failed due to an exception that was swallowed) the `Results[^1]` at line 220 would throw `IndexOutOfRangeException`, crashing the health-check task with no user-visible error. + +_Fix:_ Change line 220 to `else if (Results.Count > 0 && Results[^1].Passed is null)` (which is already present) but additionally guard the direct `Results[^1] = ...` writes in `StartIfNeededAndPollAsync` behind `if (Results.Count > 0)` checks, mirroring the pattern in `HealthCheckRunner.UpdateLast`. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:71` +**TryAdvance() triggers a fire-and-forget health check run that is then overwritten if GoNext() is called again** + +TryAdvance() (line 71) assigns `HealthCheckCompletion = RunHealthCheckAsync()` as a fire-and-forget when !IsRunning && !IsComplete. However, the caller (WizardOrchestrator.GoNext) does not use the value returned by TryAdvance — it returns true (handled internally), so the check stays on screen. If the orchestrator calls TryAdvance again before IsRunning becomes true (e.g. rapid Enter presses during the async startup of the health check), RunHealthCheckAsync is called a second time and the first task's reference is overwritten in HealthCheckCompletion. Both tasks run concurrently, both write IsRunning/IsComplete, and both append to the shared Results list — producing duplicate entries and undefined completion order. The InitWizardViewModel.GoNext path guards with `if (!healthStep.IsRunning.Value && !healthStep.IsComplete.Value)` before calling StartWithOrchestrator, but the TryAdvance() path (which is the fallback path if WizardOrchestrator.GoNext is called directly) has no such guard. + +_Fix:_ Set `IsRunning.Value = true` synchronously before launching the task in TryAdvance(), so that a second call sees IsRunning=true and returns without starting a second run. Alternatively remove TryAdvance as a trigger for the health check and require callers to go through StartWithOrchestrator exclusively. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs:81` +**PrefillFromExistingConfig called on every OnEnter — user edits overwritten when navigating back** + +PrefillFromExistingConfig (line 81) is called every time the identity step is entered, including when navigating back from a later step. It uses `??=` for CommunicationStyle and UserName (so user entries are preserved if non-null), but for AgentName and UserTimezone it uses `ReadString(...) ?? AgentName` — i.e., the existing config value wins if present, even when the user already edited the field in this wizard session. If a user changes AgentName from 'Netclaw' to 'MyBot', then navigates to the next step and comes back, AgentName will be reset to the value from the on-disk config (since the field is not null-guarded). This is especially visible in re-init flows where ExistingConfig contains the previous run's values. + +_Fix:_ Guard all prefill assignments with null-or-empty checks on the current field value, matching the pattern used for CommunicationStyle/UserName, so that any field the user has already edited is not overwritten: `AgentName = string.IsNullOrWhiteSpace(AgentName) ? (ReadString(context, ...) ?? AgentName) : AgentName;` + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs:82` +**PreserveExistingUpdateChannel reads the config file a second time via ConfigurationBuilder after LoadJsonDict already loaded it** + +WizardConfigBuilder constructor loads the existing config at line 31 via ConfigFileHelper.LoadJsonDict into _existingConfig. PreserveExistingUpdateChannel (line 77) then creates a new ConfigurationBuilder and re-reads the same file from disk. This means there is a window where a concurrent write between construction and WriteConfigFile (rare but possible when the daemon's ConfigWatcherService is also writing) could cause the two reads to see different data. More practically, the double-read is simply redundant — _existingConfig already has the Daemon.UpdateChannel value if present. + +_Fix:_ Read UpdateChannel from _existingConfig directly instead of re-reading the file. The already-loaded dictionary can be accessed with ConfigFileHelper.GetSectionOrNull(_existingConfig, 'Daemon') and the UpdateChannel key read from that. + +### [UNVERIFIED] correctness — `src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs:536` +**WriteSecretsFile has an operator precedence ambiguity in the final write gate** + +Line 536 reads: `if (hasDirectSecrets || contributionChanged && (_secretsFileExists || HasUserSecretData(merged)))`. In C#, `&&` has higher precedence than `||`, so this parses as `hasDirectSecrets || (contributionChanged && (_secretsFileExists || HasUserSecretData(merged)))`. This means: if there are direct secrets, always write regardless of whether the file exists or has user data. That is likely intentional for the fresh-install case (first-time secrets write). However, the intent of the gate appears to be 'write only if there is something meaningful to write' — and hasDirectSecrets alone bypasses the _secretsFileExists / HasUserSecretData guards entirely. If a step contributes a placeholder or empty section via AddSection, secrets.json is written unconditionally even to a fresh config with no real secrets. + +_Fix:_ Make the intent explicit with parentheses: `if ((hasDirectSecrets || contributionChanged) && (_secretsFileExists || HasUserSecretData(merged) || hasDirectSecrets))` or use an early-return pattern with clearly named intermediate booleans. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs:22` +**ExposureModeConfigViewModel creates a ProviderDescriptorRegistry with an empty registry on every construction** + +The constructor (line 22–32) initialises `WizardContext` with `Registry = new ProviderDescriptorRegistry([])`. This is a hollow registry with no provider descriptors. `ExposureModeStepViewModel` does not use the provider registry, so this has no functional impact today. However, any future step that is added to the single-step orchestrator that does query `context.Registry` would receive an empty registry silently, rather than an exception, potentially leading to the 'no providers available' experience without any diagnostic. + +_Fix:_ Pass the real `ProviderDescriptorRegistry` (from DI) into `ExposureModeConfigViewModel` if there is any chance the wizard context will be shared with steps that use provider data. If the empty registry is truly intentional and permanent, add a comment stating this is by design. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs:510` +**IsDirty reads config from disk on every access** + +ComputeIsDirty() (line 510-515) calls `_mapper.Load(_paths)` which reads and deserializes the netclaw.json config file from disk every time IsDirty is evaluated. The page can call IsDirty repeatedly during rendering and keybinding evaluation. Each call is a synchronous disk read that blocks the render loop thread. Under normal file sizes this is fast but not free; on network-mounted config paths or slow disks this will visibly stall rendering. + +_Fix:_ Cache the persisted baseline at construction time and on each ReloadPersistedDraft() call (a field like `_persistedSnapshot`). Update the snapshot after every successful save. ComputeIsDirty() then compares in-memory values only, with no I/O. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:501` +**RouteRequested delegate is declared and called but never wired — dead code in both ViewModels** + +`SkillSourcesConfigViewModel` declares `internal Action? RouteRequested` at line 201 and invokes `RouteRequested?.Invoke("/config")` at line 505 alongside `Navigate?.Invoke("/config")`. `WorkspacesConfigViewModel` does the same at lines 28/160. `Navigate` is the Termina framework's page-router delegate and is wired by the framework on `RegisterRoute`. `RouteRequested` is never assigned anywhere in the codebase (confirmed: grep found no assignment site in `Program.cs` or any page). Every call to `RouteRequested?.Invoke(...)` is thus a guaranteed no-op in production. The only observable navigation is `Navigate?.Invoke(...)`. Having two invocations with one always a no-op is misleading and could cause confusion if a future test wires `RouteRequested` instead of `Navigate`. + +_Fix:_ Remove the `RouteRequested` property and all its call sites from both `SkillSourcesConfigViewModel` and `WorkspacesConfigViewModel`. Navigation is already handled correctly by `Navigate?.Invoke("/config")`. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:27` +**`SkillFeedReachabilityProbe` creates a new `HttpClient` per probe call — resource waste and no connection reuse** + +`SkillFeedReachabilityProbe.Probe` at line 35 does `using var client = new HttpClient { Timeout = timeout }` inside the method body, so a new `HttpClient` (and its underlying `HttpClientHandler` / socket) is created and immediately disposed on every probe invocation. This suppresses connection pooling and, on .NET, can exhaust ephemeral ports under repeated rapid probes (socket TIME_WAIT). The correct pattern per Microsoft guidance is to reuse `HttpClient` instances or use `IHttpClientFactory`. + +_Fix:_ Make `SkillFeedReachabilityProbe` hold a single `HttpClient` field (or accept `IHttpClientFactory`). The timeout should be applied per-request via `CancellationTokenSource` rather than as `HttpClient.Timeout`, so a single client can serve probes with different timeouts. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs:96` +**Duplicate PruneEmptySections implementations between ConfigEditorSession (secrets) and ConfigFileHelper (config)** + +There are two independent `PruneEmptySections` implementations: one at `ConfigEditorSession.cs:168` operating on `Dictionary` with an iterative descent, and one at `ConfigFileHelper.cs:293` using `TryGetPathValue` + `RemovePath` with mutual recursion. The comment at line 108–113 documents the intentional divergence but also notes that the two engines must stay in sync. Over time, a fix to one is unlikely to be applied to the other. The `ConfigFileHelper` version is also indirectly recursive (`RemovePath` calls `PruneEmptySections` which calls `RemovePath`), making its depth bounded by path length but harder to audit for infinite-loop correctness. + +_Fix:_ Consolidate behind a single `PruneEmptySections(Dictionary root, IReadOnlyList segments)` helper in `ConfigFileHelper` and make the secrets path call it directly. The only real divergence is that `ConfigEditorSession.SetSecretPathValue` uses `GetOrCreateSection` (throws on scalar collision) while `ConfigFileHelper.SetPathValue` overwrites — that divergence lives in the write path, not in prune, so consolidation of the prune logic is safe. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs:23` +**ConfigEditorStateStore.Apply performs N disk read+write pairs for N contributions** + +Each call to `stateStore.Apply(actions)` does a full `LoadState()` (file read + deserialize) and `WriteState()` (serialize + file write) round-trip. In `ConfigEditorSession.Apply` this happens once per `contribution` because `Apply` is called once per section. In `ConfigEditorSession.ApplyEditorStateActions` (the static batch path), a single `ConfigEditorStateStore` instance is reused across contributions, but each `stateStore.Apply(contribution.StateActionsOrEmpty)` still triggers a full read-modify-write. For the wizard's multi-section commit (N sections, each with state actions), this produces N redundant reads and N sequential writes when 1+1 would be sufficient. + +_Fix:_ Expose an internal batch method on `ConfigEditorStateStore` that accepts `IEnumerable>`, performs a single `LoadState`, applies all action batches, then a single `WriteState`. Update `ApplyEditorStateActions` to use it. The per-`Apply` path (called from `ConfigEditorSession.Apply`) already only writes once per call so it is acceptable. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs:71` +**TryAdvance() on HealthCheckStepViewModel starts a fire-and-forget health check via RunHealthCheckAsync in standalone mode** + +TryAdvance() at line 71 calls `HealthCheckCompletion = RunHealthCheckAsync()` when !IsRunning && !IsComplete. RunHealthCheckAsync (line 133) is the no-op standalone path. However, in normal wizard operation, InitWizardViewModel.GoNext() intercepts the health check step before calling orchestrator.GoNext(), so TryAdvance is never reached via normal flow. The inconsistency is that TryAdvance — which always returns true — means the orchestrator never moves past the health check step even if it somehow ends up calling GoNext on the orchestrator. In tests that directly call orchestrator.GoNext(), the wizard will not advance past health check and TryAdvance returns true (handled internally) rather than false (step complete), which is the correct semantic for 'health check is an endpoint' but could mislead test authors. + +_Fix:_ Document the intentional design: TryAdvance always returns true because the health check step is a terminal step — the wizard never advances past it via the orchestrator path. Add a comment to that effect, and ensure InitWizardViewModel.GoNext() always intercepts this step (which it does) so TryAdvance is never the primary code path. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs:101` +**TryAdvance always returns false — orchestrator never naturally advances the provider step; View calls SetSubStep directly for all transitions** + +TryAdvance() at line 101 returns false unconditionally with the comment 'step complete'. This means every Enter keypress on the provider step is treated as 'step complete — move to next'. The actual sub-step navigation (provider selection → auth method → credentials → validation → model) is driven entirely by the View calling SetSubStep directly (lines 98, 115 in the view, and via StartProbe success callbacks). The orchestrator's GoNext will therefore advance to IdentityStep the moment TryAdvance returns false — even if the user is on sub-step 0 and hasn't selected a provider yet. The Page must prevent this by intercepting Enter for the provider step, which it does via the step view's HandleKeyPress. This creates a hidden coupling: if a new code path triggers GoNext on the orchestrator while the provider step is active, the wizard silently advances past it. + +_Fix:_ TryAdvance should guard against premature advancement by checking the current sub-step and returning true (handled) if the step is not yet complete (e.g., sub-step < 4, or model not selected). This makes the step self-protecting rather than relying entirely on the view's capture logic. + +### [UNVERIFIED] design — `src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs:160` +**Two-phase config write in `WriteConfig` creates a latent conflict risk between `ContributeConfig` and `BuildContribution`** + +For steps implementing `ISectionEditor`, `WriteConfig` calls both `step.ContributeConfig(configBuilder)` (writes typed section objects) and then `sectionEditor.BuildContribution(step)` (applies field-action overrides that win). The comment at line 172-174 acknowledges this: "the two must stay in agreement… so the clobbered typed write is a genuine no-op". `ExposureModeStepViewModel` illustrates the problem concretely: `ContributeConfig` writes `Daemon.Host`, `Daemon.TrustedProxies`, and `Webhooks` when `IncludeWebhookToggle && SelectedMode != Local`, but `BuildContribution` (used from the config editor where `IncludeWebhookToggle = false`) only writes `Daemon.ExposureMode` and deletes `Daemon.Host`/`Daemon.TrustedProxies` for non-reverse-proxy modes — it does not write `Webhooks` at all. When `IncludeWebhookToggle` is `false` (config-editor path), `WebhooksEnabled` cannot be set and `ContributeConfig` skips it; but if future code sets `WebhooksEnabled` before calling `WriteConfig` in config-editor mode, `BuildContribution` silently drops it. The two emission paths need explicit reconciliation or `ContributeConfig` should be removed from steps that are `ISectionEditor`. + +_Fix:_ For steps implementing `ISectionEditor`, remove `ContributeConfig` (make it a no-op) and rely solely on `BuildContribution` as the single emission path. This eliminates the double-write and the comment-documented fragile invariant. + +### [UNVERIFIED] error-handling — `src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs:282` +**Fire-and-forget `_ = ViewModel.SubmitCurrentConfigurationFromInputAsync()` in key handler** + +`SearchConfigEditorPage` at line 282 (key handler for Enter) launches `_ = ViewModel.SubmitCurrentConfigurationFromInputAsync()` discarding the task. If this async method throws an unhandled exception it is silently lost — no UI error, no user feedback. The method is async and performs validation + HTTP probe work, meaning any error in that chain (network exception, config write failure, etc.) disappears. + +_Fix:_ Await the task in a try/catch within the page, or ensure `SubmitCurrentConfigurationFromInputAsync` catches and surfaces all exceptions to a status message before returning. + +### [UNVERIFIED] error-handling — `src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs:2185` +**Bare catch in SuggestNameFromUrl swallows all exception types including OutOfMemoryException** + +`SuggestNameFromUrl` at line 2185 contains `catch { return "custom-feed"; }` — a bare catch with no filter. This suppresses any exception type thrown by `new Uri(url)`, including `ThreadAbortException`, `OutOfMemoryException`, and `StackOverflowException`. Since `TryNormalizeFeedUrl` has already validated the URL as an absolute HTTP/HTTPS URI before `SuggestNameFromUrl` is called, the `Uri` constructor will never throw `UriFormatException` at this point. The bare catch masks bugs — for example, a `NullReferenceException` on `uri.Host` would silently become `"custom-feed"`. + +_Fix:_ Replace `catch` with `catch (UriFormatException)` (the only exception `new Uri(string)` throws for malformed input). Alternatively, use `Uri.TryCreate` to avoid exceptions entirely: `return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? NormalizeSourceName(uri.Host) : "custom-feed";` + +### [UNVERIFIED] error-handling — `src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs:721` +**Fire-and-forget `_ = RevalidateAsync(DetailProvider)` without exception observation** + +`RevalidateDetailProvider` at line 714 launches `_ = RevalidateAsync(DetailProvider)` and discards the task. `RevalidateAsync` has an exception handler (`catch { item.Health = ProviderHealthStatus.Unhealthy; }`) that swallows all exceptions silently. If `_probe.ProbeAsync` throws beyond the catch (e.g., a `ThreadAbortException` or the catch itself throws), the unhandled exception on the discarded task is silently lost — no UI feedback, no logs. The same issue affects `RevalidateAsync` at line 724 where `item.Entry is null` path calls `ProbeAsync` with a null `GetProbeCredential(null)` argument. + +_Fix:_ Await `RevalidateAsync` (make `RevalidateDetailProvider` async), or add a `.ContinueWith` that faults visibly. Also guard against `item.Entry` being null before invoking the first overload. + +### [UNVERIFIED] error-handling — `src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs:53` +**SectionFieldAction.Set with null Value has no contract guard, persists JSON null into config** + +`SectionFieldAction` is a positional record with `object? Value = null` and no validation. When `Action == Set` and `Value == null`, `ConfigEditorSession.ApplyFieldActions` calls `ConfigFileHelper.SetPathValue(config, action.Path, action.Value)`, which executes `current[segments[^1]] = value!` (null-forgiving). This persists `"key": null` into `netclaw.json`. For schema fields that are required or non-nullable, this produces a JSON doc that fails `ConfigSchemaDoctorCheck` at runtime — a silent pre-persistence violation. `SectionSecretAction` (same file, line 55) throws `ArgumentNullException` for `Set+null`. This is an asymmetric contract. + +_Fix:_ Add a validating constructor to `SectionFieldAction` mirroring `SectionSecretAction`: throw `ArgumentNullException` when `action == Set && value == null`. Alternatively, convert it from a positional record to a class with a constructor guard. + +### [UNVERIFIED] error-handling — `src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs:229` +**Mattermost ContributeConfig persists null ServerUrl without validation — runtime connection fails silently** + +`ContributeConfig` at line 229 writes `ServerUrl = string.IsNullOrWhiteSpace(ServerUrl) ? null : ServerUrl.Trim()`. If `ServerUrl` is null or whitespace, a null value is written to `MattermostConfigSection.ServerUrl`. `ContributeHealthChecksAsync` at line 252 calls `BeginAdapterCheck("Mattermost", MattermostEnabled, (ServerUrl, "server URL"), (BotToken, "bot token"))` which would catch this — but only if the health check runs. If the health check is skipped or not reached (e.g. the user cancels early and the wizard writes config anyway), a null ServerUrl is persisted and the daemon fails to connect with no indication of the config source of the problem. + +_Fix:_ This is acceptable as long as the health check always runs before config write in the normal wizard flow. Validate that `WriteConfig()` in the orchestrator is never called without `RunHealthChecksAsync()` completing. If the wizard can write config while skipping health checks (early exit path), add a validation guard in `ContributeConfig` itself. + +### [UNVERIFIED] resource-leak — `src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs:68` +**`WizardContext` created in `ChannelsConfigViewModel` constructor uses a `new ProviderDescriptorRegistry([])` that is never disposed** + +At line 69–75 a `WizardContext` is created with `Registry = new ProviderDescriptorRegistry([])`. `ProviderDescriptorRegistry` is not itself `IDisposable`, so there is no direct leak. However, `WizardContext` is an `IDisposable` (holding a `ReactiveProperty StatusMessage`) and is properly disposed in `ChannelsConfigViewModel.Dispose()` at line 1275. The real issue is that a fresh `ProviderDescriptorRegistry` seeded with an empty array is semantically wrong for the channels config: any code path inside `ChannelPickerStepViewModel` or its child adapters that calls `_context.Registry.Get(...)` will throw `InvalidOperationException` with a confusing "unknown provider" message. This is a latent correctness defect, not a resource leak. + +_Fix:_ Inject the real `ProviderDescriptorRegistry` (or a no-op singleton) rather than constructing an empty registry that will fail loudly on any lookup. + +### [UNVERIFIED] resource-leak — `src/Netclaw.Cli/Tui/InitWizardViewModel.cs:209` +**Individual wizard step view models are not disposed in Dispose — ProviderStepViewModel and HealthCheckStepViewModel hold reactive properties** + +`InitWizardViewModel.Dispose()` calls `_orchestrator.Dispose()` which (in `WizardOrchestrator.Dispose()`, line 261) calls `step.Dispose()` on each `IWizardStepViewModel` in `_allSteps`. However `_stepViews` (the `Dictionary`) is never iterated or disposed in `InitWizardViewModel.Dispose()`. `IWizardStepView` implementors like `ProviderStepView` and `HealthCheckStepView` may hold CompositeDisposable or other resources. More importantly, `_healthCheckStep` (an `IWizardStepViewModel`) is correctly disposed through the orchestrator, but `ProviderStep` is exposed as a public `ProviderStepViewModel` property and is the same object that's in `_allSteps` — so it is disposed through the orchestrator. The `_sectionEditors` registry is disposed. The step *views* (not view models) however are not disposable by interface so this is likely fine unless a future step view adds subscriptions in its constructor. + +_Fix:_ Add a `foreach` loop over `_stepViews.Values` in `Dispose()` that calls `Dispose()` on any value implementing `IDisposable`. Alternatively, verify that no `IWizardStepView` implementation is `IDisposable`; if confirmed, document that assumption with a comment. + +### [UNVERIFIED] resource-leak — `src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs:133` +**Partial construction of SectionEditorRegistry leaks IDisposable editors if a later registration throws** + +In `SectionEditorRegistry`'s constructor (line 133), editors are created via `ActivatorUtilities.CreateInstance` and added to `_editors` one at a time (line 145). If creation or the duplicate-ID check throws for editor `i`, editors `[0..i-1]` that are `IDisposable` are already in `_editors` but `Dispose()` is never called — the partially-constructed registry is discarded without cleanup. The `InvalidOperationException` for duplicate IDs at line 141 makes this a startup-time-only risk, but any editor that opens file handles, subscriptions, or allocates unmanaged resources will leak. + +_Fix:_ Wrap the construction loop in a try/catch: on exception, call `Dispose()` on all `IDisposable` entries already in `_editors` before re-throwing. Alternatively, construct all instances into a temporary list and validate before committing to `_editors`. + +### [UNVERIFIED] security — `src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs:680` +**GetProfile silently returns the Public profile for unknown TrustAudience values** + +`GetProfile` (line 680–687) returns `profiles.Public` for any `TrustAudience` value not explicitly handled in the switch (`_ => profiles.Public`). This fallback is the most restrictive tier, which is the correct fail-closed direction. However, `AudienceConfigName` (line 698) delegates to `AudienceLabel` (line 689) which returns `audience.ToString()` for unknown values. If a caller passes an out-of-range enum value, `SaveAudienceProfile` (line 543–546) writes the profile under an unrecognised key (e.g., `Tools.AudienceProfiles.4`), which adds an unknown property to the config rejected by `ConfigSchemaDoctorCheck` (`additionalProperties: false`). + +_Fix:_ Throw `ArgumentOutOfRangeException` from `GetProfile` for unrecognised `TrustAudience` values, consistent with the pattern used in `ExposureModeExtensions.ToWireValue` and `RequiresRemoteAuthentication`. This makes the failure loud rather than producing a silently-corrupt config key. diff --git a/docs/spec/SPEC-007-guided-onboarding.md b/docs/spec/SPEC-007-guided-onboarding.md index 0b6ec4788..de987770b 100644 --- a/docs/spec/SPEC-007-guided-onboarding.md +++ b/docs/spec/SPEC-007-guided-onboarding.md @@ -4,66 +4,81 @@ Source PRDs: `PRD-004`, `PRD-002`, `PRD-005` ## Purpose -Define the guided onboarding flow for first-time Netclaw setup. +Define the bootstrap-first `netclaw init` experience and its limited +existing-install re-entry paths. ## Entry Points - `netclaw init` (interactive default) -- `netclaw init --resume` - `netclaw init --non-interactive ...` for automation -## Onboarding Steps +## Fresh-Install Flow -### Step 1: Environment Check +### Step 1: Provider Setup -- verify required runtime version -- verify writable config path -- detect existing partial setup +- select provider type +- collect credentials or OAuth device flow inputs +- assign the initial model +- validate provider authentication and connectivity -### Step 2: Slack Setup +### Step 2: Identity -- collect and validate `SLACK_BOT_TOKEN` -- collect and validate `SLACK_APP_TOKEN` -- test Socket Mode connectivity +- collect workspaces directory +- collect user name +- collect timezone +- regenerate `SOUL.md` and `TOOLING.md` -### Step 3: Provider Setup +### Step 3: Security Posture -- default selection: OpenRouter -- collect provider credentials and default model -- validate provider authentication with dry-run request +- choose `Personal`, `Team`, or `Public` +- keep posture distinct from both Enabled Features and Audience Profiles -### Step 4: ACL Bootstrap +### Step 4: Enabled Features -- create default-deny ACL template -- capture owner identifiers and allowed channels -- set mention/ambient behavior per channel +- shown automatically for `Team` and `Public` +- skipped for `Personal` +- controls deployment-wide runtime enablement only -### Step 5: Security Profile +### Step 5: Final Validation -- choose exposure mode (`local`, `reverse-proxy`, `tailscale-serve`, - `tailscale-funnel`, `cloudflare-tunnel`) -- for `reverse-proxy`: collect `Daemon.Host` (must be non-loopback) and - `Daemon.TrustedProxies` (≥1 IP or CIDR entry required to advance — matches - the daemon's startup validator so the wizard cannot emit a non-startable - config), then show an informational notice with the resulting serving URL - (`http://{Host}:{Port}`) before continuing -- enforce policy prerequisites for selected mode +- run config and health validation +- show summary with remediation guidance on failure +- output next-step commands (`netclaw chat`, `netclaw config`) -### Step 6: Final Validation +## Existing-Install Flow -- run config and ACL validation -- show summary with red/yellow/green status -- output next run commands +When an install already exists, `netclaw init` SHALL NOT replay the full +bootstrap flow by default. Instead it presents: -## Resume Behavior +1. `Redo identity setup` +2. `Open configuration editor` +3. `Start over from scratch` +4. `Cancel` -- incomplete steps are persisted with status -- resumed onboarding starts at first incomplete step -- validated completed steps can be skipped with explicit confirmation +### Identity Re-entry + +- remains init-owned +- reuses the identity form with existing values prefilled +- continues into the bot-assisted identity conversation + +### Start Over From Scratch + +- opens a second dialog with: + - `Reset setup only` + - `Full reset` + - `Cancel` +- both destructive options require double confirmation + +`Reset setup only` preserves working data such as the SQLite database, logs, +projects, schedules, environment, and skills. + +`Full reset` wipes the entire Netclaw home except the binary payload. ## Safety Requirements - secrets are never echoed in plain text -- risky internet-reachable exposure modes require explicit confirmation text -- audience/posture choice and exposure mode remain separate decisions +- structurally invalid config blocks save without override +- runtime/probe failures may offer explicit `Save anyway` +- posture, Enabled Features, and Audience Profiles remain separate decisions - onboarding must fail closed if validation fails +- `netclaw init --force` is not part of this flow diff --git a/docs/spec/SPEC-010-testing-and-smoke-strategy.md b/docs/spec/SPEC-010-testing-and-smoke-strategy.md index ddbfd67f0..2b90b4bc2 100644 --- a/docs/spec/SPEC-010-testing-and-smoke-strategy.md +++ b/docs/spec/SPEC-010-testing-and-smoke-strategy.md @@ -1,6 +1,6 @@ # SPEC-010: Testing and Smoke Strategy -Source PRDs: `PRD-001`, `PRD-005`, `PRD-004` +Source PRDs: `PRD-001`, `PRD-002`, `PRD-004`, `PRD-005`, `PRD-006`, `PRD-007`, `PRD-008`, `PRD-009` ## Purpose @@ -29,6 +29,23 @@ tests can validate real provider integrations. - explicit opt-in tests using real endpoints (for example, local Ollama) - intended for developer or pre-release validation +## Critical Producer/Consumer Contract Inventory + +The contracts below are the minimum cross-boundary producer/consumer pairs that +must stay named in planning and tests. A row is complete only when the producer +emits the canonical representation consumed by the downstream runtime path, and +the listed proof covers both a positive path and a relevant negative path. If the +proof is not complete yet, the gap is assigned to an explicit `NOW` task in +`IMPLEMENTATION_PLAN.md`. + +| Contract surface | Producer | Downstream consumer | Canonical representation | Current proof or explicit `NOW` gap | +|------------------|----------|---------------------|--------------------------|--------------------------------------| +| Config editor -> runtime channel options | `ChannelsConfigViewModel` writes `netclaw.json` and `secrets.json` for Slack, Discord, and Mattermost | Daemon configuration binding into `SlackChannelOptions`, `DiscordChannelOptions`, `MattermostChannelOptions`, then adapter ACL policies | `AllowedChannelIds` are provider-native IDs; Slack IDs are `C...` or `G...`, Discord IDs are snowflake strings, Mattermost IDs are Mattermost channel IDs. `ChannelAudiences` uses those same IDs plus reserved `dm`; values are lowercase `personal`, `team`, or `public`. Secrets stay in `secrets.json` and are preserved when blank on re-entry. | `src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs` proves Slack name -> ID persistence, audience remapping, unresolved Slack/Discord/Mattermost rejection, and secret preservation. Full editor-output -> runtime-options binding remains an explicit gap in Task 3.1. | +| Channel events -> ACL and gateway routing | Slack, Discord, and Mattermost adapters produce inbound provider messages and gateway route messages | `SlackAclPolicy`, `DiscordAclPolicy`, `MattermostAclPolicy`, and gateway actors before session delivery | Inbound messages carry provider-native channel ID, provider-native sender ID, accurate DM flag, source kind, and channel audience lookup keys matching configured IDs. Session identity remains `{channelId}/{threadTs}` or provider equivalent. | `src/Netclaw.Actors.Tests/Channels/Contracts/AclPolicyContractTests.cs`, `SlackAclContractTests.cs`, `DiscordAclContractTests.cs`, and `MattermostAclContractTests.cs` prove allowed, denied, DM, audience override, and invalid audience paths. `GatewayRoutingContractTests.cs` plus provider gateway contract tests prove denied messages are not routed and allowed messages are routed. | +| Scheduler -> delivery gateway | `SetReminderTool` and reminder persistence write `ReminderDefinition.Delivery` and later emit trusted delivery messages | Reminder execution actor and provider session binding actors that deliver without re-running inbound ACL | `Delivery.Kind` is `Channel` for channel delivery, `Delivery.Transport` is the lowercase provider key such as `slack`, and `Delivery.Address` is a canonical provider channel/user ID resolved before persistence. Runtime trusted delivery uses the stored target rather than a display name. | `src/Netclaw.Daemon.Tests/Reminder/ReminderTargetResolutionPathTests.cs` proves display target resolution to canonical channel/user IDs and unresolved target rejection. `src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs` proves delivery success/failure reporting. Full gateway-chain and no-inbound-ACL re-entry coverage remains an explicit gap in Task 5.3. | +| Tool schemas -> model/tool dispatcher | Built-in tool registrations and MCP tool adapters expose tool declarations and schemas | Provider serializers, `SessionToolExecutionPipeline`, `McpToolAdapter`, and MCP client manager | Model-facing tools serialize as OpenAI-compatible function tools with stable names, descriptions, JSON Schema parameters, and required fields. MCP tool names use `server/tool`. Dispatcher arguments preserve schema-declared string values and reconstruct structured JSON values only when the schema requires them. | `src/Netclaw.Daemon.Tests/Configuration/OpenAiCompatibleChatClientTests.cs` proves OpenAI function-tool serialization and tool-call history shape. `src/Netclaw.Daemon.Tests/Mcp/SmokeMcpServerArgumentCoercionTests.cs` proves schema-driven MCP argument reconstruction over the real stdio JSON-RPC path. Approval allow/deny/prompt and malformed metadata coverage remains an explicit gap in Task 4.2. | +| Memory persistence -> prompt assembly | Memory curation, SQLite memory store, session events, and compaction events persist memory and conversation state | `SQLiteMemoryRecallCoordinator`, `SessionMessageAssembler`, and system prompt/session state assembly | Persisted memory uses framework-owned SQLite records and wire enum strings such as trust audience wire values. Session history uses `SerializableChatMessage` records, not provider SDK chat types. Recall appears as volatile context/nudges and does not mutate the stable system prompt prefix. | `src/Netclaw.Actors.Tests/Memory/SQLiteMemoryStoreTests.cs` proves memory persistence/search filtering and audience boundaries. `src/Netclaw.Actors.Tests/Memory/MemoryRedesignedEvalSuiteTests.cs` proves formation -> persistence -> recall. `src/Netclaw.Actors.Tests/Sessions/SessionMessageAssemblerTests.cs`, `SessionStateTests.cs`, and `src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs` prove prompt assembly placement and serialization-safe session records. Restart/recovery and corrupt/missing state coverage remains an explicit gap in Task 5.2. | + ## CI Rules - required CI pipeline executes categories A-C only diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 8497acf65..6a5400d18 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -761,8 +761,10 @@ Rules: form unless the operator provides a configuration-style colon path. - `netclaw secrets add` is an alias for `set` and overwrites the same effective path. -- Re-running `netclaw init` updates secret values explicitly entered in the - wizard while preserving unrelated secrets. +- Re-running `netclaw init` on an existing install opens an action menu + (`Redo identity setup`, `Open configuration editor`, `Start over from + scratch`, `Cancel`) rather than re-walking setup. Update individual secrets + with `netclaw secrets set` or the relevant `netclaw config` editor. - If a channel reports a 401 or invalid-token error, rotate the relevant secret and restart the daemon so the channel reloads config. @@ -942,22 +944,31 @@ Exposure diagnostics are fail-closed: `cloudflare-tunnel`) require their local tunnel process by default. `Daemon.SkipTunnelProcessCheck=true` is an explicit opt-in only for sidecar or host-managed tunnel topologies; all other exposure requirements still apply. - -The `netclaw init` wizard's Network Exposure step offers all five modes — -`local`, `reverse-proxy`, `tailscale-serve`, `tailscale-funnel`, -`cloudflare-tunnel`. Selecting `reverse-proxy` adds two follow-up prompts that -collect `Daemon.Host` (must be non-loopback) and `Daemon.TrustedProxies` (≥1 -entry required, comma-separated). The wizard refuses to advance past the -trusted-proxies prompt with an empty list — the same minimum the daemon -validator enforces at startup — so an operator who does not yet know their -proxy IP should choose `local` and re-run `netclaw init` later, supplying the -bind address and trusted proxies on the second pass once the proxy topology -is known. +- The `netclaw config` Exposure Mode editor preserves dormant reverse-proxy + values in `~/.netclaw/config/editor-state.json` when switching to `local` or a + tunnel mode. Runtime-active `Daemon.Host` and `Daemon.TrustedProxies` are + removed from `netclaw.json` while inactive so local startup validation remains + loopback-only. Treat `editor-state.json` as passive editor state, not daemon + configuration. + +Network exposure is configured in `netclaw config` → Security & Access → +Exposure Mode — not in first-run `netclaw init`, which is a minimal bootstrap +(Provider → Identity → Security Posture → Enabled Features → Health Check). +The exposure editor offers all five modes — `local`, `reverse-proxy`, +`tailscale-serve`, `tailscale-funnel`, `cloudflare-tunnel`. Selecting +`reverse-proxy` collects `Daemon.Host` (must be non-loopback) and +`Daemon.TrustedProxies` (≥1 entry required, comma-separated). The editor +refuses to save past the trusted-proxies prompt with an empty list — the same +minimum the daemon validator enforces at startup — so an operator who does not +yet know their proxy IP can leave exposure at `local` and set the bind address +and trusted proxies later once the proxy topology is known. Config files: `~/.netclaw/config/netclaw.json` (daemon-owned base config, including `Daemon.Host`, `Daemon.Port`, `Daemon.ExposureMode`), `~/.netclaw/client/config.json` (local CLI endpoint state), -`~/.netclaw/config/secrets.json` (credentials — never display API keys). +`~/.netclaw/config/secrets.json` (credentials — never display API keys), and +`~/.netclaw/config/editor-state.json` (passive config-editor state for dormant +mode-specific values). ## Feature Kill Switches diff --git a/opencode.jsonc b/opencode.jsonc index 2f81d7141..883f88554 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -1,5 +1,20 @@ { "$schema": "https://opencode.ai/config.json", + "model": "openai/gpt-5.5", + "agent": { + "build": { + "model": "openai/gpt-5.5", + "variant": "xhigh" + }, + "plan": { + "model": "openai/gpt-5.5", + "variant": "xhigh" + }, + "general": { + "model": "openai/gpt-5.5", + "variant": "xhigh" + } + }, "mcp": { "aspire": { "type": "local", @@ -20,4 +35,4 @@ "enabled": true } } -} \ No newline at end of file +} diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/.openspec.yaml b/openspec/changes/archive/2026-06-09-netclaw-config-command/.openspec.yaml new file mode 100644 index 000000000..0f0616986 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/design.md b/openspec/changes/archive/2026-06-09-netclaw-config-command/design.md new file mode 100644 index 000000000..e46c9a8ae --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/design.md @@ -0,0 +1,191 @@ +## Context + +This change introduces `netclaw config` as the main post-install settings +surface. The IA is now locked as domain-oriented and heavier on sub-pages, +not a flat menu of every registered leaf editor. + +The section-editor abstraction remains the implementation substrate, but the +config command is free to group leaves, route some entries into existing +commands, and keep some capabilities out of scope entirely. + +## Goals / Non-Goals + +**Goals:** + +- Ship `netclaw config` as the main post-install settings command. +- Use a domain-oriented root dashboard. +- Keep Security Posture, Enabled Features, Audience Profiles, and Exposure + Mode distinct under `Security & Access`. +- Keep the existing `Daemon` config shape and global shell-mode shape. +- Route MCP permissions editing out to `netclaw mcp permissions` instead of + duplicating it. +- Use generalized save validation across all leaf editors. + +**Non-Goals:** + +- Editing Identity here. +- Adding MCP Servers to this branch. +- Flattening the IA to match registry order. +- Refactoring command back-stack behavior. +- Adding new persisted exposure-mode fields outside the current config + shape. + +## Decisions + +### D1. Root IA is domain-oriented + +The dashboard root is a navigation page with domain entries, not a flat +list of all leaf editors. The root contains: + +- Inference Providers +- Models +- Channels +- Inbound Webhooks +- Skill Sources +- Search +- Browser Automation +- Telemetry & Alerting +- Security & Access + +Alternative considered: render every registered editor directly in one flat +screen. Rejected because the locked IA is intentionally domain-oriented and +heavier on sub-pages. + +### D2. Routed handoffs are valid top-level outcomes + +`Inference Providers` routes to `netclaw provider` and `Models` routes to +`netclaw model`. This branch accepts the handoff without redesigning +navigation history. + +Alternative considered: re-host provider and model editors inline. Rejected +for scope and because the user explicitly accepted routed handoffs here. + +### D3. Security posture, enabled features, and audience profiles are separate + +These concepts are explicitly decoupled: + +- Security Posture sets the deployment stance. +- Enabled Features controls deployment-wide runtime enablement. +- Audience Profiles is a curated high-level per-audience editor. + +For Team and Public posture flows, changing posture continues into Enabled +Features. Personal skips that continuation. + +Alternative considered: keep per-audience feature toggles inside Audience +Profiles. Rejected because runtime enablement is deployment-wide, not a +per-audience policy surface. + +### D4. Audience Profiles is curated, not raw config + +Audience Profiles edits only: + +- Tool Access (non-MCP) +- File Access +- Incoming Attachments +- Reset to posture default + +It does not expose per-audience runtime feature toggles, per-audience shell +mode, MCP grants, or approval-policy editing. Reset/overwrite resets the +full underlying profile, including hidden MCP/approval settings. + +### D5. MCP permissions route out to the existing command + +If an operator needs MCP access, grant, or approval editing, the config +surface directs them to `netclaw mcp permissions`. + +### D6. Exposure Mode keeps the existing `Daemon` shape + +Exposure Mode uses explicit modes: + +- Local +- Reverse Proxy +- Tailscale Serve +- Tailscale Funnel +- Cloudflare Tunnel + +`Daemon.ExposureMode` is the single active selector. Mode-specific dialogs +edit only fields already supported by the current config shape. Inactive +values remain preserved. + +Alternative considered: one collapsed Tailscale option or new per-mode +active flags. Rejected by the locked decisions. + +### D7. First non-local enablement may bootstrap pairing automatically + +If the operator enables a non-local exposure mode and no bootstrap/pairing +state exists, the config flow auto-pairs the current configuring client. If +bootstrap state exists but is orphaned or mismatched, the flow blocks and +points the operator to `netclaw doctor`, the docs, and issue `#875`. + +### D8. Validation is generalized, not one bug-specific rule + +Every leaf editor validates what it edits before save: paths, URIs, +credentials, binary presence, referenced entities, and remote resource +reachability where appropriate. Structural invalidity is a hard block; +runtime/probe failures can present `Save anyway`. + +This closes the planning gap around `#1151` by making validation a general +leaf-editor rule rather than a one-off search bug workaround. + +### D9. Missing install refuses before any TUI starts + +If no install/config exists, `netclaw config` prints a plain non-zero +message directing the operator to `netclaw init`. No partial dashboard or +placeholder shell renders. + +### D10. Coverage follows ownership + +Leaf editors get substantive round-trip and smoke coverage. Routed handoffs +get shallow routing coverage only. + +### D11. Inline config editors autosave completed actions through one shared contract + +Inline config editors use a shared autosave interaction component instead of +page-specific save buttons or one-off status text. The standard behavior is: + +- `Esc` backs out or cancels incomplete input; it never saves. +- Completed actions save immediately after validation. +- Text and multi-field input becomes a completed action only when accepted + with `Enter` / Apply. +- Toggles, audience changes, enable/disable, add/remove, and confirmed reset + actions are completed actions. +- Structural validation failures block writes and leave disk unchanged. +- Runtime/probe failures may offer `Save anyway` only after the structurally + valid draft is known. +- Each write is section-preserving and field-scoped to the editor's ownership + boundary. + +Alternative considered: explicit `[s] Save` staged editing. Rejected because +the existing config surfaces behave like action editors, and mixing staged +edits with navigation caused operators to lose unrelated channel configuration. +The safer user model is “doing the thing saves the thing,” with `Esc` reserved +for navigation/cancel. + +## Risks / Trade-offs + +- The domain-oriented IA introduces more navigation depth. + Mitigation: the structure matches operator mental models and keeps the + root from becoming an unscannable flat list. +- Routed handoffs create command-context boundaries. + Mitigation: accepted for this branch; avoid stack refactors here. +- Audience Profiles hides some underlying settings. + Mitigation: that is intentional; reset/overwrite semantics explicitly + restore the full underlying profile, including hidden settings. +- Exposure-mode auto-pairing can fail on inconsistent state. + Mitigation: fail loudly and route to doctor/docs/#875 rather than doing + inline repair. +- Autosave can surprise operators if every keypress writes. + Mitigation: only completed actions autosave; incomplete text entry remains + an in-memory draft until accepted with `Enter` / Apply. + +## Migration Plan + +1. Land `netclaw config` as the primary post-install settings command. +2. Keep provider/model/MCP permission power-user commands in place. +3. Keep Identity in init. +4. Preserve existing config shape during migration; no `Daemon` section + rearrangement is required. + +## Open Questions + +None. The locked decisions remove the earlier IA ambiguity. diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/proposal.md b/openspec/changes/archive/2026-06-09-netclaw-config-command/proposal.md new file mode 100644 index 000000000..856497352 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/proposal.md @@ -0,0 +1,150 @@ +## Why + +After install, operators need one main settings surface. That surface is +now locked as `netclaw config`, while `netclaw init` is reduced to +bootstrap-only setup. The existing planning drifted toward a flat list of +leaf editors and duplicated advanced policy controls that already belong to +other commands. This change realigns the plan around the locked product +shape: + +- `netclaw config` is the main post-install settings surface. +- The root IA is domain-oriented, not a flat list of every leaf editor. +- Routed handoffs are acceptable for `Inference Providers -> netclaw provider` + and `Models -> netclaw model` without a navigation-stack refactor. +- MCP permission editing routes to `netclaw mcp permissions`; it is not + recreated inside `netclaw config`. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`, `PRD-002-gateway-security-envelope.md`. + +## What Changes + +- Add a top-level `netclaw config` command that launches a domain-oriented + navigation dashboard rather than a flat registry dump. +- The root dashboard SHALL include these areas for this branch: + - Inference Providers + - Models + - Channels + - Inbound Webhooks + - Skill Sources + - Search + - Browser Automation + - Telemetry & Alerting + - Security & Access +- Routed handoffs are first-class for: + - `Inference Providers` -> `netclaw provider` + - `Models` -> `netclaw model` + No back-stack refactor is required in this branch. +- `Channels` contains Slack, Discord, Mattermost. +- `Skill Sources` contains External Skills and Skill Feeds. +- `Telemetry & Alerting` contains Telemetry and Outbound Webhooks only in + this pass. Delivery policy tuning is deferred. +- `Security & Access` contains Security Posture, Enabled Features, + Audience Profiles, and Exposure Mode. +- Leave MCP Servers out of scope for this branch. Any MCP permissions, + grants, or approval editing SHALL route to `netclaw mcp permissions`. +- Keep posture values to `Personal`, `Team`, and `Public` only. +- Keep Security Posture, Enabled Features, and Audience Profiles as + separate concepts: + - Security Posture: selects the high-level operating stance. + - Enabled Features: deployment-wide runtime enablement. + - Audience Profiles: curated high-level per-audience editor. +- Audience Profiles SHALL remove per-audience feature toggles and + per-audience shell mode. Audience Profiles SHALL focus on: + - Tool Access (non-MCP) + - File Access + - Incoming Attachments + - Reset to posture default +- `Reset to posture default` / posture overwrite SHALL reset the full + underlying audience profile, including hidden MCP/approval settings for + that audience. +- Exposure Mode is edited under `Security & Access` and retains the + existing `Daemon` config shape. Modes remain explicit: + `Local`, `Reverse Proxy`, `Tailscale Serve`, `Tailscale Funnel`, + `Cloudflare Tunnel`. +- Each non-local exposure mode gets its own mode-specific dialog. `Local` + requires no extra setup. +- Keep a single active selector via `Daemon.ExposureMode`; do not add + per-mode active flags. Preserve inactive old values in config and ignore + them when inactive. +- Do not add or persist new exposure-specific fields that do not already + fit the current config shape. +- First-time enablement of a non-local exposure mode from `netclaw config` + SHALL auto-pair the current configuring client if no bootstrap/pairing + state exists yet. +- If existing bootstrap state is orphaned or mismatched, the editor SHALL + block and point the operator to `netclaw doctor`, the formal docs, and + issue `#875`. No inline repair is in scope. +- `netclaw config` on a missing install SHALL refuse with a plain non-zero + message directing the operator to `netclaw init`. No partial TUI renders. +- Validation is generalized across leaf editors: each leaf validates what + it edits before save, including local references and external probes when + relevant. Structurally invalid config remains non-overridable; runtime or + probe failures MAY offer `Save anyway`. +- Inline leaf editors use one shared autosave interaction contract: completed + actions persist immediately after validation, `Esc` only navigates or + cancels incomplete input, and there is no explicit save key for ordinary + config edits. +- Autosaves are atomic and section-preserving. An editor writes only the + fields it owns; disabling a provider or feature preserves dormant values, + while destructive removal requires an explicit reset/confirm action. +- Round-trip preservation and test assertions are semantic, not + byte-identical. +- Leaf editors receive substantive round-trip and smoke coverage. Routed + handoffs receive shallow routing coverage only. + +**In scope (MVP):** `netclaw config`, domain-oriented dashboard IA, routed +handoffs for providers/models, leaf editors for the in-scope areas above, +generalized validation behavior, shared autosave interaction behavior, +section-preserving persistence, exposure-mode dialogs within the existing +config shape, missing-install refusal, and coverage aligned to leaf-vs- +routed responsibilities. + +**Out of scope:** Identity editing, MCP Servers, MCP permissions editing +inside config, delivery-policy tuning, config-stack/back-stack redesign, +new exposure-specific persisted fields, inline bootstrap repair, and any +config-shape rearrangement of the existing `Daemon` or global shell mode +sections. + +## Capabilities + +### New Capabilities + +- `netclaw-config-command`: contract for the domain-oriented config + dashboard, routed handoffs, leaf-editor hosting, generalized validation, + missing-install refusal, and coverage expectations. + +### Modified Capabilities + +- `netclaw-cli`: add `netclaw config` as a top-level settings command. +- `feature-selection-wizard`: move post-install runtime enablement editing + to the `Enabled Features` leaf under `Security & Access`, while keeping + init bootstrap behavior aligned to posture. + +## Impact + +**Affected systems:** + +- CLI routing for `netclaw config`. +- Termina config dashboard and sub-pages. +- Section-editor hosting for in-scope leaves. +- Routed handoff affordances for provider/model commands. +- Exposure-mode editing and validation. +- Test surface for leaf editors, routing coverage, and generalized save + validation. +- Shared config TUI interaction component for autosaving completed actions. + +**Security and operational impact:** + +- Ongoing settings now have one primary post-install home. +- Audience Profiles no longer duplicate MCP permissions or raw low-level + policy editing. +- Exposure-mode changes keep the existing config shape and preserve + inactive values. +- Validation behavior is generalized beyond issue `#1151`; structural + invalidity still blocks writes, while runtime reachability failures can + be overridden with `Save anyway`. +- Navigation no longer implies persistence. Completed config actions save + immediately, while `Esc` remains safe navigation/cancel behavior. +- Section-preserving writes prevent one editor or provider action from + deleting unrelated persisted configuration. diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/feature-selection-wizard/spec.md b/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/feature-selection-wizard/spec.md new file mode 100644 index 000000000..834b826a8 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/feature-selection-wizard/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Post-install runtime feature editing SHALL move to Enabled Features + +Post-install runtime feature editing SHALL move to +`netclaw config -> Security & Access -> Enabled Features`, not to Audience +Profiles. + +**Reason**: Runtime feature enablement is deployment-wide and remains a +separate concept from Security Posture and Audience Profiles. + +Audience Profiles remains a curated per-audience access editor and SHALL NOT +own per-audience runtime feature toggles. + +#### Scenario: Post-install feature editing does not use Audience Profiles + +- **GIVEN** the operator wants to change deployment-wide search or memory + enablement after install +- **WHEN** they use `netclaw config` +- **THEN** the change is made in `Enabled Features` +- **AND** Audience Profiles is not used for that runtime toggle + +### Requirement: Feature config Enabled flags + +The configuration schema SHALL include deployment-wide `Enabled` flags for +the applicable runtime features. These flags MAY be set during bootstrap +and SHALL be editable post-install through the Enabled Features leaf. The +post-install editor and bootstrap flow SHALL preserve config semantics for +equivalent inputs; byte-identical serialization is not required. + +#### Scenario: Enabled Features writes deployment-wide flags + +- **GIVEN** the operator disables search in Enabled Features +- **WHEN** the editor saves +- **THEN** `Search.Enabled` is `false` in `netclaw.json` + +#### Scenario: Personal posture default keeps all features enabled + +- **GIVEN** the operator selected Personal posture during bootstrap +- **WHEN** bootstrap finalizes config +- **THEN** deployment-wide runtime features default to enabled diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/netclaw-cli/spec.md b/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/netclaw-cli/spec.md new file mode 100644 index 000000000..80e96c8a3 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/netclaw-cli/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Config command surface + +The CLI SHALL expose `netclaw config` as a top-level command. The command +SHALL operate on local config files and SHALL behave per the +`netclaw-config-command` capability. + +If no config exists, `netclaw config` SHALL print a plain message directing +the operator to `netclaw init` and exit non-zero without launching Termina. + +#### Scenario: Help text describes config as post-install settings surface + +- **WHEN** the operator runs `netclaw config --help` +- **THEN** the command exits zero +- **AND** help text describes `netclaw config` as the main post-install + settings surface +- **AND** help text references `netclaw init` as the bootstrap companion + +#### Scenario: No-args invocation launches dashboard on configured install + +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw config` +- **THEN** the domain-oriented dashboard launches + +#### Scenario: Missing install refuses with plain message + +- **GIVEN** `netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** stderr contains `No configuration found. Run \`netclaw init\` first.` +- **AND** the command exits non-zero +- **AND** no partial TUI starts diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/netclaw-config-command/spec.md b/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/netclaw-config-command/spec.md new file mode 100644 index 000000000..cd0828141 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/specs/netclaw-config-command/spec.md @@ -0,0 +1,282 @@ +## ADDED Requirements + +### Requirement: Config command launches a domain-oriented dashboard + +`netclaw config` SHALL launch a domain-oriented settings dashboard. The +root SHALL be navigation-first and SHALL NOT be a flat list of every +registered leaf editor. + +The root SHALL include: + +- `Inference Providers` +- `Models` +- `Channels` +- `Inbound Webhooks` +- `Skill Sources` +- `Search` +- `Browser Automation` +- `Telemetry & Alerting` +- `Security & Access` + +#### Scenario: Root dashboard shows domain entries + +- **GIVEN** a configured install +- **WHEN** the operator runs `netclaw config` +- **THEN** the root dashboard opens with the documented domain entries +- **AND** it does not render a flat dump of every registered leaf editor + +### Requirement: Missing install refuses before TUI startup + +`netclaw config` SHALL detect a missing install/config before starting the +TUI. It SHALL print `No configuration found. Run \`netclaw init\` first.` +to stderr and exit non-zero. + +#### Scenario: No install refusal renders no TUI + +- **GIVEN** `~/.netclaw/config/netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** the command prints the refusal message to stderr +- **AND** exits non-zero +- **AND** no partial TUI is rendered + +### Requirement: Routed handoffs SHALL be first-class config outcomes + +The config dashboard SHALL treat routed handoffs as first-class config +outcomes and MAY route specific domain entries into existing commands +instead of re-hosting the full editor inline. In this branch, `Inference +Providers` SHALL route to `netclaw provider` and `Models` SHALL route to +`netclaw model`. + +#### Scenario: Inference Providers routes to provider command + +- **GIVEN** the operator selects `Inference Providers` +- **WHEN** the handoff is activated +- **THEN** the flow routes to `netclaw provider` +- **AND** no config-dashboard back-stack refactor is required + +### Requirement: Security & Access separates posture, features, profiles, and exposure + +The `Security & Access` area SHALL contain separate entries for Security +Posture, Enabled Features, Audience Profiles, and Exposure Mode. + +Security Posture, Enabled Features, and Audience Profiles SHALL remain +distinct concepts: + +- Security Posture selects the deployment stance. +- Enabled Features controls deployment-wide runtime enablement. +- Audience Profiles edits curated per-audience high-level access rules. + +#### Scenario: Team posture continues into enabled-features flow + +- **GIVEN** the operator changes Security Posture to `Team` +- **WHEN** the posture change flow completes +- **THEN** the config flow continues into Enabled Features + +#### Scenario: Personal posture skips enabled-features continuation + +- **GIVEN** the operator changes Security Posture to `Personal` +- **WHEN** the posture change flow completes +- **THEN** the config flow does not force an Enabled Features continuation + +### Requirement: Audience Profiles is curated and excludes MCP editing + +The Audience Profiles editor SHALL be a curated high-level editor. It SHALL +focus on: + +- Tool Access (non-MCP) +- File Access +- Incoming Attachments +- Reset to posture default + +It SHALL NOT expose: + +- per-audience runtime feature toggles +- per-audience shell mode +- MCP grants/access editing +- raw approval-policy editing + +MCP access/grants/approval editing SHALL route to `netclaw mcp permissions`. + +#### Scenario: Audience Profiles omits per-audience feature toggles + +- **WHEN** the operator opens Audience Profiles +- **THEN** the UI does not offer per-audience runtime feature toggles +- **AND** runtime enablement remains owned by Enabled Features + +#### Scenario: Reset to posture default resets full underlying profile + +- **GIVEN** an audience has customized visible settings and hidden MCP or + approval settings +- **WHEN** the operator activates `Reset to posture default` +- **THEN** the full underlying audience profile is reset to posture + defaults +- **AND** hidden MCP and approval settings for that audience are reset as + well + +### Requirement: Exposure Mode preserves current config shape + +The Exposure Mode editor SHALL keep the existing `Daemon` config shape. It +SHALL use `Daemon.ExposureMode` as the single active selector and SHALL NOT +introduce per-mode active flags. + +Supported explicit modes are: + +- `Local` +- `Reverse Proxy` +- `Tailscale Serve` +- `Tailscale Funnel` +- `Cloudflare Tunnel` + +Each non-local mode SHALL use its own mode-specific dialog. `Local` +requires no extra setup. Inactive old values SHALL be preserved and ignored +when inactive. + +#### Scenario: Switching modes preserves inactive values + +- **GIVEN** the config contains previously saved Cloudflare Tunnel values +- **AND** `Daemon.ExposureMode` is currently `Reverse Proxy` +- **WHEN** the operator edits Reverse Proxy settings and saves +- **THEN** the inactive Cloudflare values remain preserved in config +- **AND** the active mode remains determined only by `Daemon.ExposureMode` + +### Requirement: First non-local exposure enablement SHALL bootstrap pairing when needed + +The flow SHALL auto-pair the current configuring client when the operator +first enables a non-local exposure mode from `netclaw config` and no +bootstrap/pairing state exists. + +If bootstrap state is orphaned or mismatched, the flow SHALL block and +direct the operator to `netclaw doctor`, formal docs, and issue `#875`. + +#### Scenario: Missing bootstrap state auto-pairs current client + +- **GIVEN** the operator enables `Tailscale Serve` +- **AND** no bootstrap or pairing state exists yet +- **WHEN** the save flow runs +- **THEN** the current configuring client is auto-paired before the mode is + finalized + +#### Scenario: Orphaned bootstrap state blocks save + +- **GIVEN** the operator enables a non-local exposure mode +- **AND** existing bootstrap state is orphaned or mismatched +- **WHEN** the save flow validates exposure setup +- **THEN** the save is blocked +- **AND** the operator is directed to `netclaw doctor`, formal docs, and + issue `#875` + +### Requirement: Leaf validation is generalized + +Every config leaf editor SHALL validate what it edits before save. +Validation SHALL cover local structural validity and any relevant probes +such as paths, URIs, auth, binary presence, or remote reachability. + +Structurally invalid config SHALL block save without override. +Runtime/probe failures MAY present `Save anyway`. + +#### Scenario: Structural error blocks save with no override + +- **GIVEN** a leaf editor contains an invalid URI or malformed config + reference +- **WHEN** the operator saves +- **THEN** save is blocked +- **AND** no `Save anyway` affordance is shown + +#### Scenario: Probe failure offers Save anyway + +- **GIVEN** a leaf editor is structurally valid +- **AND** a remote reachability or runtime probe fails +- **WHEN** the operator saves +- **THEN** the editor may show `Save anyway` +- **AND** the operator can choose to persist the structurally valid config + +### Requirement: Inline config editors autosave completed actions consistently + +Every inline `netclaw config` leaf editor SHALL use a shared autosave +interaction contract. The UI SHALL NOT require an explicit save key for +ordinary config edits. + +Completed actions SHALL save immediately after validation. Completed actions +include accepted text or multi-field forms, toggles, audience changes, +enable/disable actions, add/remove actions, and confirmed reset actions. +Incomplete text input SHALL remain an in-memory draft until accepted with +`Enter` or an equivalent Apply action. + +`Esc` SHALL only navigate back or cancel incomplete input. It SHALL NOT save +pending edits and SHALL NOT be required to complete a save. + +All autosaves SHALL be atomic: validation SHALL complete before files are +written, and failed validation SHALL leave persisted config and secrets +unchanged. + +#### Scenario: Completed toggle autosaves immediately + +- **GIVEN** an inline config leaf editor contains a boolean toggle +- **WHEN** the operator toggles the setting +- **THEN** the editor validates the resulting state +- **AND** persists the change immediately when validation succeeds +- **AND** shows a saved status without asking the operator to press a save key + +#### Scenario: Esc cancels draft text without persisting + +- **GIVEN** an inline config leaf editor contains a text field +- **AND** the operator has typed a draft value but has not accepted it +- **WHEN** the operator presses `Esc` +- **THEN** the editor navigates back or cancels the draft +- **AND** the persisted config is unchanged + +#### Scenario: Invalid completed action writes nothing + +- **GIVEN** an inline config leaf editor contains a structurally invalid draft +- **WHEN** the operator accepts the action +- **THEN** validation fails +- **AND** no config or secrets file is modified +- **AND** the UI shows the validation error + +### Requirement: Inline config persistence is section-preserving + +Inline config leaf editors SHALL persist only the sections, providers, +fields, and sidecar files they own. Saving one provider or sub-area SHALL NOT +delete or reset unrelated providers, inactive values, secrets, audiences, or +sidecar files. + +Disable actions SHALL preserve dormant configuration and secrets while writing +only the runtime-enabled flag. Destructive removal SHALL require an explicit +reset/confirm action and SHALL be scoped to the confirmed target. + +#### Scenario: Disabling one channel provider preserves its dormant setup + +- **GIVEN** Slack has saved channels, audiences, allowed users, and secrets +- **WHEN** the operator disables Slack from the Channels config area +- **THEN** Slack `Enabled` is persisted as `false` +- **AND** Slack channels, audiences, allowed users, and secrets remain + persisted + +#### Scenario: Saving one channel provider does not wipe another provider + +- **GIVEN** Slack and Discord both have saved channel configuration +- **WHEN** the operator adds a Discord channel and the action autosaves +- **THEN** the Discord addition is persisted +- **AND** the saved Slack configuration remains present and unchanged except + for any explicit Slack action the operator completed + +#### Scenario: Reset is the only provider-destructive action + +- **GIVEN** a provider has saved channel configuration and secrets +- **WHEN** the operator confirms reset for that provider +- **THEN** only that provider's config and secrets are removed +- **AND** other providers remain unchanged + +### Requirement: Coverage follows leaf ownership + +Leaf editors SHALL receive substantive round-trip and smoke coverage. +Routed handoffs SHALL receive shallow routing coverage only. Preservation +assertions SHALL be semantic, not byte-identical. + +#### Scenario: Routed handoff does not require leaf round-trip suite + +- **GIVEN** `Inference Providers` routes to `netclaw provider` +- **WHEN** coverage is defined for the config dashboard +- **THEN** the handoff requires routing coverage +- **AND** it does not require a duplicate leaf-editor round-trip suite in + this change diff --git a/openspec/changes/archive/2026-06-09-netclaw-config-command/tasks.md b/openspec/changes/archive/2026-06-09-netclaw-config-command/tasks.md new file mode 100644 index 000000000..3e143b76a --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-config-command/tasks.md @@ -0,0 +1,154 @@ +## 1. OpenSpec planning artifacts and traceability + +- [x] 1.1 Confirm proposal, design, and spec deltas reflect the + domain-oriented config IA and the locked ownership split. +- [x] 1.2 Remove planning language that still assumes Enterprise posture, + per-audience runtime feature toggles, per-audience shell mode, inline + MCP permission editing, flat dashboards, or byte-identical assertions. +- [x] 1.3 Run `openspec validate netclaw-config-command --type change`. + +## 2. Command entry and refusal behavior + +- [x] 2.1 Add `netclaw config` to CLI routing. +- [x] 2.2 Refuse with a plain non-zero message when no install/config is + present: direct operators to `netclaw init` and render no TUI. +- [x] 2.3 Keep `--help` discoverable from `netclaw --help`. + +## 3. Root dashboard IA + +- [x] 3.1 Implement the root dashboard as domain navigation, not a flat + list of every leaf editor. +- [x] 3.2 Add these root entries: Inference Providers, Models, Channels, + Inbound Webhooks, Skill Sources, Search, Browser Automation, + Telemetry & Alerting, Security & Access. +- [x] 3.3 Add Quit and Run Full Doctor affordances at the root. + +## 4. Routed handoffs + +- [x] 4.1 Route `Inference Providers` to `netclaw provider`. +- [x] 4.2 Route `Models` to `netclaw model`. +- [x] 4.3 Add shallow routing coverage for both handoffs. + +## 5. Channels area + +- [x] 5.1 Add `Channels` sub-page containing Slack, Discord, Mattermost. +- [x] 5.2 Keep each channel editor as a leaf with substantive validation + and round-trip coverage. + +## 6. Skill Sources area + +- [x] 6.1 Add `Skill Sources` sub-page containing External Skills and + Skill Feeds. +- [x] 6.2 Keep validation for paths, URIs, auth, and reachability aligned + to the generalized save-validation rule. + +## 7. Telemetry & Alerting area + +- [x] 7.1 Add `Telemetry & Alerting` sub-page. +- [x] 7.2 Include Telemetry and Outbound Webhooks only in this pass. +- [x] 7.3 Defer delivery-policy tuning. + +## 8. Security & Access area + +- [x] 8.1 Add `Security & Access` sub-page. +- [x] 8.2 Include Security Posture, Enabled Features, Audience Profiles, + and Exposure Mode. +- [x] 8.3 Keep posture values to `Personal`, `Team`, and `Public` only. + +## 9. Security Posture leaf + +- [x] 9.1 Keep Security Posture distinct from Enabled Features and + Audience Profiles. +- [x] 9.2 When posture changes to Team or Public, continue into Enabled + Features. +- [x] 9.3 When posture changes to Personal, skip the Enabled Features + continuation. +- [x] 9.4 Support overwrite/reset behavior that resets the full underlying + audience profile when requested. + +## 10. Enabled Features leaf + +- [x] 10.1 Implement Enabled Features as deployment-wide runtime + enablement. +- [x] 10.2 Do not represent Enabled Features as per-audience policy. +- [x] 10.3 Cover runtime-enablement editing with substantive round-trip and + smoke tests. + +## 11. Audience Profiles leaf + +- [x] 11.1 Implement Audience Profiles as a curated high-level editor. +- [x] 11.2 Remove per-audience feature toggles from this editor. +- [x] 11.3 Remove per-audience shell mode from this editor. +- [x] 11.4 Limit editable concerns to Tool Access (non-MCP), File Access, + Incoming Attachments, and Reset to posture default. +- [x] 11.5 Ensure reset/overwrite resets the full underlying audience + profile, including hidden MCP and approval settings. +- [x] 11.6 Route MCP access/grants/approval editing to + `netclaw mcp permissions` instead of recreating it here. + +## 12. Exposure Mode leaf + +- [x] 12.1 Implement explicit modes: Local, Reverse Proxy, + Tailscale Serve, Tailscale Funnel, Cloudflare Tunnel. +- [x] 12.2 Keep a single active selector via `Daemon.ExposureMode`. +- [x] 12.3 Do not add per-mode active flags. +- [x] 12.4 Keep the existing `Daemon` config shape; do not rearrange + config sections. +- [x] 12.5 Preserve inactive old values and ignore them when inactive. +- [x] 12.6 Give each non-local mode its own dialog; Local requires no + extra setup. +- [x] 12.7 Do not add new persisted exposure-specific fields that do not + exist in the current config shape. +- [x] 12.8 On first non-local enablement, auto-pair the current + configuring client when no bootstrap/pairing state exists. +- [x] 12.9 If bootstrap state is orphaned or mismatched, block and point + the operator to `netclaw doctor`, formal docs, and issue `#875`. + +## 13. Validation model + +- [x] 13.1 Apply generalized pre-save validation to every leaf editor. +- [x] 13.2 Validate paths, URIs, auth, binary presence, local references, + and remote reachability where relevant. +- [x] 13.3 Keep structurally invalid config as a hard block. +- [x] 13.4 Allow `Save anyway` only for runtime/probe failures. +- [x] 13.5 Update planning/tests around `#1151` so validation is framed as + a cross-editor rule, not just a narrow search regression. + +## 14. Coverage + +- [x] 14.1 Add shared autosave contract tests for every inline config leaf: + completed actions persist, `Esc` does not save incomplete drafts, and + invalid completed actions write nothing. +- [x] 14.2 Add substantive round-trip tests for leaf editors. +- [x] 14.3 Add substantive smoke tapes for leaf editors. +- [x] 14.4 Use semantic preservation assertions, not byte-identical file + assertions. +- [x] 14.5 Add shallow routing coverage for routed handoffs only. + +## 16. Shared autosave config interaction + +- [x] 16.1 Introduce a shared autosave interaction component/contract for + inline config editors. +- [x] 16.2 Remove explicit save-key behavior and copy from inline config + editors; completed actions autosave instead. +- [x] 16.3 Ensure `Esc` only navigates/cancels and never persists edits. +- [x] 16.4 Ensure each autosave validates before writing and leaves files + unchanged on validation failure. +- [x] 16.5 Ensure writes are section-preserving and field-scoped to editor + ownership boundaries. +- [x] 16.6 Harden Channels persistence so provider enable/disable, add/remove, + audience, allowed-user, direct-message, and credential actions autosave + provider-granular changes without wiping unrelated providers. +- [x] 16.7 Add the regression: seed Slack and Discord, add a Discord channel, + disable Slack, press `Esc`, and verify only completed autosaves occurred + with Slack dormant setup preserved. + +## 15. Quality gates + +- [x] 15.1 `dotnet build` clean. +- [x] 15.2 `dotnet test` clean. +- [x] 15.3 `./scripts/smoke/run-smoke.sh light` clean. +- [x] 15.4 `dotnet slopwatch analyze` clean. +- [x] 15.5 `./scripts/Add-FileHeaders.ps1 -Verify` clean. +- [x] 15.6 `openspec validate netclaw-config-command --type change` + passes. diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/.openspec.yaml b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/.openspec.yaml new file mode 100644 index 000000000..b4c82a0a9 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-06 diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/design.md b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/design.md new file mode 100644 index 000000000..f0d829f60 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/design.md @@ -0,0 +1,354 @@ +## Context + +`netclaw config` currently has reusable presentation helpers, but mutable +behavior is still page-specific. Several pages manually subscribe to key +events, append text to view-model drafts, handle `Enter`, and call save or +autosave methods directly. That makes validation a convention: a page can look +correct, pass render tests, and still bypass static validation, dynamic +validation, or canonical runtime-consumer checks. + +The active config change already states the desired behavior: completed +actions autosave after validation, incomplete drafts do not persist, and +runtime/probe failures are handled explicitly. This design makes that behavior +enforceable by moving mutable input and commit behavior into page-independent +Netclaw UI components. + +This change does not affect actor/session boundaries. It is a CLI/TUI and +configuration-persistence boundary change. The important downstream consumers +are daemon startup/options binding, channel adapter config, skill scanning/feed +loading, search provider setup, webhook runtime, ACL/security policy, and other +runtime services that consume persisted config/secrets. + +## Goals / Non-Goals + +**Goals:** + +- Make missing static validation impossible for mutable Netclaw TUI actions. +- Make missing dynamic validation explicit through either `Required` or + `NotApplicable(justification)`. +- Route `Enter`, save/apply, autosave, toggles, picker selections, token + rotation, reset, delete, and confirmed destructive actions through one + pipeline. +- Move text input, paste, `Enter`, and autosave handling out of config pages + and into standard page-independent components. +- Add build enforcement that fails when pages bypass the standard components + or commit pipeline. +- Delete obsolete tests/components only when replacement coverage proves they + are no longer needed. + +**Non-Goals:** + +- Redesigning the `netclaw config` information architecture. +- Implementing `simplify-netclaw-init`. +- Changing persisted config schema or runtime option types unless a migration + task discovers an existing mismatch. +- Removing tests just because they are old. +- Converting non-mutable display pages to validated components. + +## Decisions + +### D1. Use a single mandatory commit object + +The core abstraction is intentionally small: one required object describes a +mutable UI action. + +```csharp +internal sealed record NetclawUiCommit( + string Id, + string Label, + Func ReadDraft, + Action WriteDraft, + Func Validate, + NetclawUiDynamicCheck DynamicCheck, + Func PersistAsync, + Action AfterCommit); +``` + +Rationale: this is simpler than a framework of validator/writer interfaces, +but it still forces every mutable field/action to declare all required hooks. +Interfaces can be introduced later only if delegate-based commits become hard +to read or reuse. + +Alternative considered: separate `IConfigStaticValidator`, +`IConfigDynamicValidator`, `IConfigCommitWriter`, and draft-binding +interfaces. Rejected for the first pass because it increases surface area +without adding enforcement beyond what one required commit object provides. + +### D2. Dynamic validation is an explicit discriminated policy + +Dynamic validation is never nullable and never absent by omission. + +```csharp +internal abstract record NetclawUiDynamicCheck +{ + private NetclawUiDynamicCheck() { } + + internal sealed record Required( + Func> ValidateAsync, + NetclawUiDynamicFailurePolicy FailurePolicy) : NetclawUiDynamicCheck; + + internal sealed record NotApplicable(string Justification) : NetclawUiDynamicCheck; +} +``` + +`NotApplicable` must reject empty or whitespace-only justification. The +justification is not busywork; it records why no runtime/probe check applies. + +Alternative considered: make dynamic validation optional via nullable delegate. +Rejected because that recreates the current failure mode. + +### D3. The commit pipeline is the only persistence path + +All completed mutable actions flow through one pipeline. + +```csharp +internal sealed class NetclawUiCommitPipeline +{ + public ValueTask CommitAsync( + NetclawUiCommit commit, + NetclawUiCommitTrigger trigger, + CancellationToken ct); +} + +internal enum NetclawUiCommitTrigger +{ + Enter, + Save, + AutoSave, + Toggle, + PickerSelection, + Delete, + Reset, + TokenRotation, +} +``` + +Pipeline order: + +```text +ReadDraft +-> Validate +-> DynamicCheck.Required or DynamicCheck.NotApplicable +-> PersistAsync +-> AfterCommit +``` + +Persistence never runs after a static validation failure. Dynamic validation +never runs after a static validation failure. Persistence never runs after a +dynamic validation failure unless the failure policy explicitly allows a +save-anyway path and the operator chooses that path through the pipeline. + +Alternative considered: keep `ConfigAutosave` and direct `Save` methods but +audit them harder. Rejected because that keeps multiple persistence paths. + +### D4. Standard components own mutable input handling + +Pages compose standard components. Components own the mutable interaction. + +```csharp +internal interface INetclawUiComponent +{ + ILayoutNode Build(); + bool HandleInput(ConsoleKeyInfo keyInfo); + void HandlePaste(PasteEvent paste); +} + +internal sealed class NetclawValidatedTextField : INetclawUiComponent +{ + public NetclawValidatedTextField( + NetclawUiCommit commit, + NetclawUiCommitPipeline pipeline, + TextInputNode input); +} + +internal sealed class NetclawValidatedAction : INetclawUiComponent +{ + public NetclawValidatedAction( + NetclawUiCommit commit, + NetclawUiCommitPipeline pipeline, + Func nextDraft); +} + +internal sealed class NetclawValidatedToggle : INetclawUiComponent +{ + public NetclawValidatedToggle( + NetclawUiCommit commit, + NetclawUiCommitPipeline pipeline); +} + +internal sealed class NetclawValidatedPicker : INetclawUiComponent +{ + public NetclawValidatedPicker( + NetclawUiCommit commit, + NetclawUiCommitPipeline pipeline, + IReadOnlyList options); +} +``` + +The constructors require a commit object. There is no constructor that accepts +only a label, current value, and raw save callback. + +Alternative considered: keep page-level `HandleKeyPress` methods and call the +pipeline from those handlers. Rejected because pages would still own the +dangerous control flow and future pages could bypass the pipeline again. + +### D5. Use a validated page/input router where possible + +Pages with mutable controls should derive from or compose a router that +delegates input to active validated components. + +```csharp +internal abstract class NetclawValidatedPage : ReactivePage +{ + protected abstract IReadOnlyList Components { get; } + + public sealed override bool HandlePageInput(ConsoleKeyInfo keyInfo); +} +``` + +If Termina constraints prevent a sealed override in every existing page, the +first pass may use an injected `NetclawUiInputRouter`. The enforcement rule +stays the same: mutable persistence is routed through validated components, +not page-specific save handlers. + +Alternative considered: enforce only at view-model level. Rejected because the +bug class is specifically the mismatch between rendered TUI actions and the +actual user key path. + +### D6. Enforcement is part of the feature, not a follow-up + +The implementation must include build enforcement. Preferred shape: + +```csharp +internal sealed class NetclawValidatedUiBypassAnalyzer : DiagnosticAnalyzer +``` + +Analyzer diagnostics should reject: + +- raw `TextInputNode` construction for mutable persisted fields outside + approved standard components +- page input handlers that call `Save`, `SaveAsync`, `ConfigAutosave`, or + config writer methods directly +- page or view-model autosave paths that do not use `NetclawUiCommitPipeline` +- mutable config page `ConsoleKey.Enter` branches that persist directly +- `NetclawUiDynamicCheck.NotApplicable` with empty justification + +Architecture tests may be added first as a backstop, but the task is not done +until build enforcement prevents the bypass class. If an analyzer is not +feasible in the current repo structure, the design must record why and the +architecture test must fail the build in CI for the same bypass cases. + +Alternative considered: rely on code review and OpenSpec checklists. Rejected +because that is the current failure mode. + +### D7. Config-specific logic lives behind adapters/factories + +The reusable UI layer is page-independent. Config-specific adapters create +commits. + +```text +SkillSourcesConfigPage +-> NetclawValidatedTextField +-> NetclawUiCommit +-> SkillSourcesCommitFactory +-> static path/url/name/token validators +-> dynamic skill scanner/feed probe validators +-> config/secrets writers +-> runtime binding verifier tests +``` + +Suggested adapter names: + +```csharp +internal static class SkillSourcesCommitFactory; +internal static class ChannelsCommitFactory; +internal static class TelemetryCommitFactory; +internal static class WorkspacesCommitFactory; +internal static class InboundWebhooksCommitFactory; +internal static class ExposureModeCommitFactory; +``` + +Alternative considered: name the reusable layer `ConfigCommit*`. Rejected +because these components should be usable outside config pages. + +### D8. Deletion is proof-based + +Old tests and components get deleted only when no longer needed. + +Deletion checklist: + +- the old artifact has no production callers, or all callers have migrated +- replacement tests cover the same behavior through the public user action +- no unique visual, accessibility, persistence, or edge-case assertion is lost +- `git grep` or architecture tests prove the old bypass pattern is gone + +Render-only tests that assert labels may be removed when component interaction +tests already cover rendering plus typed input, paste, `Enter`, validation +failure, unchanged persistence, and successful persistence. Direct view-model +save tests may remain if they cover pure domain validation, but they do not +count as user-action validation proof. + +Alternative considered: delete all legacy tests after migration starts. +Rejected because the operative rule is "we don't need," not "old." + +## TUI to backend relationship + +```text +TUI page +-> Netclaw validated component +-> NetclawUiCommit +-> NetclawUiCommitPipeline +-> static validator +-> dynamic validator or explicit NotApplicable policy +-> persistence writer +-> runtime binding verifier tests +-> status/reload/navigation +``` + +The TUI page owns layout and routing. The validated component owns user input. +The commit contract owns the mutation definition. The pipeline owns ordering +and failure handling. Backend validators and writers own domain behavior. +Runtime binding tests prove the persisted shape is consumed correctly. + +## Failure modes and recovery behavior + +- Static validation failure: show an error, do not call dynamic validation, do + not write files, keep the draft available for correction unless the action is + destructive and canceled. +- Dynamic validation failure: show a warning/error, do not write files, offer + save-anyway only if the declared failure policy allows it. +- Persistence exception: catch in the pipeline, show an error, leave page + active, and do not report success. +- Post-commit reload failure: show an error and do not hide the failure behind + a success message. +- Analyzer false positive: add the narrowest exemption in the standard + component layer only, never on a leaf page to silence a real bypass. + +## Migration Plan + +1. Add `NetclawUiCommit`, `NetclawUiDynamicCheck`, + `NetclawUiCommitPipeline`, result types, and standard validated components. +2. Add focused pipeline/component tests that prove validation ordering, + unchanged persistence on failure, typed input, paste, `Enter`, autosave, and + explicit `NotApplicable` justification. +3. Add build enforcement in warning-as-error mode for the targeted bypasses. +4. Migrate Skill Sources first because it exposed the current regression. +5. Migrate Telemetry & Alerting, Workspaces Directory, Inbound Webhooks, + Channels, Search, Browser Automation, and Exposure Mode. +6. Update config editor audit tests so user-action path coverage is required. +7. Remove obsolete helpers/tests only after replacement proof and caller + migration are complete. +8. Run focused tests after each page migration and native smoke for migrated + config paths before completion. + +Rollback strategy: the change is internal to the CLI/TUI. If a migration slice +fails, keep the standard component layer and stop before removing old callers. +Do not reintroduce direct page-level save/autosave bypasses. + +## Open Questions + +- Whether the build enforcement should be implemented first as a Roslyn + analyzer project or as architecture tests that run in the existing test + suite, then promoted to analyzer once stable. +- Whether non-config onboarding pages should migrate in this change or only + after config pages prove the component contract. diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/proposal.md b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/proposal.md new file mode 100644 index 000000000..d118fdab0 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/proposal.md @@ -0,0 +1,106 @@ +## Why + +Recent `netclaw config` regressions prove that validation is still a +convention instead of an architectural constraint: pages can render an input, +handle `Enter`, call save/autosave directly, and bypass static or dynamic +validation. The fix must move validation and commit behavior into reusable, +page-independent Netclaw UI components so missing validation fails at compile +or build time instead of relying on repeated human reminders. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`, `PRD-002-gateway-security-envelope.md`. + +## What Changes + +- Add page-independent Netclaw TUI commit components named with `NetclawUi*` + and `NetclawValidated*`, not `Config*`, so the validation contract can be + reused by config, onboarding, provider, model, MCP, and future operator UI + surfaces. +- Introduce one mandatory mutation contract, `NetclawUiCommit`, that + carries draft access, static validation, explicit dynamic validation policy, + persistence, and post-commit behavior. +- Introduce `NetclawUiCommitPipeline` as the only path for persisting mutable + UI actions. `Enter`, save/apply actions, autosave, toggles, pickers, delete, + reset, token rotation, and confirmed destructive actions all go through this + pipeline. +- Introduce `NetclawUiDynamicCheck` with two explicit states: + `Required(...)` or `NotApplicable(justification)`. Dynamic validation can no + longer be silently absent. +- Introduce standard components such as `NetclawValidatedTextField`, + `NetclawValidatedAction`, `NetclawValidatedToggle`, and + `NetclawValidatedPicker` that require a `NetclawUiCommit` in + their constructors. +- Introduce a validated page/input router so pages render and compose + components, while standard components own key handling for typed input, + paste, `Enter`, `Space`, picker selection, and autosave triggers. +- Add build-time enforcement, preferably a Roslyn analyzer with architecture + tests as a backstop, that fails when mutable TUI pages bypass the standard + components or commit pipeline. +- Migrate `netclaw config` pages to the standard Netclaw UI components, + starting with Skill Sources, Telemetry & Alerting, Workspaces Directory, + Inbound Webhooks, Channels, Search, Browser Automation, and Exposure Mode. +- Delete old tests, helper components, page-level input handlers, and UI + helpers only when they are no longer needed: the replacement component must + cover the same behavior, no callers may remain, and focused tests must prove + the replacement path. + +**BREAKING internal architecture change:** config pages and view models SHALL +NOT persist mutable UI actions by calling `Save`, `SaveAsync`, `ConfigAutosave`, +or config writers directly from page input handlers. Those paths must move to +`NetclawUiCommitPipeline` or become rejected by build enforcement. + +**In scope (MVP):** page-independent validated TUI commit primitives, +config-surface migration, enforcement against direct save/autosave bypasses, +replacement tests, native smoke coverage for migrated config flows, and removal +of obsolete tests/components proven redundant by the migration. + +**Out of scope:** visual redesign of the config IA, broad init simplification, +new persisted config shape, new runtime capabilities unrelated to validation, +and deleting still-needed tests or components merely because they predate this +change. + +## Capabilities + +### New Capabilities + +- `netclaw-validated-ui-components`: page-independent TUI mutation components, + commit pipeline, dynamic validation policy, autosave/Enter unification, and + build-time bypass enforcement. + +### Modified Capabilities + +- `netclaw-config-command`: config leaf editors must consume the validated + Netclaw UI components for mutable input and completed actions, and must prove + static validation, dynamic validation, autosave, persistence, and runtime + consumer contracts through the same user-action paths. +- `section-editor-abstraction`: leaf editor hosting must support validated UI + component composition without implying that pages can hand-roll input/save + behavior. + +## Impact + +**Affected code and APIs:** + +- New reusable TUI component namespace, expected under `Netclaw.Cli.Tui` with + names such as `NetclawUiCommit`, `NetclawUiCommitPipeline`, and + `NetclawValidatedTextField`. +- Existing config pages under `src/Netclaw.Cli/Tui/Config/*ConfigPage.cs`. +- Existing config view models that currently expose direct `Save`, `SaveAsync`, + `ActivateSelected`, `AppendText`, `Backspace`, and autosave entry points. +- Existing helpers such as `WorkflowViewComponents`, `NetclawTuiChrome`, raw + `TextInputNode` usage, and `ConfigAutosave` call sites. +- Headless Termina tests, config editor audit tests, native smoke tapes, and + semantic assertion scripts. + +**Security and operational impact:** + +- Invalid config, unresolved runtime references, bad credentials, unreachable + dependencies, and malformed secret changes are blocked before persistence. +- Dynamic validation failures cannot disappear by accident; each mutable action + declares a required dynamic check or an explicit not-applicable reason. +- Autosave no longer has a separate bypass path from explicit save/apply. +- Runtime-bound config writes continue to require consumer-facing proof that + the persisted shape is canonical and daemon/runtime code can consume it. +- Operators get consistent behavior across config leaves: completed actions + save after validation, incomplete drafts do not persist, and failures are + visible. diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/netclaw-config-command/spec.md b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/netclaw-config-command/spec.md new file mode 100644 index 000000000..be4c0b0e2 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/netclaw-config-command/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Config leaves use validated Netclaw UI components for mutable actions + +Every mutable `netclaw config` leaf editor SHALL use page-independent +validated Netclaw UI components for text fields, toggles, pickers, add/remove +actions, reset actions, token rotation, and other completed actions. Config +pages SHALL NOT call persistence APIs directly from page key handlers. + +#### Scenario: Skill Sources local path validates through the user action path + +- **GIVEN** the operator opens Skill Sources and chooses Add local folder +- **WHEN** the operator types a missing path and presses `Enter` +- **THEN** the validated component runs static path validation +- **AND** the config file remains unchanged +- **AND** the UI shows the validation error + +#### Scenario: Skill Sources remote URL probes through the user action path + +- **GIVEN** the operator opens Skill Sources and chooses Add skill server +- **AND** the fake skill feed probe is configured to fail +- **WHEN** the operator types a structurally valid URL and presses `Enter` +- **THEN** the validated component runs dynamic validation through the commit + pipeline +- **AND** persistence is blocked before writing +- **AND** the UI exposes save-anyway only through the declared failure policy + +### Requirement: Config autosave and explicit acceptance share one pipeline + +Config completed actions SHALL use the same `NetclawUiCommitPipeline` whether +the action is accepted by `Enter`, a save/apply affordance, a toggle, picker +selection, or autosave trigger. Autosave SHALL NOT have a separate persistence +path. + +#### Scenario: Toggle autosave uses dynamic validation when declared + +- **GIVEN** a config toggle changes a runtime-consumed setting whose commit + declares dynamic validation +- **WHEN** the operator toggles the setting +- **THEN** the autosave trigger runs static and dynamic validation through the + commit pipeline before persistence + +#### Scenario: Escape never persists incomplete drafts + +- **GIVEN** the operator has typed a draft text value in a config leaf +- **AND** the draft has not been accepted by `Enter` or an equivalent Apply + action +- **WHEN** the operator presses `Esc` +- **THEN** the draft is canceled or navigation occurs +- **AND** no config, secrets, or sidecar file is modified + +### Requirement: Config validation coverage is driven by standard component contracts + +Every migrated config leaf SHALL have headless tests that drive the same input +path the user drives. Tests SHALL cover typed input, paste when supported, +`Enter` acceptance, `Esc` cancellation, static validation failure, dynamic +validation failure when declared, unchanged persistence on failure, and +successful canonical persistence. + +#### Scenario: Audit fails when a config leaf lacks interaction-path validation tests + +- **WHEN** the config editor audit runs +- **THEN** each visible mutable config leaf must identify tests that exercise + the validated component user-action path +- **AND** a leaf with only direct view-model save tests fails the audit + +#### Scenario: Runtime consumer proof remains required + +- **GIVEN** a config leaf writes values consumed by daemon startup, routing, + ACL, channel adapters, skill scanners, search providers, or webhook runtime +- **WHEN** the leaf is migrated to validated components +- **THEN** tests prove the persisted canonical representation is consumed by + the runtime-facing consumer diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/netclaw-validated-ui-components/spec.md b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/netclaw-validated-ui-components/spec.md new file mode 100644 index 000000000..460ab7459 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/netclaw-validated-ui-components/spec.md @@ -0,0 +1,158 @@ +## ADDED Requirements + +### Requirement: Mutable UI actions require a Netclaw UI commit contract + +Every mutable Netclaw TUI action SHALL be represented by a +`NetclawUiCommit` or an equivalent standard contract with no +constructor path that omits static validation, dynamic validation policy, +persistence, and post-commit behavior. + +The contract SHALL be page-independent and named with `NetclawUi*` or +`NetclawValidated*` terminology, not config-specific names. Config pages MAY +adapt domain validators and writers into the contract, but the reusable UI +component contract SHALL NOT depend on config page types. + +#### Scenario: Editable field cannot be constructed without validation hooks + +- **WHEN** a developer adds a mutable text field to a TUI page +- **THEN** the standard field constructor requires a `NetclawUiCommit` +- **AND** the code cannot compile using only a label, current value, and raw + save callback + +#### Scenario: Toggle cannot persist without a commit contract + +- **WHEN** a developer adds a boolean toggle that changes persisted state +- **THEN** the toggle component requires a `NetclawUiCommit` +- **AND** persistence cannot be wired directly from the page input handler + +### Requirement: Dynamic validation policy is explicit + +Every `NetclawUiCommit` SHALL declare a dynamic validation policy. +The policy SHALL be either required dynamic validation or explicitly not +applicable with a non-empty justification. Silent omission of dynamic +validation SHALL be rejected by construction or by build-time enforcement. + +#### Scenario: Dynamic check is required for remote probes + +- **GIVEN** a mutable action edits a remote skill server URL +- **WHEN** the action is declared +- **THEN** its commit contract declares a required dynamic check that probes + the skill feed discovery endpoint before persistence + +#### Scenario: Not-applicable dynamic check requires justification + +- **GIVEN** a mutable action changes a purely local display preference +- **WHEN** the action is declared without a live probe +- **THEN** its commit contract uses `NotApplicable` with a non-empty reason +- **AND** an empty justification fails validation or build enforcement + +### Requirement: One commit pipeline owns Enter, save, autosave, and completed actions + +The `NetclawUiCommitPipeline` SHALL be the only persistence path for mutable +TUI actions. The pipeline SHALL accept a trigger that identifies whether the +commit came from `Enter`, save/apply, autosave, toggle, picker selection, +delete, reset, token rotation, or another completed action. + +The pipeline SHALL run static validation before dynamic validation and SHALL +run all validation before persistence. Failed validation SHALL leave config, +secrets, and sidecar files unchanged. + +#### Scenario: Enter and autosave use the same validation pipeline + +- **GIVEN** a text field and a toggle both persist runtime-consumed settings +- **WHEN** the text field is accepted with `Enter` +- **AND** the toggle autosaves after selection +- **THEN** both actions run through `NetclawUiCommitPipeline` +- **AND** both actions run static validation before dynamic validation before + persistence + +#### Scenario: Static validation failure writes nothing + +- **GIVEN** a mutable action has an invalid local path draft +- **WHEN** the action is committed +- **THEN** static validation fails +- **AND** dynamic validation is not called +- **AND** no persisted file is modified + +#### Scenario: Dynamic validation failure writes nothing before override + +- **GIVEN** a mutable action has a structurally valid remote URL +- **AND** its required probe fails +- **WHEN** the action is committed +- **THEN** persistence is blocked +- **AND** no persisted file is modified +- **AND** the result can expose a save-anyway path only when the action's + failure policy allows runtime/probe override + +### Requirement: Standard components own mutable input handling + +Standard Netclaw validated components SHALL own mutable key handling for their +controls, including typed characters, paste, backspace, `Enter`, `Space`, +picker selection, and autosave triggers. TUI pages SHALL compose components +and render layout; pages SHALL NOT implement persistence behavior in key +handlers. + +#### Scenario: Text input uses standard component handling + +- **WHEN** a page renders a mutable text field +- **THEN** typed characters, paste, backspace, and `Enter` are handled by + `NetclawValidatedTextField` or the standard validated input router +- **AND** accepting the field invokes `NetclawUiCommitPipeline` + +#### Scenario: Page-level Enter save bypass is rejected + +- **WHEN** a config page handles `ConsoleKey.Enter` and calls `Save`, + `SaveAsync`, `ConfigAutosave`, or a config writer directly +- **THEN** build enforcement fails +- **AND** the implementation must move the action behind a validated component + and `NetclawUiCommit` + +### Requirement: Build enforcement rejects validation bypasses + +The build SHALL include enforcement that detects mutable TUI bypasses. The +preferred enforcement is a Roslyn analyzer; architecture tests MAY be used as +a backstop but SHALL NOT be the only long-term protection if analyzer coverage +is feasible. + +Build enforcement SHALL reject raw mutable `TextInputNode` construction, +direct save/autosave calls, direct config writer calls, and direct +`ConsoleKey.Enter` persistence handling in mutable TUI pages unless the code +is inside the standard Netclaw validated component layer or commit pipeline. + +#### Scenario: Raw input construction fails outside standard components + +- **WHEN** a mutable TUI page instantiates `TextInputNode` directly for a + persisted field +- **THEN** build enforcement fails +- **AND** the page must use `NetclawValidatedTextField` or an approved + standard component + +#### Scenario: Direct autosave call fails outside commit pipeline + +- **WHEN** a mutable TUI page or view model calls `ConfigAutosave` directly for + a persisted action +- **THEN** build enforcement fails unless the call is part of the approved + `NetclawUiCommitPipeline` implementation + +### Requirement: Obsolete UI artifacts are deleted only after replacement proof + +Old tests, helper components, and page-specific input handlers SHALL be +removed only when they are not needed. A removal is allowed only after the +replacement standard component covers the behavior, no production caller +remains, and tests prove the replacement path through the public user action. + +#### Scenario: Obsolete render-only test is replaced by interaction proof + +- **GIVEN** an old test checks only that an input label renders +- **WHEN** a validated component test covers typed input, paste, `Enter`, + failed validation, and unchanged persistence +- **THEN** the render-only test MAY be deleted if no unique visual contract is + lost + +#### Scenario: Still-needed helper remains until callers migrate + +- **GIVEN** an old UI helper still has production callers not yet migrated +- **WHEN** the cleanup phase runs +- **THEN** the helper remains +- **AND** deletion is deferred until caller migration and replacement coverage + are complete diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/section-editor-abstraction/spec.md b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/section-editor-abstraction/spec.md new file mode 100644 index 000000000..869f8184e --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/specs/section-editor-abstraction/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Section editor hosting supports validated component composition + +Section editor hosting SHALL support composing page-independent validated +Netclaw UI components. The section editor abstraction SHALL NOT imply that a +leaf page may hand-roll mutable input, direct save behavior, or autosave +persistence outside the standard commit pipeline. + +#### Scenario: Leaf editor supplies validated components to host + +- **GIVEN** a leaf editor exposes mutable fields or completed actions +- **WHEN** the editor is hosted by init, config, or another page shell +- **THEN** mutable controls are represented by standard validated Netclaw UI + components or by declarations that the host adapts to those components + +#### Scenario: Host navigation does not own persistence + +- **GIVEN** a section editor is hosted in the config dashboard +- **WHEN** the operator presses navigation keys such as `Esc` +- **THEN** the host routes navigation +- **AND** persistence remains owned only by validated commit actions + +### Requirement: Leaf editor audits include validated commit coverage + +Leaf editor audit tests SHALL require every mutable section editor to declare +validated commit coverage. The audit SHALL distinguish replacement coverage +from obsolete coverage so that old tests are deleted only when they are no +longer needed. + +#### Scenario: Mutable leaf without commit coverage fails audit + +- **GIVEN** a registered leaf editor has a mutable field +- **WHEN** the audit cannot find a corresponding validated component or + `NetclawUiCommit` declaration +- **THEN** the audit fails + +#### Scenario: Obsolete tests are removed only after replacement coverage + +- **GIVEN** a legacy section-editor test covers a behavior through a direct + view-model call +- **AND** a new validated component test covers the same behavior through the + public user-action path +- **WHEN** no unique assertion remains in the legacy test +- **THEN** the legacy test MAY be deleted as no longer needed diff --git a/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/tasks.md b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/tasks.md new file mode 100644 index 000000000..7ba5c138d --- /dev/null +++ b/openspec/changes/archive/2026-06-09-netclaw-validated-ui-components/tasks.md @@ -0,0 +1,92 @@ +## 1. Discovery and migration inventory + +- [ ] 1.1 Inventory every mutable TUI surface under `src/Netclaw.Cli/Tui` and classify each as `validated-component-required`, `display-only`, or `defer-with-reason`. +- [ ] 1.2 For each `netclaw config` leaf, document the downstream runtime consumer of its persisted data: daemon options, channel adapter, skill scanner/feed loader, search provider, webhook runtime, ACL/security policy, or other named consumer. +- [ ] 1.3 For each mutable action, document its static validation rule, dynamic validation policy, persistence writer, and post-commit reload/status behavior before code migration starts. +- [ ] 1.4 Create a deletion candidate list for old tests, helpers, page-level input handlers, and UI components; mark each candidate `delete-after-replacement-proof`, `keep`, or `defer`. +- [ ] 1.5 Run `openspec validate netclaw-validated-ui-components --type change` and keep it passing before implementation begins. + +## 2. Core page-independent Netclaw UI commit primitives + +- [x] 2.1 Add `NetclawUiCommit`, `NetclawUiCommitTrigger`, `NetclawUiCommitResult`, `NetclawUiValidationResult`, and failure/status result types in a page-independent TUI namespace. +- [x] 2.2 Add `NetclawUiDynamicCheck` with `Required(...)` and `NotApplicable(justification)`; reject empty `NotApplicable` justification. +- [x] 2.3 Implement `NetclawUiCommitPipeline` with ordering `ReadDraft -> static Validate -> DynamicCheck -> PersistAsync -> AfterCommit`. +- [x] 2.4 Ensure static validation failure prevents dynamic validation and persistence. +- [x] 2.5 Ensure dynamic validation failure prevents persistence unless the declared failure policy and user action explicitly choose save-anyway. +- [x] 2.6 Ensure persistence exceptions are caught by the pipeline and surface visible error status instead of silent failure. + +## 3. Standard validated Netclaw UI components + +- [x] 3.1 Add `INetclawUiComponent` or equivalent component contract for build, input handling, paste handling, and commit ownership. +- [x] 3.2 Add `NetclawValidatedTextField` using the existing boxed `TextInputNode` presentation, but requiring `NetclawUiCommit` for acceptance. +- [x] 3.3 Add `NetclawValidatedAction` for completed actions such as add/remove, reset, token rotation, and save-anyway. +- [x] 3.4 Add `NetclawValidatedToggle` and `NetclawValidatedPicker` for immediate completed actions. +- [ ] 3.5 Add `NetclawUiInputRouter` or `NetclawValidatedPage` so pages delegate typed input, paste, backspace, `Enter`, `Space`, picker selection, and autosave triggers to validated components. +- [ ] 3.6 Prove the components still use standard Netclaw TUI chrome and do not introduce a parallel visual system. + +## 4. Build enforcement against bypasses + +- [ ] 4.1 Add Roslyn analyzer or build-failing architecture tests that reject raw mutable `TextInputNode` construction in TUI pages outside approved validated components. +- [ ] 4.2 Add enforcement that rejects page input handlers calling `Save`, `SaveAsync`, `ConfigAutosave`, or config writer methods directly for persisted mutable actions. +- [ ] 4.3 Add enforcement that rejects `ConsoleKey.Enter` branches that directly persist mutable config state. +- [ ] 4.4 Add enforcement that rejects direct `ConfigAutosave` use outside `NetclawUiCommitPipeline` or approved adapter code. +- [ ] 4.5 Add enforcement that rejects `NetclawUiDynamicCheck.NotApplicable` with empty or whitespace-only justification. +- [ ] 4.6 Add negative enforcement fixtures/tests proving each forbidden bypass fails the build/test gate. + +## 5. Core component and pipeline tests + +- [x] 5.1 Add pipeline tests proving static validation failure leaves config/secrets/sidecar files unchanged and does not call dynamic validation. +- [x] 5.2 Add pipeline tests proving dynamic validation failure leaves files unchanged and surfaces the declared error/warning. +- [x] 5.3 Add pipeline tests proving save-anyway persists only after structural validation passes and dynamic failure policy allows override. +- [x] 5.4 Add component tests proving typed input, paste input, backspace, and `Enter` acceptance flow through `NetclawUiCommitPipeline`. +- [ ] 5.5 Add component tests proving autosave, toggle, and picker actions use the same pipeline and trigger value as explicit acceptance. +- [ ] 5.6 Add component tests proving `Esc` cancels/navigates without committing incomplete drafts. + +## 6. Skill Sources migration first + +- [x] 6.1 Create `SkillSourcesCommitFactory` or equivalent adapters that produce commits for local path, local name, symlink toggle, remote URL, auth/token, remote name, rename, location change, enable toggle, token removal, token rotation, and source removal. +- [x] 6.2 Wire Skill Sources text entry screens through `NetclawValidatedTextField`; remove page-specific text draft rendering only after the standard component renders the same necessary field labels, placeholders, hints, and skill-server callout. +- [x] 6.3 Wire Skill Sources toggles/actions through `NetclawValidatedAction`, `NetclawValidatedToggle`, or `NetclawValidatedPicker`. +- [ ] 6.4 Add headless Termina tests for Skill Sources local path: typed input, paste input, `Enter`, missing-directory static failure, unchanged config, success persistence, and `Esc` cancellation. +- [x] 6.5 Add headless Termina tests for Skill Sources remote URL: typed input, `Enter`, invalid URL static failure, fake probe dynamic failure, unchanged config, save-anyway path, successful canonical `SkillFeeds.Feeds` persistence, and token preserve/delete behavior. +- [x] 6.6 Add runtime consumer proof that local sources persist to `ExternalSkills.Sources` and remote skill servers persist to `SkillFeeds.Feeds` in the exact shapes consumed by runtime skill loading. +- [ ] 6.7 Delete old Skill Sources tests/components only if replacement tests cover their behavior through public user actions and no unique assertion is lost. + +## 7. Remaining config leaf migrations + +- [ ] 7.1 Migrate Telemetry & Alerting to validated components and prove invalid OTLP/webhook drafts block persistence before write. +- [ ] 7.2 Migrate Workspaces Directory to validated components and prove path validation, successful persistence, runtime path consumption, typed/paste input, `Enter`, and `Esc` cancellation. +- [ ] 7.3 Migrate Inbound Webhooks to validated components and prove timeout static validation, route-count diagnostics, enabled-state autosave, and unchanged persistence on invalid input. +- [ ] 7.4 Migrate Channels to validated components and prove Slack, Discord, and Mattermost dynamic validation failures block save before persistence through the same user action path. +- [ ] 7.5 Migrate Search to validated components without regressing provider-specific static and dynamic validation, probe warning/save-anyway behavior, and secret preservation. +- [ ] 7.6 Migrate Browser Automation to validated components and prove binary/profile validation and config-to-runtime consumer behavior. +- [ ] 7.7 Migrate Exposure Mode to validated components and prove non-local mode validation, pairing/orphaned-state behavior, inactive value preservation, and canonical `Daemon.ExposureMode` persistence. + +## 8. Audit tests and obsolete artifact deletion + +- [ ] 8.1 Update config editor audit tests so every visible mutable editor declares validated component coverage and dynamic validation policy coverage. +- [ ] 8.2 Update section-editor abstraction tests so mutable leaves without `NetclawUiCommit` coverage fail. +- [ ] 8.3 Replace render-only tests with component interaction tests only when the interaction tests also cover required rendering, accessibility-relevant labels, and user-action behavior. +- [ ] 8.4 Delete old page-level input handlers, helper components, and tests marked `delete-after-replacement-proof` only after `git grep` shows no production callers and replacement tests pass. +- [ ] 8.5 Preserve direct view-model/domain tests that still cover pure validation, mapping, serialization, or runtime binding behavior not duplicated by component tests. +- [ ] 8.6 Remove or encapsulate direct `ConfigAutosave` APIs so callers cannot bypass `NetclawUiCommitPipeline`. + +## 9. Documentation and agent guidance + +- [ ] 9.1 Update relevant developer docs or `docs/ui` material to describe `NetclawUiCommit`, dynamic validation policy, and the no-bypass rule for TUI pages. +- [ ] 9.2 Update `feeds/skills/.system/files/netclaw-operations/SKILL.md` if config/TUI operational guidance changes; bump `metadata.version` in the skill frontmatter. +- [ ] 9.3 If a system skill changes, run `./evals/run-evals.sh` and update eval expectations only for legitimate guidance changes. +- [ ] 9.4 Keep `openspec/changes/netclaw-config-command/tasks.md` aligned if this change supersedes or closes any remaining generalized autosave-validation tasks. + +## 10. Validation gates + +- [ ] 10.1 Run `dotnet build` after core components and after each major migration slice. +- [ ] 10.2 Run focused tests for each migrated area, including `dotnet test src/Netclaw.Cli.Tests/Netclaw.Cli.Tests.csproj`. +- [ ] 10.3 Run full `dotnet test` before marking the change complete. +- [ ] 10.4 Run `openspec validate netclaw-validated-ui-components --type change` after each artifact or behavior update. +- [ ] 10.5 Run native smoke for changed config surfaces: at minimum `./scripts/smoke/run-smoke.sh config-ops-surfaces`, `./scripts/smoke/run-smoke.sh config-channels`, and any additional migrated surface tapes. +- [ ] 10.6 Run `./scripts/smoke/run-smoke.sh light` before completion unless explicitly scoped to a narrower final validation with justification. +- [ ] 10.7 Run `dotnet slopwatch analyze` and fix any new violations. +- [ ] 10.8 Run `pwsh ./scripts/Add-FileHeaders.ps1 -Verify`. +- [ ] 10.9 Run `git diff --check`. +- [ ] 10.10 Verify build enforcement catches representative bypass fixtures for raw text input, direct `Save`, direct `ConfigAutosave`, direct `ConsoleKey.Enter` persistence, and missing dynamic validation policy. diff --git a/openspec/changes/archive/2026-06-09-section-editor-abstraction/.openspec.yaml b/openspec/changes/archive/2026-06-09-section-editor-abstraction/.openspec.yaml new file mode 100644 index 000000000..0f0616986 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-section-editor-abstraction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/archive/2026-06-09-section-editor-abstraction/design.md b/openspec/changes/archive/2026-06-09-section-editor-abstraction/design.md new file mode 100644 index 000000000..5300a82a1 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-section-editor-abstraction/design.md @@ -0,0 +1,108 @@ +## Context + +This change defines the reusable leaf-editor contract that both +bootstrap-time init flows and the later `netclaw config` command will use. +The locked product shape matters: + +- `netclaw init` is bootstrap, not the main editor. +- `netclaw config` is the main post-install surface. +- Identity stays init-owned. + +So the abstraction should model reusable leaf editors and semantic writes, +not a specific top-level dashboard layout. + +## Goals / Non-Goals + +**Goals:** + +- Define `ISectionEditor` as the reusable leaf-editor contract. +- Support init-owned editors and config-owned editors without forcing them + all into one menu. +- Preserve existing config semantically on save, including inactive + exposure-mode values and unrelated sections. +- Keep secrets masked and non-rehydratable. +- Refactor the bootstrap leaves that matter to the locked split: + Provider, Identity, Security Posture, Enabled Features. + +**Non-Goals:** + +- Defining the `netclaw config` IA. +- Making Identity editable from `netclaw config`. +- Forcing all schema sections into TUI editors. +- Byte-identical JSON preservation. + +## Decisions + +### D1. The abstraction is for leaf editors, not dashboard IA + +`ISectionEditor` describes the smallest reusable editable surface. The next +change may compose those leaves under domain pages such as `Channels` or +`Security & Access`, or route specific nodes to existing commands such as +`netclaw provider` and `netclaw model`. + +Alternative considered: make the registry shape equal the config dashboard +shape. Rejected because the locked IA is domain-oriented and heavier on +sub-pages, while the reusable abstraction is leaf-oriented. + +### D2. Merge-on-save is semantic, not byte-identical + +The merge layer preserves the meaning of unrelated sections and inactive +values, but ordering, whitespace, and exact serialized shape are not part of +the contract. Tests compare semantics, not raw file bytes. + +Alternative considered: keep byte-identical guarantees. Rejected because the +locked product decisions explicitly require semantic round-tripping and +inactive-value preservation without turning formatting into a compatibility +surface. + +### D3. Existing config loading supports init-owned re-entry, not init-as-editor + +`WizardContext.ExistingConfig` is populated when an init-owned flow needs to +re-enter existing state. This supports things like identity re-entry and +shared bootstrap leaves, but does not commit the product to "re-run init to +edit everything". + +Alternative considered: frame this change as full init reentrancy. Rejected +because the locked split moves ongoing editing to `netclaw config`. + +### D4. Identity is synthetic and permanently init-owned in this branch + +Identity spans config plus generated identity files, so it keeps a synthetic +`SectionId` and `ShowInMenu = false`. The config dashboard must not surface +Identity as just another settings page. + +### D5. Enabled Features is a separate reusable leaf from Security Posture + +Security Posture, Enabled Features, and Audience Profiles are distinct +concepts. This change therefore refactors posture and enabled-features as +separate leaves rather than encoding runtime feature enablement inside the +posture editor. + +### D6. Audit scope is registered leaf editors only + +Registered leaf editors require round-trip tests and validation contracts. +Future routed handoff entries are not leaf editors and only need shallow +routing coverage in the config command change. + +## Risks / Trade-offs + +- Refactoring four existing bootstrap leaves can regress init behavior. + Mitigation: keep init smoke coverage and add leaf-level round-trip tests. +- Semantic merge assertions are less strict than byte equality. + Mitigation: test meaningful preservation of unrelated values, + hidden/inactive values, and secrets behavior. +- A synthetic Identity editor can be confusing to reviewers. + Mitigation: keep the exemption entry explicit and document that Identity + remains init-owned. + +## Migration Plan + +1. Land the abstraction and the four leaf refactors. +2. The next change composes those leaves into the domain-oriented + `netclaw config` dashboard. +3. The third change constrains `netclaw init` to bootstrap-only behavior. + +## Open Questions + +None. The abstraction is intentionally narrower after the locked product +decisions. diff --git a/openspec/changes/archive/2026-06-09-section-editor-abstraction/proposal.md b/openspec/changes/archive/2026-06-09-section-editor-abstraction/proposal.md new file mode 100644 index 000000000..1c6ec8455 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-section-editor-abstraction/proposal.md @@ -0,0 +1,99 @@ +## Why + +Netclaw needs one reusable editing contract for bootstrap-only init flows +and for the heavier post-install `netclaw config` command, but the product +split is now locked: + +- `netclaw init` is first-run bootstrap and then rarely used again. +- `netclaw config` is the main post-install settings surface. +- Identity remains `netclaw init` owned. + +That means the shared abstraction cannot assume a flat dashboard, cannot +assume every section is menu-editable, and cannot promise byte-identical +JSON preservation as a product contract. It needs to support reusable leaf +editors, routed handoffs, semantic merge-on-save, and init-owned surfaces +that are intentionally absent from `netclaw config`. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`. + +## What Changes + +- Add an `ISectionEditor` contract in `Netclaw.Cli.Tui.Sections` for + reusable leaf editors. Each editor describes one editable leaf surface: + stable identity, status/summary, relevant validation checks, and a + factory that returns an `IWizardStepViewModel` runnable either from + `netclaw init` or from `netclaw config`. +- Keep the registry flat at the leaf-editor level, but explicitly DO NOT + make the registry shape the `netclaw config` IA contract. The next change + is free to build a domain-oriented dashboard with grouped pages and + routed handoffs on top of the leaf registry. +- Add `SectionEditorRegistry`, `SectionStatus`, `SectionContribution`, and + `SectionEditorExemptions` so schema-backed leaves, dotted-path leaves, + and synthetic init-owned editors can all participate without pretending + everything is a top-level config page. +- Add single-step `WizardOrchestrator` hosting so one editor can run + standalone without the full init step list. +- Populate `WizardContext.ExistingConfig` from on-disk config when an + init-owned flow needs existing state, so init-owned editors can re-enter + with non-secret fields prefilled. +- Switch wizard/config persistence from overwrite semantics to semantic + merge-on-save. Unrelated sections and inactive per-mode values are + preserved semantically; formatting and property ordering are not part of + the contract. +- Refactor four existing bootstrap editors to implement `ISectionEditor`: + Provider, Identity, Security Posture, and Enabled Features. Identity + remains `ShowInMenu = false` because it stays init-owned. Security + Posture and Enabled Features become reusable leaf editors for the next + change's `Security & Access` area. +- Keep the secret-handling contract: secrets never rehydrate to screen; + masked inputs use "leave blank to keep" semantics; explicit removal is + the only delete path. +- Add `MenuRegistryAuditTests` and `SectionEditorTestBase` so + registered leaf editors require meaningful round-trip coverage and + validation declarations. Routed handoff entries in the next change are + covered separately and do not pretend to be leaf editors. + +**In scope (MVP):** the abstraction, registry, exemption list, audit and +round-trip harnesses, single-step orchestrator mode, semantic merge-on-save +for `netclaw.json` and `secrets.json`, `ExistingConfig` population, and the +refactor of Provider / Identity / Security Posture / Enabled Features. + +**Out of scope:** the `netclaw config` command itself, the domain-oriented +dashboard IA, routed handoff nodes for `netclaw provider` / `netclaw model` + / `netclaw mcp permissions`, simplification of the init flow, and daemon +hot-reload. + +## Capabilities + +### New Capabilities + +- `section-editor-abstraction`: reusable leaf-editor contract for init and + config, including reentrancy, secret handling, semantic merge-on-save, + and audit obligations. + +### Modified Capabilities + +- `netclaw-onboarding`: init-owned editable flows SHALL load existing + config state when needed, prefill non-secret fields, keep secrets masked, + and write via semantic merge-on-save. + +## Impact + +**Affected systems:** + +- CLI wizard infrastructure (`Program`, `WizardOrchestrator`, + `WizardConfigBuilder`, `WizardContext`). +- Bootstrap editors (`ProviderStepViewModel`, `IdentityStepViewModel`, + `SecurityPostureStepViewModel`, `FeatureSelectionStepViewModel` / its + Enabled Features successor naming). +- Config merge helper (`ConfigFileHelper`). +- Test surface under `tests/Netclaw.Cli.Tests/Tui/Sections/`. + +**Security and operational impact:** + +- Secrets remain non-rehydratable in the UI. +- Merge behavior preserves meaning, not file bytes. +- The abstraction now matches the locked product split instead of + implying that `netclaw init` is the long-term editor for ongoing + settings. diff --git a/openspec/changes/archive/2026-06-09-section-editor-abstraction/specs/netclaw-onboarding/spec.md b/openspec/changes/archive/2026-06-09-section-editor-abstraction/specs/netclaw-onboarding/spec.md new file mode 100644 index 000000000..e425c0beb --- /dev/null +++ b/openspec/changes/archive/2026-06-09-section-editor-abstraction/specs/netclaw-onboarding/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Init-owned editor re-entry SHALL use existing config state + +Init-owned editor re-entry SHALL load existing config into +`WizardContext.ExistingConfig` when `netclaw init` reuses a registered leaf +editor against an existing install, and SHALL prefill non-secret values from +that state. Secret-bearing fields SHALL remain masked and empty. + +#### Scenario: Provider re-entry keeps credential field masked + +- **GIVEN** an existing provider configuration with stored credentials +- **WHEN** an init-owned provider flow re-enters +- **THEN** provider choice and non-secret fields are prefilled +- **AND** credential inputs remain blank with configured/not-set hint text + +#### Scenario: Identity re-entry prefills init-owned fields + +- **GIVEN** an existing install with agent name, operator name, and + timezone already set +- **WHEN** an init-owned identity flow re-enters +- **THEN** those non-secret fields are prefilled + +### Requirement: Init-owned writes use semantic merge + +Init-owned editor flows SHALL write changes through semantic merge-on-save. +Unrelated config meaning and unrelated stored secrets SHALL be preserved even +if the serialized file text changes. + +#### Scenario: Identity-only edit preserves unrelated config meaning + +- **GIVEN** an existing install with configured channels, search, and + exposure settings +- **WHEN** an init-owned identity flow updates only identity-owned data +- **THEN** the unrelated config sections remain semantically unchanged + +#### Scenario: Blank secret submission preserves existing secret + +- **GIVEN** an init-owned flow includes a secret-bearing field with an + existing stored value +- **WHEN** the operator leaves that field blank and saves +- **THEN** the existing secret remains stored +- **AND** no decrypted value is shown in the UI diff --git a/openspec/changes/archive/2026-06-09-section-editor-abstraction/specs/section-editor-abstraction/spec.md b/openspec/changes/archive/2026-06-09-section-editor-abstraction/specs/section-editor-abstraction/spec.md new file mode 100644 index 000000000..8fa3990b6 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-section-editor-abstraction/specs/section-editor-abstraction/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: Leaf editor interface + +The CLI SHALL define an `ISectionEditor` contract for reusable editable +leaf surfaces. A leaf editor SHALL declare a stable `SectionId`, a +user-facing `DisplayName`, optional `Category`, `ShowInMenu`, status and +summary methods, relevant validation checks, and a factory that returns an +`IWizardStepViewModel` runnable in either init-owned flows or config-owned +single-step hosting. + +The contract SHALL describe leaf editing only. It SHALL NOT imply that the +top-level `netclaw config` IA is flat or identical to registry order. + +#### Scenario: Registered leaf editor does not define dashboard shape + +- **GIVEN** a registered leaf editor with `SectionId = "Search"` +- **WHEN** the config dashboard is later composed +- **THEN** the dashboard MAY place that leaf under a grouped page such as + `Search` or `Security & Access` +- **AND** the leaf editor contract remains valid regardless of the + top-level navigation shape + +#### Scenario: Synthetic init-owned editor is allowed + +- **GIVEN** an editor such as `Identity` spans generated files and config + leaves +- **WHEN** it is registered with `ShowInMenu = false` +- **THEN** it MAY use a synthetic identifier when documented in the + exemption list +- **AND** it SHALL remain absent from the config dashboard menu + +### Requirement: Semantic merge-on-save + +Leaf editors SHALL persist changes through semantic merge-on-save. The merge +writer SHALL preserve unrelated sections and inactive values semantically. +Formatting, property order, and byte-for-byte file identity are NOT part of +the contract. + +#### Scenario: Editing one leaf preserves unrelated meaning + +- **GIVEN** `netclaw.json` contains configured `Providers`, `Slack`, + `Search`, and inactive exposure-mode values for modes other than the + current `Daemon.ExposureMode` +- **WHEN** the operator edits only the Search leaf and saves +- **THEN** `Search` reflects the requested change +- **AND** the unrelated sections and inactive exposure-mode values remain + semantically unchanged + +#### Scenario: No-op save may rewrite formatting without changing meaning + +- **GIVEN** an existing config file with non-canonical property order +- **WHEN** an editor performs a no-op save +- **THEN** the resulting file MAY differ in byte representation +- **AND** the resulting parsed config SHALL be semantically equivalent to + the original + +### Requirement: Reentrancy contract for init-owned flows SHALL preserve existing state rules + +Init-owned re-entrant flows SHALL prefill non-secret fields from +`WizardContext.ExistingConfig` when init reuses a leaf editor against +existing state. Secret-bearing fields SHALL remain empty and masked, using +existence-only hint text. + +#### Scenario: Existing non-secret values prefill + +- **GIVEN** an init-owned flow enters the Security Posture editor with an + existing posture already configured +- **WHEN** the editor loads +- **THEN** the current posture is preselected + +#### Scenario: Stored secrets never rehydrate + +- **GIVEN** an editor owns a secret-bearing field whose value exists in + `secrets.json` +- **WHEN** the editor loads +- **THEN** the field renders empty +- **AND** the hint indicates only whether a value exists +- **AND** the decrypted value is never displayed + +### Requirement: Secret-presence lookup without decryption + +`ConfigFileHelper` SHALL expose an existence-only secret lookup API used by +leaf editors to decide between "configured - leave blank to keep" and +"(not set)". + +#### Scenario: Presence lookup does not decrypt + +- **GIVEN** `secrets.json` contains an encrypted value for a leaf editor +- **WHEN** `SecretPresent(...)` is called +- **THEN** the result indicates presence or absence only +- **AND** the decrypted value is not materialized for UI display + +### Requirement: Audit applies to registered leaf editors + +The test project SHALL audit registered leaf editors for round-trip test +coverage and declared validation checks. `ShowInMenu = false` leaves remain +subject to round-trip coverage but are exempt from config-dashboard tape +requirements. + +#### Scenario: Menu-hidden init-owned editor still needs a round-trip test + +- **GIVEN** `Identity` is registered with `ShowInMenu = false` +- **WHEN** the registry audit runs +- **THEN** the audit requires a leaf-editor round-trip test class +- **AND** it does NOT require a config-dashboard smoke tape for Identity diff --git a/openspec/changes/archive/2026-06-09-section-editor-abstraction/tasks.md b/openspec/changes/archive/2026-06-09-section-editor-abstraction/tasks.md new file mode 100644 index 000000000..fe5e8a070 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-section-editor-abstraction/tasks.md @@ -0,0 +1,100 @@ +## 1. OpenSpec planning artifacts and traceability + +- [x] 1.1 Confirm proposal, design, and spec deltas describe a leaf-editor + abstraction rather than a flat dashboard contract. +- [x] 1.2 Confirm the artifacts reflect the locked split: `init` owns + bootstrap and Identity; `config` owns post-install editing. +- [x] 1.3 Run `openspec validate section-editor-abstraction --type change` + and resolve issues. + +## 2. Core abstraction + +- [x] 2.1 Add `ISectionEditor` with `SectionId`, `DisplayName`, + `Category?`, `ShowInMenu`, `GetStatus`, `Summary`, + `RelevantDoctorChecks`, and `CreateEditor`. +- [x] 2.2 Add `SectionStatus`. +- [x] 2.3 Add `SectionContribution` with explicit field and secret + actions. +- [x] 2.4 Add `[NoDoctorChecks]` justification support where truly needed. + +## 3. Registry and exemption list + +- [x] 3.1 Add `SectionEditorRegistry` with duplicate-ID fail-fast. +- [x] 3.2 Add `AddSectionEditor()` DI registration. +- [x] 3.3 Add `SectionEditorExemptions` entries for synthetic/init-owned + surfaces, including Identity. +- [x] 3.4 Document that the registry is a leaf-editor registry and does + not dictate the future dashboard IA. + +## 4. Single-step orchestrator mode + +- [x] 4.1 Add single-step hosting to `WizardOrchestrator`. +- [x] 4.2 Ensure save exits and cancel exits work without linear step-list + navigation. +- [x] 4.3 Add unit tests for single-step save and cancel. + +## 5. Semantic merge-on-save plumbing + +- [x] 5.1 Refactor config writes to load existing config, apply + contributions, and preserve unrelated sections semantically. +- [x] 5.2 Refactor secret writes to preserve blank submissions, replace on + non-blank, and remove only on explicit delete. +- [x] 5.3 Preserve inactive values for exposure-mode and similar editors + when they are not the active leaf being changed. +- [x] 5.4 Add `ConfigFileHelper.SecretPresent(...)` without decrypting + stored values. + +## 6. ExistingConfig population + +- [x] 6.1 Populate `WizardContext.ExistingConfig` from on-disk config when + init enters an editor flow that needs existing state. +- [x] 6.2 Keep secrets out of the context entirely. +- [x] 6.3 Document that this supports init-owned re-entry, not init as the + main post-install editor. + +## 7. Refactor bootstrap leaves + +- [x] 7.1 Refactor Provider to implement `ISectionEditor` + (`ShowInMenu = false`; owned by init / routed provider command). +- [x] 7.2 Refactor Identity to implement `ISectionEditor` + (`ShowInMenu = false`; synthetic ID; init-owned). +- [x] 7.3 Refactor Security Posture to implement `ISectionEditor` + (`ShowInMenu = true`; reusable under `Security & Access`). +- [x] 7.4 Refactor Enabled Features to implement `ISectionEditor` + (`ShowInMenu = true`; separate from posture and audience profiles). +- [x] 7.5 Ensure each refactored editor declares meaningful validation + checks and produces `SectionContribution` output. + +## 8. Round-trip test harness + +- [x] 8.1 Add `SectionEditorTestBase` with semantic round-trip, + secret-preservation, and targeted update scenarios. +- [x] 8.2 Add Provider leaf tests. +- [x] 8.3 Add Identity leaf tests. +- [x] 8.4 Add Security Posture leaf tests. +- [x] 8.5 Add Enabled Features leaf tests. + +## 9. Menu registry audit + +- [x] 9.1 Add `MenuRegistryAuditTests` for registered leaf editors. +- [x] 9.2 Require round-trip tests and validation declarations for every + registered leaf editor. +- [x] 9.3 Exempt `ShowInMenu = false` leaves from config smoke-tape + existence checks. +- [x] 9.4 Document that routed handoff entries are tested separately in the + config command change. + +## 10. Existing test suite preservation + +- [x] 10.1 Keep current init smoke coverage passing. +- [x] 10.2 Keep current reverse-proxy/init coverage passing until the later + config and init changes intentionally move it. + +## 11. Quality gates + +- [x] 11.1 `dotnet build` clean. +- [x] 11.2 `dotnet test` clean. +- [x] 11.3 `dotnet slopwatch analyze` clean. +- [x] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` clean. +- [x] 11.5 `openspec validate section-editor-abstraction --type change` + passes. diff --git a/openspec/changes/archive/2026-06-09-simplify-netclaw-init/.openspec.yaml b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/.openspec.yaml new file mode 100644 index 000000000..0f0616986 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-23 diff --git a/openspec/changes/archive/2026-06-09-simplify-netclaw-init/design.md b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/design.md new file mode 100644 index 000000000..7185badd2 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/design.md @@ -0,0 +1,103 @@ +## Context + +This change narrows `netclaw init` to what it is now supposed to be: +bootstrap. The old draft assumed re-run refusal plus `init --force`, and it +treated Team/Public feature configuration as something silently derived from +posture. The locked decisions are more specific: + +- Identity stays owned by init. +- `netclaw config` owns post-install editing. +- Team and Public posture flows continue into Enabled Features. +- Personal skips Enabled Features. +- Existing installs get an explicit action menu, not a plain refusal and + not a hidden force flag. + +## Goals / Non-Goals + +**Goals:** + +- Make first-run init a short bootstrap flow. +- Preserve Identity ownership inside init. +- Handle existing installs through an explicit menu. +- Remove `init --force` from the plan. +- Keep posture values to Personal / Team / Public. +- Keep Enabled Features separate from Audience Profiles. + +**Non-Goals:** + +- Making init the main post-install editor. +- Adding Enterprise posture. +- Putting Audience Profiles or MCP permissions inside init. +- Designing inline config repair for broken bootstrap state. + +## Decisions + +### D1. Existing installs get a menu, not refusal-plus-flag + +When `netclaw init` detects an existing install, it opens a menu with these +four choices: + +- Redo identity setup +- Open configuration editor +- Start over from scratch +- Cancel + +Alternative considered: refuse and point to `netclaw config`, with +`--force` for reset. Rejected because the user explicitly locked the menu +shape instead. + +### D2. Scratch reset is a two-stage destructive flow + +`Start over from scratch` opens a dialog with: + +- Reset setup only +- Full reset +- Cancel + +Either destructive path then requires double confirmation before mutation. + +### D3. Identity remains init-owned + +Existing-install identity edits stay in init via `Redo identity setup`. +This branch does not move Identity into `netclaw config`. + +### D4. Team/Public posture continues into Enabled Features + +Security Posture remains separate from Enabled Features. + +- Personal: posture flow ends without Enabled Features. +- Team/Public: posture flow automatically continues into Enabled Features. + +Alternative considered: keep silently applying runtime defaults with no +Enabled Features step. Rejected because the user explicitly locked the +continuation behavior. + +### D5. Audience Profiles stays out of init bootstrap + +Audience Profiles is a post-install curated editor in `netclaw config`. +Init does not expose per-audience access editing. + +### D6. Post-flight points to config for ongoing changes + +Successful bootstrap ends with a message directing the operator to +`netclaw chat` to start and `netclaw config` for ongoing settings. + +## Risks / Trade-offs + +- Existing-install init now has more branching than the simple refusal + draft. Mitigation: the branches are explicit and operator-centered. +- Identity remaining in init means two different commands remain part of + the operator journey. Mitigation: this is the locked ownership split. +- Double confirmation adds a little friction to reset. Mitigation: that is + intentional for destructive actions. + +## Migration Plan + +1. Land the bootstrap-only init rewrite. +2. Existing installs reaching `netclaw init` see the existing-install menu. +3. Ongoing settings move to `netclaw config`; identity changes remain in + init. + +## Open Questions + +None. The menu wording and ownership split are locked. diff --git a/openspec/changes/archive/2026-06-09-simplify-netclaw-init/proposal.md b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/proposal.md new file mode 100644 index 000000000..094603797 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/proposal.md @@ -0,0 +1,80 @@ +## Why + +`netclaw init` is now explicitly the first-run bootstrap command and then +rarely used again. The earlier planning still treated it as a re-runnable +general editor with a `--force` reset path. That contradicts the locked +product split: + +- `netclaw init` is for bootstrap. +- `netclaw config` is the main post-install settings surface. +- Identity remains `netclaw init` owned. + +This change rewrites init around that split. It trims init to the minimum +bootstrap flow, removes `init --force` from planning, and makes existing- +install behavior an explicit menu that either redoes identity, hands off to +the configuration editor, offers a guarded reset flow, or cancels. + +Source PRDs: `PRD-004-cli-onboarding-and-config.md`, +`PRD-001-netclaw-mvp.md`. + +## What Changes + +- Trim first-run `netclaw init` to a bootstrap flow that gets operators to + a runnable install quickly. +- Keep posture values to `Personal`, `Team`, and `Public` only. +- Keep Security Posture, Enabled Features, and Audience Profiles as + separate concepts. +- First-run posture flow behavior: + - `Personal` skips Enabled Features. + - `Team` and `Public` automatically continue into Enabled Features. +- Enabled Features remains deployment-wide runtime enablement, not a + per-audience policy surface. +- Identity remains owned by init, not by `netclaw config`. +- On an existing install, `netclaw init` SHALL open a menu with exactly: + - `Redo identity setup` + - `Open configuration editor` + - `Start over from scratch` + - `Cancel` +- `Open configuration editor` routes to `netclaw config`. +- `Start over from scratch` opens a second dialog with exactly: + - `Reset setup only` + - `Full reset` + - `Cancel` + followed by a double confirmation before any destructive action. +- Remove `init --force` from planning entirely. +- Keep the post-flight messaging focused on the split: + bootstrap is complete, use `netclaw chat` to start and `netclaw config` + for ongoing settings. + +**In scope (MVP):** bootstrap-first init flow, existing-install menu, +guarded scratch-reset flow with double confirmation, posture and enabled- +features behavior aligned to the locked decisions, and init smoke coverage +updated to match. + +**Out of scope:** turning init back into the main settings surface, +recreating config editing inline, `--force`, Enterprise posture, or moving +Identity into `netclaw config`. + +## Capabilities + +### Modified Capabilities + +- `netclaw-onboarding`: bootstrap-only init flow, explicit existing-install + menu, guarded scratch reset, and locked posture/enabled-features split. + +## Impact + +**Affected systems:** + +- CLI init entry handling. +- Init wizard step composition. +- Existing-install branching screens. +- Init smoke tapes and assertions. + +**Security and operational impact:** + +- Existing installs are not silently re-walked. +- Destructive reset behavior is explicit, menu-driven, and double- + confirmed. +- Identity remains under init ownership instead of becoming a second config + surface. diff --git a/openspec/changes/archive/2026-06-09-simplify-netclaw-init/specs/netclaw-onboarding/spec.md b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/specs/netclaw-onboarding/spec.md new file mode 100644 index 000000000..baa945ef0 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/specs/netclaw-onboarding/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Guided onboarding + +`netclaw init` SHALL provide bootstrap-first guided setup. The flow SHALL +collect provider configuration, identity, and security posture. Security +Posture, Enabled Features, and Audience Profiles are distinct concepts. + +If the operator selects `Personal`, the bootstrap flow SHALL skip Enabled +Features. + +If the operator selects `Team` or `Public`, the bootstrap flow SHALL +automatically continue into Enabled Features before final write. + +Audience Profiles editing SHALL NOT be part of init bootstrap; it belongs +to `netclaw config`. + +The wizard SHALL continue to write `SOUL.md` and `TOOLING.md`. Identity +remains init-owned in this branch. + +#### Scenario: Personal posture skips enabled-features bootstrap step + +- **GIVEN** the operator selected `Personal` +- **WHEN** the posture step completes +- **THEN** init does not open an Enabled Features step + +#### Scenario: Team posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Team` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +#### Scenario: Public posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Public` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +### ADDED Requirement: Existing-install init menu + +When `netclaw init` runs on an existing install, it SHALL open an action +menu with exactly these options: + +- `Redo identity setup` +- `Open configuration editor` +- `Start over from scratch` +- `Cancel` + +#### Scenario: Existing install opens action menu + +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw init` +- **THEN** init opens the existing-install menu with the documented four + options + +#### Scenario: Existing install routes to config editor + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Open configuration editor` +- **THEN** control routes to `netclaw config` + +#### Scenario: Existing install routes to init-owned identity flow + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Redo identity setup` +- **THEN** control routes to the init-owned identity flow + +### ADDED Requirement: Start-over flow is double-confirmed + +Choosing `Start over from scratch` SHALL open a second dialog with exactly: + +- `Reset setup only` +- `Full reset` +- `Cancel` + +Either destructive option SHALL require double confirmation before files are +mutated. + +#### Scenario: Start-over dialog presents reset choices + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Start over from scratch` +- **THEN** the second dialog presents `Reset setup only`, `Full reset`, and + `Cancel` + +#### Scenario: Destructive reset requires double confirmation + +- **GIVEN** the operator selected either `Reset setup only` or `Full reset` +- **WHEN** the destructive flow proceeds +- **THEN** two distinct confirmations are required before mutation + +### ADDED Requirement: No init-force flag in this flow + +This bootstrap flow SHALL NOT rely on a `netclaw init --force` mode. +Existing-install reset behavior is owned by the in-TUI existing-install +menu and start-over dialogs. + +#### Scenario: Existing-install reset does not require hidden flag + +- **GIVEN** an existing install +- **WHEN** the operator wants to restart setup +- **THEN** the path is available from the existing-install init menu +- **AND** it does not depend on `netclaw init --force` diff --git a/openspec/changes/archive/2026-06-09-simplify-netclaw-init/tasks.md b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/tasks.md new file mode 100644 index 000000000..03f01f846 --- /dev/null +++ b/openspec/changes/archive/2026-06-09-simplify-netclaw-init/tasks.md @@ -0,0 +1,63 @@ +## 1. OpenSpec planning artifacts and traceability + +- [x] 1.1 Remove all planning references to `netclaw init --force`. +- [x] 1.2 Confirm the artifacts reflect bootstrap-only init and init-owned + Identity. +- [x] 1.3 Run `openspec validate simplify-netclaw-init --type change`. + +## 2. First-run bootstrap flow + +- [x] 2.1 Trim init to the bootstrap steps only. +- [x] 2.2 Keep posture values to `Personal`, `Team`, `Public`. +- [x] 2.3 Keep Security Posture, Enabled Features, and Audience Profiles + distinct in planning and implementation. +- [x] 2.4 When posture is `Personal`, skip Enabled Features. +- [x] 2.5 When posture is `Team` or `Public`, automatically continue into + Enabled Features. + +## 3. Existing-install init menu + +- [x] 3.1 Detect an existing install before entering the first-run flow. +- [x] 3.2 Show exactly these existing-install options: + `Redo identity setup`, `Open configuration editor`, + `Start over from scratch`, `Cancel`. +- [x] 3.3 Route `Open configuration editor` to `netclaw config`. +- [x] 3.4 Route `Redo identity setup` into the init-owned identity flow. + +## 4. Start-over flow + +- [x] 4.1 Implement the `Start over from scratch` dialog with exactly: + `Reset setup only`, `Full reset`, `Cancel`. +- [x] 4.2 Require double confirmation before either destructive action. +- [x] 4.3 Remove all implementation planning tied to `--force` backup or + flag parsing. + +## 5. Identity ownership + +- [x] 5.1 Keep Identity owned by init. +- [x] 5.2 Remove any planning language that assumes Identity moves into + `netclaw config`. + +## 6. Post-flight messaging + +- [x] 6.1 Point successful bootstrap users to `netclaw chat` and + `netclaw config`. +- [x] 6.2 Keep messaging consistent with the bootstrap-vs-config split. + +## 7. Coverage + +- [x] 7.1 Rewrite init smoke coverage for the bootstrap-first flow. +- [x] 7.2 Add coverage for the existing-install action menu. +- [x] 7.3 Add coverage for the start-over dialog and double confirmation. +- [x] 7.4 Remove old smoke planning tied to `init --force`. + +## 8. Quality gates + +- [x] 8.1 `dotnet build` clean. +- [x] 8.2 `dotnet test` clean. +- [x] 8.3 `./scripts/smoke/run-smoke.sh init-wizard` clean. +- [x] 8.4 `./scripts/smoke/run-smoke.sh light` clean. +- [x] 8.5 `dotnet slopwatch analyze` clean. +- [x] 8.6 `./scripts/Add-FileHeaders.ps1 -Verify` clean. +- [x] 8.7 `openspec validate simplify-netclaw-init --type change` + passes. diff --git a/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/.openspec.yaml b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/.openspec.yaml new file mode 100644 index 000000000..a903f7fe1 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-16 diff --git a/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/design.md b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/design.md new file mode 100644 index 000000000..f8f8b77cc --- /dev/null +++ b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/design.md @@ -0,0 +1,68 @@ +## Context + +The deep review (`docs/reviews/2026-06-config-tui-deep-review.md`) shows the high-severity +TUI bugs are not independent: they fall out of three missing conventions. This design +fixes the conventions once and routes the individual findings through them, rather than +patching ~25 call sites in isolation. Constraint: the Termina event loop is single-threaded; +config viewmodels are `IDisposable`; the repo is default-deny and forbids silent fallbacks. + +## Goals / Non-Goals + +**Goals** +- One atomic, serialized write path for all config/secrets/device-registry persistence. +- A uniform background-task lifecycle for config viewmodels (track → cancel → await). +- A uniform fail-loud convention for config parse/read on render & autosave paths. +- Deny-by-default for unparseable/unknown security-relevant values. +- Each fix backed by a test that fails before the fix (race, fake-failure, or round-trip). + +**Non-Goals** +- Decomposing the two god-object viewmodels (separate follow-on change). +- The 53 low-severity findings (opportunistic later sweep). +- Any happy-path behavior change visible to the operator. + +## Decisions + +- **Single atomic write seam.** Replace `File.WriteAllText` in the config/secrets/device + writers with a shared atomic write (write to a sibling temp file, flush, then + `File.Move(temp, dest, overwrite: true)`). Centralize in `ConfigFileHelper` so + `ConfigEditorSession`, `WizardConfigBuilder`, and the `devices.json` writer all reuse it. + Rejected: per-writer ad-hoc temp files (duplicates the logic; drift risk — the exact + defect class this whole change exists to remove). + +- **Serialize writes + background-task lifecycle.** A config viewmodel that spawns a + background probe/label task stores the `Task` handle and its `CancellationTokenSource`, + exposes a `CancelAndAwaitBackgroundAsync()`, and calls it at the start of `Save` and in + `Dispose`. The save path and the background path therefore never write concurrently, and + a stale post-probe continuation can no longer mutate a reset viewmodel. Rejected: a + global write lock only (doesn't stop the stale-state clobber — the data race on the + shared viewmodel object is separate from the file race). + +- **Fail-loud convention.** Config parse/read invoked from a render or autosave path is + wrapped to convert a parse/IO exception into a surfaced status message (and a safe, + read-only fallback for rendering) instead of throwing into the loop. Distinct from + **deny-by-default**: when the value is *security-relevant* and unparseable/unknown, the + fallback is the most-restrictive interpretation (disabled / no-grant) plus a warning — + never a permissive assumption. Both are explicit and visible; neither is a silent + degrade (which the constitution forbids). + +- **Persist-after-validate for secrets.** Credentials are written to disk only after the + validating probe succeeds; a failed probe leaves the prior secret untouched. + +## Risks / Trade-offs + +- **Making probes truly async changes interface shapes** (`ISkillFeedReachabilityProbe`, + etc.) → mitigation: change the interface + all impls/fakes together; cover with a + responsiveness/cancellation test. +- **Cancel-and-await before save adds latency** when a probe is mid-flight → acceptable + (bounded by the probe's own timeout; correctness over a few ms) and only on the rare + concurrent-save path. +- **Fail-loud fallbacks could mask a real config problem** → mitigation: the fallback is + always accompanied by a visible status/warning, never silent; security-relevant cases + deny rather than permit, so the safe direction is preserved. + +## Migration Plan + +Incremental and behavior-preserving on the happy path: land the atomic-write seam first +(everything else depends on it), then the per-viewmodel lifecycle + fail-loud guards, then +the targeted correctness/secret fixes — each its own commit with its test. No data +migration; existing config files are read unchanged and rewritten atomically. diff --git a/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/proposal.md b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/proposal.md new file mode 100644 index 000000000..be3c45059 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/proposal.md @@ -0,0 +1,73 @@ +## Why + +A deep C# implementation review of the `netclaw config` / `netclaw init` TUI +(`docs/reviews/2026-06-config-tui-deep-review.md` — 85 findings; the 32 high/medium +are all confirmed against code) found that the TUI's high-severity bugs cluster into +a few systemic root causes rather than isolated defects: the single-threaded Termina +loop does disk I/O and network probes with no consistent concurrency model +(fire-and-forget tasks that race a save, non-atomic `File.WriteAllText` that can +corrupt `netclaw.json`/`devices.json`, sync-over-async that freezes the input loop), +config parse/read errors throw straight into the event loop (crash or permanent +freeze), and several security-relevant fallbacks silently assume a *permissive* +default — a direct violation of the repo's default-deny posture and the constitution's +"No silent fallbacks" rule. This change hardens those root causes; it is reliability +and security hardening of shipped behavior, not a new feature. + +## What Changes + +- **Atomic, serialized config persistence.** All config / secrets / device-registry + writes go through one atomic write seam (temp file + rename) and are serialized so a + background task and a user save can never write the same file concurrently. Fixes the + corruption window on `devices.json` and `netclaw.json`. +- **Background-task lifecycle discipline.** Config viewmodels track their background + probe/label-refresh tasks (no fire-and-forget), and cancel-and-await them before a + save and on dispose, so a stale probe result can no longer clobber freshly-loaded + state or persist a stale snapshot. +- **Responsive event loop.** Remove sync-over-async on the UI thread; probes run off + the loop so the TUI stays responsive. +- **Fail-loud on config parse/read.** Parse/load on render and autosave paths surface a + status message and stay usable instead of throwing into the event loop (no more + dashboard-render crashes or a wizard wedged at `IsRunning=true`). +- **Deny-by-default security fallbacks.** An unparseable / unrecognized security-relevant + value denies (most-restrictive / disabled) and warns — never silently assumes a + permissive default (posture, server-enabled, plaintext-credential). +- **Targeted correctness/secret fixes.** Audience (ACL trust-tier) changes autosave; + credentials persist only after a successful probe; unresolved channel names never + become inert ACL keys; assorted crash/throw edges removed. +- **NOT in scope (deferred):** decomposing the two ~2,300-line god-object viewmodels + (`ChannelsConfigViewModel`, `SkillSourcesConfigViewModel`) — the design findings flag + these as the structural enabler of the concurrency bugs, but the refactor is large and + belongs in its own follow-on change after this hardening lands. The 53 low-severity + findings (catalogued in the review doc) are likewise deferred for an opportunistic sweep. + +## Capabilities + +### New Capabilities + +- `config-tui-resilience`: invariants for how the config/init TUI persists data and + handles malformed or security-relevant config — atomic+serialized writes, tracked and + cancellable background tasks, a responsive loop, fail-loud parsing, deny-by-default + fallbacks, persist-after-validate for secrets, and no silent loss of an ACL change. + +### Modified Capabilities + +None — the affected behaviors were never specified as requirements; they are introduced +as new invariants under `config-tui-resilience`. + +## Impact + +- **Code:** `ConfigEditorSession` / `WizardConfigBuilder` / `ConfigFileHelper` (atomic + write seam); the device-registry writer in `ExposureModeStepViewModel`; the config + viewmodels (`ChannelsConfigViewModel`, `SkillSourcesConfigViewModel`, + `SecurityAccessViewModel`, `BrowserAutomationConfigViewModel`, + `TelemetryAlertingConfigViewModel`, `ConfigDashboardViewModel`), the manager/step + viewmodels (`ProviderManagerViewModel`, `ProviderStepViewModel`, + `HealthCheckStepViewModel`, `SlackStepViewModel`, `DiscordStepViewModel`), + `McpToolPermissionsViewModel`, and the probe interfaces made truly async. +- **Tests:** new concurrency tests (race/cancellation), fake-failure tests proving the + bad path is blocked before persistence, and config round-trip tests, per the repo + Automation Floor; native smoke tapes for any touched TUI surface. +- **Security & operational:** net-positive — removes corruption windows, removes silent + permissive fallbacks on the default-deny surface, and stops malformed config from + crashing or wedging the TUI. No intended user-facing behavior change on the happy path. +- **Evidence:** every task cites the file:line in `docs/reviews/2026-06-config-tui-deep-review.md`. diff --git a/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/specs/config-tui-resilience/spec.md b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/specs/config-tui-resilience/spec.md new file mode 100644 index 000000000..cc28d9f3b --- /dev/null +++ b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/specs/config-tui-resilience/spec.md @@ -0,0 +1,124 @@ +## ADDED Requirements + +### Requirement: Atomic config persistence + +Config, secrets, and the paired-device registry SHALL be written atomically — to a +sibling temporary file that is flushed and then renamed over the destination — so that an +interrupted or concurrent write can never leave a partially-written or corrupted file. + +#### Scenario: Interrupted write leaves the prior file intact + +- **WHEN** a config or `devices.json` write is interrupted (process kill, crash) part-way +- **THEN** the destination file still contains the last fully-written content, never a + truncated or partial document + +#### Scenario: All persistence paths use the shared atomic writer + +- **WHEN** any of the config editor, the wizard config builder, or the device-registry + writer persists to disk +- **THEN** it goes through the single shared atomic write helper, not a direct + non-atomic `File.WriteAllText` + +### Requirement: Serialized config writes + +The config TUI SHALL serialize disk writes for a given file so that a background task and +a user-triggered save can never write the same file concurrently. + +#### Scenario: Background refresh in flight during a save + +- **WHEN** a background channel-label refresh is in flight and the operator triggers a save +- **THEN** the background task is cancelled and awaited before the save writes to disk, so + the two writers never overlap + +### Requirement: Tracked, cancellable background tasks + +Config viewmodels SHALL track their background probe and refresh tasks (retaining the +`Task` handle and cancellation source) and cancel-and-await them before a save and on +dispose, rather than discarding them as fire-and-forget. + +#### Scenario: Dispose with a probe in flight + +- **WHEN** a config viewmodel is disposed while a background probe is still running +- **THEN** the probe is cancelled and its continuation performs no further state mutation + or disk write + +#### Scenario: Stale probe result cannot clobber reloaded state + +- **WHEN** a background probe completes after the viewmodel state has been reset by a save +- **THEN** the stale result is discarded rather than overwriting the freshly-loaded state + or being persisted + +### Requirement: Responsive event loop + +The config TUI SHALL NOT block the single-threaded event loop on asynchronous I/O — +network probes and disk operations run off the loop and there is no synchronous wait on +an async result from the input/render path. + +#### Scenario: Reachability probe keeps the UI responsive + +- **WHEN** a skill-feed or channel reachability probe runs +- **THEN** the input loop continues to process keystrokes and render while the probe is in + flight, rather than freezing until it completes + +### Requirement: Fail-loud config parsing on render and autosave paths + +Config parse and read operations invoked from a render or autosave path SHALL surface a +status message and remain usable, never throw an unhandled exception into the event loop. + +#### Scenario: Dashboard renders against a malformed config + +- **WHEN** the config dashboard renders and a section of the config is malformed +- **THEN** the affected summary shows an error indicator and the dashboard stays usable, + instead of the render crashing the TUI + +#### Scenario: Parse failure does not wedge the wizard + +- **WHEN** an unexpected exception occurs during a wizard health-check or config write +- **THEN** the wizard reports the failure and remains interactive, rather than being left + permanently in a running/incomplete state + +### Requirement: Deny-by-default on unparseable security values + +The editor SHALL deny by default when a security-relevant config value cannot be parsed or +has an unrecognized shape — treating it as the most-restrictive interpretation (disabled / +no-grant) and warning the operator — and MUST NOT silently assume a permissive default. + +#### Scenario: Unparseable deployment posture + +- **WHEN** the persisted deployment posture cannot be parsed +- **THEN** the editor surfaces an error rather than silently assuming the `Personal` + posture + +#### Scenario: Unrecognized server-enabled shape + +- **WHEN** a server entry's enabled flag has an unrecognized JSON shape +- **THEN** the server is treated as disabled, not enabled + +### Requirement: Persist secrets only after validation + +A credential entered in a config editor SHALL be persisted to disk only after its +validating probe succeeds; a failed probe MUST leave any previously stored secret +unchanged. + +#### Scenario: Fix-credentials probe fails + +- **WHEN** the operator submits a new credential and its probe fails +- **THEN** the new secret is not written to disk and the prior credential is preserved + +### Requirement: Audience changes are never silently lost + +An in-place change to a channel or DM audience — which sets the ACL trust tier — SHALL be +persisted immediately like every other editor mutation, and MUST NOT be silently discarded +when the operator navigates away. + +#### Scenario: Cycle a channel audience and navigate back + +- **WHEN** the operator cycles a channel's audience with the arrow keys and then navigates + out of the screen +- **THEN** the new audience is persisted to config rather than reverting on the next load + +#### Scenario: Unresolved channel name is inert, not a wrong ACL key + +- **WHEN** a channel cannot be resolved to an ID during save +- **THEN** the unresolved name is not written as an ACL key that the runtime cannot match; + it is omitted or flagged so it grants nothing diff --git a/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/tasks.md b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/tasks.md new file mode 100644 index 000000000..47d7b6bf6 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-harden-config-tui-io-and-failloud/tasks.md @@ -0,0 +1,45 @@ + + +## 0. Foundation — atomic write seam + +- [x] 0.1 Add an atomic write helper to `ConfigFileHelper` (write sibling temp file → flush → `File.Move(overwrite:true)`) and route `WriteConfigFile`/`WriteSecretsFile` through it. Test: round-trip + a partial/interrupted-write test proving the prior file survives. — Added `AtomicFile` (Netclaw.Configuration); routed `ConfigFileHelper.WriteConfigFile` + `SecretsFileWriter.Write` (secrets perms now hardened on the temp before rename). `AtomicFileTests`. +- [x] 0.2 Route the device-registry writer (`ExposureModeStepViewModel.WriteLocalDeviceTokenValue` / `WritePairedDevices`, review:392) through the shared atomic helper. Test: `devices.json` round-trip; no corruption on concurrent/interrupted write. — Both `devices.json` writes (`WritePairedDevices` + `WriteBootstrapDevice`) now use `AtomicFile.WriteAllText` + `AtomicFile.HardenOwnerOnly`; the chmod-600 logic deduped into one helper (was 3 copies; secrets path delegates to it). New atomicity assertion in `ExposureModeConfigViewModelTests`. + +## 1. Theme 1 — concurrency & background-task discipline + +- [x] 1.1 `ChannelsConfigViewModel` (review:1094, :1042) — track the label-refresh `Task`+CTS; `CancelAndAwait` before `SaveAsync` writes; guard the post-probe continuation so a stale result cannot clobber reset state or persist a stale snapshot. Test: `ChannelsConfigViewModelTests` race — a probe straddling a save neither corrupts the file nor overwrites reloaded state. — Track `_labelRefreshTask`; `CancelAndAwaitLabelRefreshAsync()` at the top of `SaveAsync` (the seam all explicit/autosave writes route through). New `SaveAsync_cancels_and_awaits_in_flight_label_refresh_before_writing` (blocking probe). Channels smoke batched for the Section 1 checkpoint. +- [x] 1.2 `ChannelsConfigViewModel.AutosaveCompletedAction` (review:1310) — remove the sync-over-async `.GetResult()`; make the autosave path async end-to-end. Test: autosave completes without blocking; existing autosave tests stay green. — Per user decision "persist now, validate async": autosave persists synchronously without the blocking network channel-access probe (`SaveAsync(..., probeChannelAccess: false)`); explicit `Save`/`SaveFromInputAsync` keep `probeChannelAccess: true`. New `Autosave_of_a_completed_action_does_not_run_the_network_channel_probe`. config-channels smoke at the Section 1 checkpoint. +- [x] 1.3 `ProviderStepViewModel` (review:173) — fix the CTS self-nulling race (`finally { CancelProbe(); }` disposing the CTS it is running on). Test: probe-cancellation test. — `CancelProbe` uses `Interlocked.Exchange`; the probe `finally` uses `Interlocked.CompareExchange` to tear down only its own CTS if still active, so a superseded probe can no longer cancel/dispose its replacement. New `Superseded_probe_completion_does_not_cancel_the_replacement_probe` (gated nested fake). init-wizard/provider smoke batched for the wizard checkpoint. +- [x] 1.4 `HealthCheckStepViewModel` (review:55) — synchronize the `Results` list across the async writer and the render thread (lock or marshal). Test: concurrent add/read does not throw or tear. — All `Results` mutations (HealthCheckRunner.Add/UpdateLast/AllPassed + VM Add/Clear/SetLast/LastPending helpers) lock on the list instance; the view reads `ResultsSnapshot()` under the same lock. New 50k-iteration concurrency stress test. init-wizard smoke in the wizard checkpoint. +- [x] 1.5 `ProviderManagerViewModel.RevalidateAsync` (review:714) — track the `Task`, pass a real CTS, cancel on `GoBackToList`/`Dispose`, guard `NotifyStateChanged` after dispose. Test: revalidate is cancelled on back-out. — `RevalidateDetailProvider` tracks `RevalidateCompletion` with a dedicated `_revalidateCts`; `CancelRevalidate()` in `GoBackToList` + `Dispose`; `RevalidateAsync(item, ct)` guards on `ct.IsCancellationRequested` before updating health/notifying. New `Leaving_detail_view_cancels_in_flight_revalidation` (gated shared fake). +- [x] 1.6 `DiscordStepViewModel` (review:300) — remove the `Task.Run` data race on `LastChannelResolution`/`ChannelEntry.DisplayName` (await-able prefetch or marshal the mutation back to the loop). Test: resolution result is applied without a race. — Dropped the fire-and-forget `Task.Run`; the prefetch is now a tracked `_resolutionTask` whose only off-thread write is an atomic `LastChannelResolution` reference publish (token-guarded). `ChannelEntry.DisplayName` mutation stays on the loop thread (`OnLeave`/`ApplyResolvedDisplayNamesToContext`), so the render thread never reads an entry a pool thread is mutating. `ContributeHealthChecksAsync` awaits the prefetch (`PendingResolution`) before reading + reuses its work. Best-effort probe/parse failures swallowed (authoritative health-check re-resolves with proper messaging). New gated `BackgroundChannelResolution_PublishesResult_AppliedOnLeaveWithoutRace` + `…_DisposedBeforeProbeReturns_DropsStaleResult`. Verified by a concurrency-specialist adversarial pass (core race closed; broadened the catch to fix an unobserved-`JsonException` hole it surfaced). init-wizard smoke green. +- [x] 1.7 `SkillSourcesConfigViewModel` (review:29, :748) — make `ISkillFeedReachabilityProbe` truly async (and its impl + fakes), remove the blocking `.GetAwaiter().GetResult()`, run the probe off the loop. Test: input loop stays responsive during the probe; probe is cancellable. — `ISkillFeedReachabilityProbe.Probe` → `ProbeAsync` (`SendAsync` + linked caller-token/timeout CTS). VM gained a tracked off-loop probe lifecycle (`_probeCts`/`_probeTask`, `StartBackgroundProbe`/`RunProbeAsync`, `PendingProbe`, cancel on `Dispose`); the continuation is **status-only** (never navigation), matching the channels async→status boundary. Per the operator-approved "persist now, validate async": the two `.GetAwaiter().GetResult()` commit gates persist immediately then warn via an off-loop probe (`SaveRemoteUrlChange`/`SaveRotatedRemoteToken` drop their blocking `probeBeforeSave` gate); the interactive add-remote review (`ProbePendingRemoteThenReview`) + `TestSource` run the probe off-loop showing "Testing…" and apply the disclosure/navigation on the **next** loop-thread Enter (two-phase) — auth-field disclosure preserved as a status-then-act prompt (operator decision: "Async + status-only disclosure"). Dead `Validate*ReachabilityAsync` + `_saveAnywayFingerprint` removed. ~30 tests migrated to `await vm.PendingProbe` + the persist-now/two-phase semantics; 2 new tests prove the loop isn't blocked (gated probe) and the probe is cancelled on dispose. Build clean, 48 SkillSources/page tests green, slopwatch 0, config-skill-picker smoke green. + +## 2. Theme 2 — fail-loud parsing, deny-by-default fallbacks + +- [x] 2.1 `SecurityAccessViewModel` (review:634) — unparseable `DeploymentPosture` surfaces an error instead of silently defaulting to `Personal`. Test: fake-bad-posture config → error surfaced, no permissive assumption. — `ReadPosture` split into `TryReadPosture`: a MISSING key still defaults to Personal (normal fresh-config state), but a PRESENT-but-unparseable value now fails **closed** to `Public` (matching the daemon's `TrustContextPolicy` fallback — the prior silent `Personal` was the *most permissive* and disagreed with the fail-closed runtime). New `PostureConfigWarning` surfaces the raw bad value, and the Security Posture menu summary renders `Unknown ('…') — using Public`. New `Unparseable_posture_fails_loud_and_closed_not_permissive`. +- [x] 2.2 `BrowserAutomationConfigViewModel.IsServerEnabled` (review:292) — return `false` for an unrecognized JSON shape (default-deny), not `true`. Test: unrecognized shape → disabled. — Both fallback branches (a JSON object lacking `Enabled`, and any other shape) now return `false`; a browser MCP server entry must carry an explicit `"Enabled": true` to be treated as enabled. New `Server_entry_without_explicit_Enabled_flag_is_treated_as_disabled`. +- [x] 2.3 `SkillSourcesConfigViewModel` (review:2111) — a stored plaintext API key surfaces a warning (and/or opportunistic re-encrypt), never silent acceptance. Test: plaintext key → warning raised. — `SkillSourceDisplay` gained `ApiKeyIsPlaintext` (a stored token present but not `ENC:`-prefixed); the remote source's Authentication detail row now renders "bearer token stored as PLAINTEXT" with a Warning tone and "use Rotate token to … encrypt it" guidance, instead of silently using the unprotected credential. New `[Theory] Feed_token_stored_as_plaintext_is_flagged_not_silently_accepted` (plaintext → flagged Warning; `ENC:` → not flagged). +- [x] 2.4 `SecurityAccessViewModel` (review:650 `ParseExposureMode`, :552 `ConvertConfigObject`) — guard parse/convert on render and mutation paths to surface a status instead of throwing into the loop. Test: malformed exposure/audience config → status, no crash. — `ReadExposureModeSummary` + `ReadAudienceProfilesSummary` (render path) and `LoadAudienceProfiles`/`AudienceProfilesCustomized` (mutation path) now catch the `InvalidOperationException` and degrade: exposure shows `Unknown ('…')`, audience shows `Unreadable — re-save to repair`, and the loader falls back to posture defaults. Also guarded the identical `ExposureModeStepViewModel.ReadExistingMode` (wizard prefill, named in the finding) → fail-closed to Local. New `Malformed_exposure_and_audience_config_render_a_status_without_crashing` + `Prefill_from_unparseable_exposure_mode_falls_back_to_local_without_throwing`. +- [x] 2.5 `ConfigDashboardViewModel` (review:245) — guard `LoadSection` in `SkillSourcesSummary`/`TelemetrySummary` so a malformed section renders a fallback string, not a layout-time crash. Test: malformed config → dashboard renders with an error indicator. — Both summaries (the only two that deserialize whole sections via `LoadSection`, unlike the others which use throw-free `TryGetPathValue`) now catch `JsonException` and return `– config error`. New `Malformed_sections_render_a_config_error_indicator_without_crashing` (wrong-shape `ExternalSkills.Sources` / `Notifications.Webhooks`). +- [x] 2.6 `SkillSourcesConfigViewModel` (review:1935) — config-write exceptions are caught and surfaced, not propagated to the event loop. Test: fake write failure → status surfaced, loop survives. — `SaveExternalConfig`/`SaveSkillFeedsConfig` route through a new `TryWriteConfigRoot` that catches `IOException`/`UnauthorizedAccessException` (disk full, permission, path-too-long), surfaces an error status, and returns `false`; all 14 callers now `if (!Save…) return;` so a write failure no longer crashes the loop AND the error status survives (no false "saved"). Also guarded the identical unguarded write in `WorkspacesConfigViewModel.Save()` (named in the finding). New `Config_write_io_failure_surfaces_an_error_and_persists_nothing` (read-only config dir, Unix-gated). +- [x] 2.7 `HealthCheckStepViewModel` (review:115) — an unexpected exception in the health-check core reports and leaves the wizard interactive (no `IsRunning=true`/`IsComplete=false` wedge). Test: injected exception → wizard not wedged. — `RunWithOrchestrator` gained a catch-all (after the timeout `OperationCanceledException` catch) that adds a `Health check failed: …` result, sets `IsRunning=false`/`IsComplete=true`, and surfaces a status — so an unexpected fault (e.g. an IO error in a step's `ContributeHealthChecksAsync`, which the orchestrator lets propagate) no longer wedges the wizard (`GoNext` gates on `!IsRunning && !IsComplete`). New `RunWithOrchestrator_UnexpectedStepException_ReleasesWizardAndReportsError` with a minimal throwing fake step. + +## 3. Theme 3 — targeted correctness & secret-ordering + +- [x] 3.1 `ChannelsConfigViewModel.ChangeSelectedChannelAudience` (review:489, :477) — autosave the ←/→ audience (ACL trust-tier) change like every other mutation. Test: cycle audience → navigate away → persisted (not reverted). — The ←/→ toggle was the only ChannelPermissions mutation that called `NotifyContentChanged()` instead of `AutosaveCompletedAction(...)`, so the security-relevant trust-tier edit was silently discarded on Esc (next load reset from disk). Now autosaves like `RemoveSelectedChannel`/`ApplyAddChannel`. New `Cycling_channel_audience_autosaves_without_an_explicit_save` (C01 Team→Public persists with no `Save()`). +- [x] 3.2 `ProviderManagerViewModel` (review:519) — write the fixed credential to disk only after the probe succeeds; a failed probe preserves the prior secret. Test: probe-fail leaves the stored secret unchanged. — `SubmitFixCredentials` no longer writes `secrets.json`/`netclaw.json` before probing; the write moved into a new `WriteFixedCredentials` helper called only from the `IsFixFlow` probe-**success** branch (matching the add flow's deferred `WriteProviderConfig`). A typo in the new API key/endpoint no longer clobbers the working credential with no rollback. New `FixCredentials_ProbeFailure_LeavesStoredSecretUnchanged` (secrets file byte-unchanged; bad key never written). +- [x] 3.3 `SlackStepViewModel.BuildChannelAudiences` (review:299) — do not write an unresolved channel name as an ACL key; omit/flag it (or block) so it grants nothing. Test: unresolved channel → no inert name key in `ChannelAudiences`. — `ResolveChannelAudienceKey` (which returned the channel NAME when `LastChannelResolution` was null or the name was unresolved) is now `TryResolveChannelAudienceKey`: it yields a key only for the DM row (`"dm"`) or a resolved canonical channel ID, and `BuildChannelAudiences` omits any entry it can't resolve — so a dead, name-keyed ACL entry the Slack runtime can never match is never written. The health-check phase already warns about unresolved channels. New `ContributeConfig_OmitsUnresolvedChannelNameFromAudiences_KeepsResolvedById`. +- [x] 3.4 `ChannelsConfigViewModel.ApplyAddChannelAsync` (review:582) — replace `Single()` with a safe predicate so a resolved id of `"dm"` with DMs enabled does not throw. Test: add a `"dm"`-resolving channel → no exception. — The row-focus lookup that positioned `_channelRowIndex` used `Single(row.Id == channelId)`; a resolved id of exactly `"dm"` with DMs enabled collided with the DM row (also `Id="dm"`) → `InvalidOperationException` crashing the add flow. Now `FirstOrDefault` over `!IsDirectMessage && !IsAction && Id==channelId` (matches only real channel rows), guarded against not-found. New `Add_channel_resolving_to_dm_with_dms_enabled_does_not_throw`. +- [x] 3.5 `TelemetryAlertingConfigViewModel` (review:289) — add an explicit gesture to clear a webhook auth header (blank-preserve still keeps it). Test: clear gesture removes the header; blank keeps it. — `SaveWebhookForm` now treats a single `-` in the auth field as an explicit clear (`target.Headers = null`); blank still preserves the stored header. The edit-form placeholder now reads `(stored header preserved — enter - to clear)` so the gesture is discoverable. New `Editing_a_webhook_clears_the_auth_header_with_the_dash_gesture`; the existing blank-preserve test still passes. +- [x] 3.6 `McpToolPermissionsViewModel.BuildAllowedServerList` (review:533) — operate on a copy, not the live in-memory profile object. Test: building the list does not mutate the source profile. — `BuildAllowedServerList` mutated `profile.McpServersMode`/`AllowedMcpServers` on the live `Profiles.Public/Team/Personal` objects that back runtime ACL queries; folded it into `SaveServerAccess`, which now accumulates each audience's allow-list in a local working dict (seeded once from the original profile so multi-change-per-audience still accumulates correctly) and writes mode/list straight to the serialization dict — the in-memory ACL profile is never touched. New `Save_DoesNotMutateTheLiveInMemoryProfile` (live profile stays `All` after a save that converts the persisted config to `Allowlist`); existing All→Allowlist + allowlist-preserve tests still pass. + +## 4. Verification & close + +- [x] 4.1 Per fix/batch: `dotnet build` + `dotnet test` (affected projects) + `dotnet slopwatch analyze` + `Add-FileHeaders.ps1 -Verify`; run the native smoke tape(s) for any touched TUI surface (config-channels, config-search, config-posture, config-exposure, init-wizard, etc.). +- [x] 4.2 `/opsx-verify` the change; full unit suite + `run-smoke.sh light` green before declaring the list complete. +- [x] 4.3 On merge with the implementation branch: `/opsx-sync` then `/opsx-archive` to fold `config-tui-resilience` into `openspec/specs/`. diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/.openspec.yaml b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/.openspec.yaml new file mode 100644 index 000000000..e767a17c2 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-15 diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/design.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/design.md new file mode 100644 index 000000000..185c75dc6 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/design.md @@ -0,0 +1,59 @@ +## Context + +The `netclaw config` rewrite and `netclaw init` simplification are implemented, tested, and +shipped on `docs/netclaw-validated-ui-components`. A spec-vs-code audit found six canonical +specs drifted from the as-built behavior. This change is documentation-only: it edits the +affected specs' requirements so they describe what the code already does. There is no +implementation work — every delta cites the implementing type and/or test as evidence. + +Constraint: deltas must copy MODIFIED requirement blocks verbatim from the existing spec +before editing, so no normative detail is lost at archive time; OpenSpec artifacts are +managed through the `/opsx-*` skills per the repo constitution. + +## Goals / Non-Goals + +**Goals:** +- Bring `netclaw-onboarding`, `channel-audience-tui`, `netclaw-config-command`, + `security-posture-tui`, `feature-selection-wizard`, and `inbound-webhooks` in line with + shipped behavior. +- Remove requirements describing abandoned approaches (the Memory/Memorizer init step) so + they cannot mislead future work. +- Preserve security-relevant invariants by stating them as the code actually enforces them + (inert unresolved channel names; auto-pairing the configuring client on non-local exposure). + +**Non-Goals:** +- No production code, API, schema, or test changes — the implementation is already complete. +- No re-litigation of the shipped design decisions; only their spec record. +- The unimplemented Phase-2 onboarding features (environment discovery, project registration) + are marked deferred, not removed — they remain future work outside this reconciliation. + +## Decisions + +- **MODIFIED in place over delete-and-readd.** Each drifted requirement is updated by copying + its full block and editing the changed clauses, so unrelated normative detail and scenarios + survive archiving. Delete-and-readd was rejected: it loses detail and muddies the diff. +- **REMOVE the Memory-provider requirements outright.** The Memorizer-vs-local-files step was a + pre-build exploration that shipped as neither — memory is the always-on auto-memory system on + SQLite, with no wizard step. It is REMOVED with Reason/Migration rather than MODIFIED, because + no shipped behavior corresponds to it. Marking it "deferred" (as with the Phase-2 features) + was rejected: there is no intent to build a memory wizard step. +- **Leave the bootstrap-exposure auto-pair requirement unchanged.** The audit flagged a + spec/code mismatch (spec said auto-pair, code blocked); that was fixed in code + (`ExposureModeStepViewModel.EnsureCurrentClientPaired`), so the existing spec is now accurate. + Evidence: `ExposureModeConfigViewModelTests` — orphaned/empty/mismatched cases assert the + configuring client is paired. + +## Risks / Trade-offs + +- [Memory-step removal reads as "memory was dropped"] → Mitigation: the REMOVED Reason states + memory is the always-on SQLite auto-memory system; the Migration points to that subsystem. +- [Deltas are point-in-time snapshots that can re-drift] → Mitigation: each delta cites the + implementing type/test; run `/opsx-verify` before archiving to confirm they still match code. +- [Imprecise MODIFIED header fails silently at apply] → Mitigation: copy `### Requirement:` + headers verbatim from `openspec/specs//spec.md`; validate before archive. + +## Migration Plan + +Spec-only change: merge with the implementation branch, then `/opsx-verify` and `/opsx-archive` +to sync the delta specs into `openspec/specs/`. Rollback is reverting the doc edits — no runtime +impact. diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/proposal.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/proposal.md new file mode 100644 index 000000000..e8e4e8972 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/proposal.md @@ -0,0 +1,71 @@ +## Why + +The `netclaw config` rewrite and `netclaw init` simplification shipped on +`docs/netclaw-validated-ui-components`, but a spec-vs-code audit found the canonical +OpenSpec specs drifted from what was actually built. Most acutely, `netclaw-onboarding` +still mandates a 9-step wizard with a Memory/Memorizer step that does not exist, and +`channel-audience-tui` describes a block-on-API-failure flow that the code replaced with +save-and-flag. These specs are referenced when extending the surface, so the drift will +mislead future work (and risks reintroducing a fixed lockout or ACL gap by trusting a +stale spec). This change reconciles the specs to the as-built, shipped behavior. (PRD-004.) + +## What Changes + +This change modifies requirements in existing specs to match shipped code. There is **no +production code change** — the implementation already exists and is covered by tests. + +- **netclaw-onboarding**: remove the obsolete Memory/Memorizer step and all 9-step + (`TotalSteps SHALL be 9`) language; document the actual 5-step flow (Provider → Identity + → Security Posture → Enabled Features [Personal skips] → Health Check); Identity collects + 4 substeps (agent name, communication style, operator name, timezone) — not + workspaces/webhook; Health Check auto-launches `netclaw chat` on success (no Enter gate); + add container-supervisor failure messaging; correct the Phase-2 identity file to SOUL.md + and mark environment-discovery / project-registration as deferred. **BREAKING (spec + only)**: removes the Memory-provider-selection and Memorizer-MCP requirements. +- **channel-audience-tui**: replace "block on Slack API failure" with the two-tier + behavior (a genuine probe failure blocks the save; unresolved channel names persist and + are flagged non-blockingly — an unresolved name in the allow-list is inert); add Slack + name→ID normalization and secret blank-preserve; replace the type-to-filter search with + the resolve-before-add single-entry flow. +- **netclaw-config-command**: add `Workspaces Directory` as a dashboard area; document the + directory pickers (Skill Sources local folder, Workspaces); add Inbound Webhooks behavior + (enable toggle + execution timeout + no-routes advisory); add Search progressive + disclosure; name Mattermost as a supported adapter. (Bootstrap-exposure auto-pair is now + accurate in code and is left unchanged.) +- **security-posture-tui**, **feature-selection-wizard**, **inbound-webhooks**: minor + corrections (step ordering/labels, audience-default ownership, posture cascade, Personal + omit-flags + auto-open Features, `Webhooks.ExecutionTimeoutSeconds` + no-routes advisory). + +## Capabilities + +### New Capabilities + +None — this is a pure reconciliation of existing capabilities. + +### Modified Capabilities + +- `netclaw-onboarding`: remove Memory/Memorizer + 9-step requirements; restate the 5-step + flow, 4-substep Identity, health-check auto-launch, supervisor-failure messaging, and the + SOUL.md identity file. +- `channel-audience-tui`: block→save-and-flag on channel resolution; name→ID normalization; + secret blank-preserve; resolve-before-add channel entry. +- `netclaw-config-command`: Workspaces Directory area; directory pickers; inbound-webhooks + enable/timeout/advisory; Search progressive disclosure; Mattermost adapter. +- `security-posture-tui`: Provider-step ordering (no "ChatServices" step); audience defaults + owned by the channel picker step; posture-change cascade confirmation. +- `feature-selection-wizard`: Personal posture omits Enabled flags (schema defaults); editor + auto-opens Enabled Features after a non-Personal posture save. +- `inbound-webhooks`: add `Webhooks.ExecutionTimeoutSeconds` and the no-routes advisory. + +## Impact + +- **Specs only.** No production code, API, or schema changes — the behaviors are already + implemented and tested on `docs/netclaw-validated-ui-components`. +- Affected specs: `openspec/specs/{netclaw-onboarding, channel-audience-tui, + netclaw-config-command, security-posture-tui, feature-selection-wizard, + inbound-webhooks}/spec.md`. +- **Security & operational**: net-zero behavior change. Reconciliation makes the + default-deny exposure/pairing and channel-ACL requirements describe what the code actually + enforces (unresolved channel names are inert; the configuring client is auto-paired on + non-local exposure), reducing the risk that a future edit reintroduces a lockout or a + silent ACL gap by trusting a stale spec. diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/channel-audience-tui/spec.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/channel-audience-tui/spec.md new file mode 100644 index 000000000..a0c3b6186 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/channel-audience-tui/spec.md @@ -0,0 +1,239 @@ +# channel-audience-tui Delta Spec + +Reconciles the `channel-audience-tui` capability spec to shipped code in +`ChannelsConfigViewModel`. Only requirements that differ from the baseline spec +are listed here. Unchanged requirements are omitted. + +--- + +## REMOVED Requirements + +### Requirement: Dynamic channel adding via Slack API + +**Reason**: The type-to-filter search populated from `conversations.list` was +never built. The shipped UI opens a single free-text input for a channel name or +ID; there is no filterable list, no inline `conversations.list` call, and no +"channel already in list" status message surfaced through that flow. The add +screen is `ChannelsConfigScreen.AddChannel`, driven by `BeginAddChannel` / +`ApplyAddChannelAsync`, which resolve the typed entry against the live adapter +before accepting it (resolve-before-add). The replacement requirement is +"Single-entry resolve-before-add channel flow" below. + +**Migration**: Tests and UI code referencing a type-to-filter search or +`conversations.list`-populated picker during channel add have no corresponding +implementation. The correct surface to test is `BeginAddChannel` → typed input +→ `ApplyAddChannelAsync` → success advances to `ChannelPermissions` with the +new row focused; failure stays on `AddChannel` with an error status. + +--- + +## MODIFIED Requirements + +### Requirement: Block on API failure with actionable error + +A channel save SHALL block only on a genuine probe failure, never on a merely-unresolved channel name. + +If a channel probe reports a **genuine failure** (invalid or expired token, +missing scope, network error, or any other condition that sets a non-empty +`ErrorMessage` on the resolution result), the save SHALL be blocked with an +actionable error message and no data SHALL be persisted. The user must fix the +credential or scope and retry before the save is accepted. + +If the probe call **succeeds** (no `ErrorMessage`) but one or more channel +names or IDs could not be resolved (the probe's `Unresolved` list is +non-empty), the save SHALL proceed: the entire adapter persists (token + all +channel entries, with resolved names rewritten to their canonical IDs and +unresolved entries kept verbatim). The unresolved entries are flagged +non-blockingly with a warning status message identifying each unresolved entry. + +Security invariant: an unresolved name or ID that persists verbatim in the +`AllowedChannelIds` list is inert — the runtime ACL matches against canonical +channel IDs, so an unresolved name grants access to no real channel. It is a +harmless placeholder until the bot can see the channel, at which point the +background label refresh will canonicalize it automatically. + +The distinction between a blocking failure and a non-blocking unresolved entry +is determined solely by the presence of a non-empty `ErrorMessage` on the +resolution result, NOT by the result's `Success` flag. `Success` is false +whenever any entry failed to resolve (including the non-blocking case), so +checking `Success` alone would incorrectly block saves where only some names +are unresolved. + +#### Scenario: Probe fails with invalid auth — blocks save, persists nothing + +- **GIVEN** Slack is enabled with a valid-format bot token and at least one channel name configured +- **WHEN** the save is attempted and the Slack probe returns `ErrorMessage = "invalid_auth"` (with `Success = false`) +- **THEN** the save returns false and `IsSaved` remains false +- **AND** the status message is `"Slack channel lookup failed: invalid_auth"` at `Error` tone +- **AND** the config file and secrets file are unchanged from before the save + +#### Scenario: Probe fails with missing scope — blocks save, persists nothing + +- **GIVEN** the Slack token lacks `channels:read` scope +- **WHEN** the Channels step save is attempted +- **THEN** an error status is shown with the scope failure reason +- **AND** the user cannot advance until the credential is corrected or they navigate back + +#### Scenario: Probe succeeds but one name does not resolve — saves with warning + +- **GIVEN** Slack has channels `"openclaw, fake-channel"` configured and the bot token is valid +- **WHEN** the probe resolves `"openclaw"` to `"C99"` and returns `"fake-channel"` in `Unresolved` (with `Success = false`, `ErrorMessage = null`) +- **THEN** the save returns true and `IsSaved` is true +- **AND** the status tone is `Warning` and the message identifies `#fake-channel` as unresolved +- **AND** the persisted `AllowedChannelIds` contains `["C99", "fake-channel"]` (resolved name replaced with its ID; unresolved name kept verbatim) +- **AND** the unresolved channel row is marked `IsUnresolved = true` in the channel permission list + +#### Scenario: Probe succeeds and all names resolve — saves cleanly + +- **GIVEN** all configured channel names resolve successfully +- **WHEN** the save is attempted +- **THEN** the save returns true at `Success` tone with no unresolved warning +- **AND** all channel names are rewritten to their canonical IDs before persistence + +#### Scenario: Network error reaching Slack API — blocks save + +- **GIVEN** the Slack API is unreachable and the probe surfaces a non-empty `ErrorMessage` +- **WHEN** the save is attempted +- **THEN** the save is blocked with the failure reason in the error status +- **AND** nothing is persisted + +--- + +## ADDED Requirements + +### Requirement: Single-entry resolve-before-add channel flow + +Adding a channel SHALL open a single free-text input (`ChannelsConfigScreen.AddChannel`) +where the operator types a channel name or ID. The typed entry is resolved +against the live adapter before it is added to the channel list. A non-resolving +entry SHALL be rejected at add time with an error status; the operator stays on +the add screen. A successfully resolved entry SHALL be added to the channel +list at the deployment-posture default audience, its row SHALL be focused in +the channel permission list, and the change SHALL be autosaved immediately. + +For Slack: if the typed value matches the canonical channel ID format +(`C…` or `G…` followed by uppercase alphanumerics), it is accepted directly +without a name-lookup probe call. If the typed value is a channel name, the +probe is called; a successful resolution returns the canonical ID. A +non-resolving name is rejected. + +Duplicate entries (where the resolved ID is already in the channel list) SHALL +be rejected with a status message indicating the channel is already configured. + +#### Scenario: Add channel by ID (Slack — skips probe) + +- **GIVEN** the operator is on the AddChannel screen for Slack +- **WHEN** the operator types `"C09"` (a valid Slack channel ID format) and confirms +- **THEN** no probe call is made +- **AND** `"C09"` is added to the channel list at the default audience +- **AND** the screen advances to `ChannelPermissions` with the new row focused +- **AND** the change is autosaved + +#### Scenario: Add channel by name (Slack — probe resolves to ID) + +- **GIVEN** the operator is on the AddChannel screen for Slack +- **WHEN** the operator types `"netclaw-support"` and confirms +- **THEN** the probe is called once with `["netclaw-support"]` and the bot token +- **AND** the probe returns resolved ID `"C09"` for that name +- **AND** `"C09"` is added at the default audience and the new row is focused +- **AND** the change is autosaved and `IsSaved` is true + +#### Scenario: Add channel by name — probe finds no match + +- **GIVEN** the operator types `"ghost"` on the AddChannel screen +- **WHEN** the probe returns `"ghost"` in `Unresolved` and `ErrorMessage` is null +- **THEN** the save does NOT occur and the screen stays on `AddChannel` +- **AND** the status shows `"Slack channel not found: #ghost"` at `Error` tone +- **AND** the channel list and persisted config are unchanged + +#### Scenario: Add channel already in list — rejected + +- **GIVEN** `"C01"` is already in the Slack channel list +- **WHEN** the operator types `"C01"` on the AddChannel screen and confirms +- **THEN** the channel is not duplicated +- **AND** the status message indicates `"C01 is already configured"` at `Error` tone + +#### Scenario: Escape from AddChannel screen discards draft + +- **GIVEN** the operator has typed a partial entry in the AddChannel input +- **WHEN** the operator presses Esc +- **THEN** the screen returns to `ChannelPermissions` +- **AND** no config or secrets files are modified + +### Requirement: Lazy Slack channel name-to-ID normalization on label refresh + +Stored Slack channel names SHALL be canonicalized to channel IDs lazily during the background label refresh. + +When the channel permission list is opened and a background label refresh is +triggered for Slack, the refresh SHALL detect any stored entries that are +channel names (not canonical IDs) that now resolve to a canonical ID and SHALL +rewrite them to their ID in-place. The rewritten entries and their audience +assignments SHALL be persisted immediately (without requiring a manual save) and +`IsSaved` SHALL be set to true. If all stored entries are already canonical IDs, +no write occurs. + +Security rationale: the runtime Slack ACL (`SlackAclPolicy`) matches +`AllowedChannelIds` against the Slack channel ID, not the channel name. A name +stored verbatim in the allow-list is inert and grants access to no channel. Once +the bot can see the channel, the normalization step makes the ACL effective +without operator intervention. + +Audience assignments travel with the ID rewrite: the audience keyed under the +old name is moved to the new canonical ID key, and the stale name key is +removed. + +#### Scenario: Background refresh normalizes stored name to ID and persists + +- **GIVEN** the config contains `AllowedChannelIds: ["C01", "netclaw-test"]` where `"netclaw-test"` is a name, not an ID +- **AND** the channel audience for `"netclaw-test"` is `"public"` +- **WHEN** the operator opens channel permissions and the background refresh runs +- **AND** the probe resolves `"netclaw-test"` to `"C99"` +- **THEN** the persisted `AllowedChannelIds` becomes `["C01", "C99"]` +- **AND** the audience for `"C99"` is `"public"` and the `"netclaw-test"` audience key is removed +- **AND** the channel row renders as `"#netclaw-test"` (display name from probe result) +- **AND** `IsSaved` is true without a manual save + +#### Scenario: Background refresh does not rewrite already-canonical IDs + +- **GIVEN** all entries in `AllowedChannelIds` are already canonical Slack channel IDs +- **WHEN** the background refresh completes successfully +- **THEN** the config file is not modified + +### Requirement: Credential blank-preserve on re-edit + +A blank credential field on re-edit SHALL preserve the existing stored secret rather than clearing it. + +When an operator re-edits a channel adapter's credentials (via the rotate +credentials screen) and leaves a secret field blank, the existing stored secret +for that field SHALL be preserved — the blank input SHALL NOT overwrite or clear +the persisted secret. Only a non-blank typed value replaces the existing secret. + +This applies to all adapter secret fields: Slack bot token, Slack app token, +Discord bot token, and Mattermost bot token. Non-secret fields (Mattermost +server URL, callback URL) are updated unconditionally from the typed value. + +The credential field display SHALL show a hint (`"configured - leave blank to +keep"`) for any field that has a persisted secret, so the operator knows the +current state without the secret value being shown. + +#### Scenario: Rotate credentials — blank field preserves existing secret + +- **GIVEN** Slack is configured with a persisted bot token `"xoxb-test"` and app token `"xapp-test"` +- **WHEN** the operator opens rotate credentials, types `"xoxb-new"` for the bot token, and leaves the app token field blank +- **AND** the operator confirms and saves +- **THEN** the persisted bot token is `"xoxb-new"` +- **AND** the persisted app token remains `"xapp-test"` (blank input did not clear it) + +#### Scenario: Rotate credentials — both fields blank keeps both existing secrets + +- **GIVEN** Slack has persisted bot and app tokens +- **WHEN** the operator opens rotate credentials and confirms without typing anything +- **AND** the operator saves +- **THEN** both existing tokens are preserved unchanged + +#### Scenario: Credential field hint shown for persisted secret + +- **GIVEN** a Slack bot token is already persisted for the adapter +- **WHEN** the operator opens the rotate credentials screen +- **THEN** the bot token field displays the hint `"configured - leave blank to keep"` +- **AND** the app token field displays the same hint if an app token is also persisted diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/feature-selection-wizard/spec.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/feature-selection-wizard/spec.md new file mode 100644 index 000000000..692d45733 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/feature-selection-wizard/spec.md @@ -0,0 +1,126 @@ +## MODIFIED Requirements + +### Requirement: Feature selection wizard step + +The init wizard SHALL present a Feature Selection step after the Security +Posture step for non-Personal deployment postures. The step SHALL display +toggleable deployment-wide feature switches with audience-appropriate defaults. +These switches control runtime enablement, not audience exposure. Audience +exposure remains governed by explicit tool/server allowlists. + +#### Scenario: Feature selection shown for Public posture + +- **GIVEN** the operator selected Public deployment posture +- **WHEN** the Security Posture step completes +- **THEN** the next step is Feature Selection +- **AND** features default to: memory off, search off, skills off, scheduling + off, subagents off, webhooks off + +#### Scenario: Feature selection shown for Team posture + +- **GIVEN** the operator selected Team deployment posture +- **WHEN** the Security Posture step completes +- **THEN** the next step is Feature Selection +- **AND** features default to: memory on, search on, skills on, scheduling on, + subagents on, webhooks on + +#### Scenario: Feature selection skipped for Personal posture + +- **GIVEN** the operator selected Personal posture +- **WHEN** the Security Posture step completes +- **THEN** the Feature Selection step is skipped +- **AND** the wizard writes no per-feature `Enabled` flags to the config +- **AND** the runtime treats absent `Enabled` flags as `true` (schema default), + so all features are effectively on without the wizard writing explicit values + +#### Scenario: Operator toggles features + +- **GIVEN** the Feature Selection step is displayed +- **WHEN** the operator presses Space on a feature row +- **THEN** the feature toggles between enabled and disabled +- **AND** pressing Enter advances to the next wizard step + +#### Scenario: Public search toggle does not implicitly allowlist Public search tools + +- **GIVEN** the operator selected Public deployment posture +- **AND** the operator enables Search in Feature Selection +- **WHEN** config is finalized +- **THEN** deployment-wide search runtime is enabled +- **BUT** `web_search` and `web_fetch` are still absent from Public sessions + unless the operator explicitly allowlists them for the Public audience + +### Requirement: Feature config Enabled flags + +The configuration schema SHALL include `Enabled` boolean properties for +Memory, Search, SkillSync, SubAgents, and Webhooks sections, plus a new top- +level `Scheduling` section whose only property is `Enabled`. The Feature +Selection wizard step SHALL write these flags to the config during +`ContributeConfig()` only when the step actually runs (i.e., for non-Personal +postures). For Personal posture, `ContributeConfig()` is never called and no +`Enabled` flags are written; the runtime defaults missing flags to `true`. + +These flags MAY be set during bootstrap and SHALL be editable post-install +through the `Enabled Features` leaf. The post-install editor and bootstrap +flow SHALL preserve config semantics for equivalent inputs; byte-identical +serialization is not required. + +#### Scenario: Disabled memory writes Enabled false + +- **GIVEN** the operator disabled memory in Feature Selection +- **WHEN** config is finalized +- **THEN** `Memory.Enabled` is `false` in `netclaw.json` + +#### Scenario: Disabled search writes Enabled false + +- **GIVEN** the operator disabled search in Feature Selection +- **WHEN** config is finalized +- **THEN** `Search.Enabled` is `false` in `netclaw.json` + +#### Scenario: Enabled Features writes deployment-wide flags + +- **GIVEN** the operator disables search in Enabled Features +- **WHEN** the editor saves +- **THEN** `Search.Enabled` is `false` in `netclaw.json` + +#### Scenario: Disabled scheduling writes top-level Scheduling.Enabled false + +- **GIVEN** the operator disabled scheduling in Feature Selection +- **WHEN** config is finalized +- **THEN** `Scheduling.Enabled` is `false` in `netclaw.json` +- **AND** `Scheduling` contains no other properties in this change + +#### Scenario: Personal posture omits Enabled flags from config + +- **GIVEN** the operator selected Personal posture (Feature Selection skipped) +- **WHEN** config is finalized +- **THEN** no per-feature `Enabled` flags are written to `netclaw.json` +- **AND** the runtime loads each absent flag as `true` via the default-true + fallback in `LoadEnabledFeatures`, making all features effectively enabled + +## ADDED Requirements + +### Requirement: Post-install posture change opens Enabled Features editor + +A non-Personal posture change applied in `netclaw config` SHALL open the Enabled Features editor. + +When the operator applies a non-Personal posture change in `netclaw config`, +the Security & Access view SHALL immediately transition to the Enabled Features +editor after saving the posture, so the operator can review and adjust +deployment-wide feature gates without a separate navigation step. + +#### Scenario: Non-Personal posture save transitions to Enabled Features + +- **WHEN** the operator saves a posture change to Team or Public posture in + `netclaw config -> Security & Access -> Security Posture` +- **THEN** the view transitions directly to the Enabled Features sub-editor + (`SecurityAccessEditorMode.Features`) +- **AND** the Enabled Features editor reflects the current on-disk feature + flag state (re-loaded from config after the posture save) + +#### Scenario: Personal posture save returns to Security & Access menu + +- **WHEN** the operator saves a posture change to Personal posture in + `netclaw config -> Security & Access -> Security Posture` +- **THEN** the view returns to the Security & Access menu + (`SecurityAccessEditorMode.Menu`) and does not open the Enabled Features + editor diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/inbound-webhooks/spec.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/inbound-webhooks/spec.md new file mode 100644 index 000000000..55607f2ed --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/inbound-webhooks/spec.md @@ -0,0 +1,78 @@ +# inbound-webhooks Delta Spec — Config UI Onboarding + +## Purpose + +Reconcile the shipped `InboundWebhooksConfigViewModel` against the existing +inbound-webhooks runtime spec. The existing runtime requirements are unchanged. +This file adds requirements that were implemented but not previously specified: +the `Webhooks.ExecutionTimeoutSeconds` top-level config field and the +non-blocking advisory emitted when the feature is enabled without any active +routes. + +## ADDED Requirements + +### Requirement: Execution timeout bounding webhook-triggered autonomous runs + +The top-level `netclaw.json` config SHALL support a `Webhooks.ExecutionTimeoutSeconds` +field that sets an upper bound (in seconds) on an inbound-webhook-triggered +autonomous run. The field MUST accept only integer values in the range 1–3600 +inclusive, and SHALL default to 300 when absent or unset. An out-of-range or +non-integer value SHALL be rejected before the config is persisted, and the UI +MUST surface the validation error without saving. + +#### Scenario: Valid timeout is accepted and persisted + +- **WHEN** an operator enters a whole-number timeout value between 1 and 3600 in + the inbound-webhooks config UI and saves +- **THEN** `Webhooks.ExecutionTimeoutSeconds` is written to `netclaw.json` with + the entered value +- **AND** the UI reports a success status + +#### Scenario: Out-of-range timeout is rejected before persistence + +- **WHEN** an operator enters a timeout value outside the range 1–3600 (e.g., 0 + or 9999) and attempts to save +- **THEN** the config file is not modified +- **AND** the UI surfaces an error message indicating the valid range + +#### Scenario: Non-integer timeout is rejected before persistence + +- **WHEN** an operator enters a non-integer string (e.g., `"fast"` or `"30.5"`) + in the execution-timeout field and attempts to save +- **THEN** the config file is not modified +- **AND** the UI surfaces an error message indicating that a whole number is + required + +#### Scenario: Missing timeout defaults to 300 on load + +- **GIVEN** `netclaw.json` does not contain `Webhooks.ExecutionTimeoutSeconds` +- **WHEN** the inbound-webhooks config UI loads +- **THEN** the timeout field is pre-populated with `300` + +### Requirement: Enable-without-routes emits non-blocking advisory + +Setting `Webhooks.Enabled = true` when no routes are enabled SHALL persist the +toggle and SHALL emit a non-blocking advisory directing the operator to author a +route with `netclaw webhooks set`. This MUST NOT block or fail the save: the +gateway fails closed per-route at runtime (returning `404 Not Found` for all +requests) until routes exist, so enabling without routes is the intended setup +order, not an error condition. + +#### Scenario: Enabling with no active routes persists toggle and shows advisory + +- **GIVEN** inbound webhooks are currently disabled +- **AND** no route files exist under `config/webhooks`, or all existing routes + are disabled or invalid +- **WHEN** an operator enables the feature and saves +- **THEN** `Webhooks.Enabled = true` is written to `netclaw.json` +- **AND** the UI displays a warning-tone advisory instructing the operator to add + a route with `netclaw webhooks set` +- **AND** the save succeeds (is not blocked or treated as an error) + +#### Scenario: Enabling with at least one active route shows success status + +- **GIVEN** at least one route file under `config/webhooks` is enabled and valid +- **WHEN** an operator enables the feature and saves +- **THEN** `Webhooks.Enabled = true` is written to `netclaw.json` +- **AND** the UI displays a success-tone status message +- **AND** no advisory is shown diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/netclaw-config-command/spec.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/netclaw-config-command/spec.md new file mode 100644 index 000000000..8384096cc --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/netclaw-config-command/spec.md @@ -0,0 +1,179 @@ +## MODIFIED Requirements + +### Requirement: Config command launches a domain-oriented dashboard + +`netclaw config` SHALL launch a domain-oriented settings dashboard. The +root SHALL be navigation-first and SHALL NOT be a flat list of every +registered leaf editor. + +The root SHALL include: + +- `Inference Providers` +- `Models` +- `Channels` +- `Inbound Webhooks` +- `Skill Sources` +- `Search` +- `Browser Automation` +- `Telemetry & Alerting` +- `Security & Access` +- `Workspaces Directory` + +#### Scenario: Root dashboard shows domain entries + +- **GIVEN** a configured install +- **WHEN** the operator runs `netclaw config` +- **THEN** the root dashboard opens with the documented domain entries +- **AND** `Workspaces Directory` appears as the tenth entry, after + `Security & Access` +- **AND** it does not render a flat dump of every registered leaf editor + +## ADDED Requirements + +### Requirement: Channels area supports Slack, Discord, and Mattermost adapters + +The `Channels` domain area SHALL support three channel adapters: Slack, +Discord, and Mattermost. Each adapter SHALL be independently enabled, +configured, and managed from the same Channels editor. + +#### Scenario: Mattermost adapter is available alongside Slack and Discord + +- **GIVEN** the operator opens the Channels config area +- **WHEN** the adapter list is rendered +- **THEN** Slack, Discord, and Mattermost each appear as configurable + adapter entries +- **AND** enabling Mattermost leads to credential entry (server URL and + bot token) followed by channel resolution + +### Requirement: Directory pickers use an interactive file-picker widget + +The Skill Sources local-folder add flow and the Workspaces Directory editor SHALL use an interactive directory picker. + +The Skill Sources "add a local folder" flow and the Workspaces Directory +editor SHALL present a Termina `FilePickerNode` directory picker instead +of a typed path field. The picker SHALL be scoped to directories only and +SHALL fill the content area. + +Selecting a directory in the picker SHALL save immediately +(autosave-on-selection) without requiring a separate confirm step. + +A `Ctrl+N` affordance SHALL be available throughout both pickers. When +activated, it SHALL open an inline naming overlay that lets the operator +name and create a new folder inside the currently focused picker +directory. On successful creation the folder SHALL be selectable +immediately without restarting the picker. On `Esc` the naming overlay +SHALL be dismissed and the picker SHALL remain active. + +#### Scenario: Selecting a directory in the Workspaces Directory picker saves immediately + +- **GIVEN** the operator opens the Workspaces Directory editor +- **WHEN** the operator navigates the picker and confirms a directory +- **THEN** the selected path is saved to `Workspaces.Directory` + immediately +- **AND** no separate save key is required + +#### Scenario: Ctrl+N creates a new folder from within the directory picker + +- **GIVEN** the operator is in a directory picker (Skill Sources or + Workspaces Directory) +- **WHEN** the operator presses `Ctrl+N`, enters a folder name, and + confirms with `Enter` +- **THEN** the folder is created inside the currently focused directory +- **AND** the naming overlay is dismissed +- **AND** the new folder is available for selection in the same picker + session + +#### Scenario: Esc cancels new-folder naming without affecting the picker + +- **GIVEN** the operator has opened the new-folder naming overlay via + `Ctrl+N` +- **WHEN** the operator presses `Esc` +- **THEN** the naming overlay is dismissed +- **AND** the directory picker remains active with no folder created + +### Requirement: Inbound Webhooks editor manages global enablement and execution timeout + +The Inbound Webhooks editor SHALL provide two editable settings: + +- A global `Enabled` boolean toggle that persists to + `Webhooks.Enabled`. +- An `ExecutionTimeoutSeconds` integer field (1–3600 seconds) that + persists to `Webhooks.ExecutionTimeoutSeconds`. + +Route authoring SHALL remain owned by the `netclaw webhooks` CLI +(`netclaw webhooks set|list|validate`). The editor SHALL NOT create, +edit, or delete route files. It SHALL display a live route summary +(total, enabled, disabled, invalid counts) so the operator can assess +configuration health without leaving the TUI. + +Enabling the global toggle with no valid routes present SHALL still +persist `Webhooks.Enabled = true`. The editor SHALL surface a +non-blocking advisory directing the operator to run `netclaw webhooks +set` to add routes; it SHALL NOT block the save or require routes to +exist before enabling. + +Saving SHALL be blocked only when `ExecutionTimeoutSeconds` contains a +structurally invalid value (non-integer, or outside 1–3600). + +#### Scenario: Toggling Enabled with no routes persists true and shows advisory + +- **GIVEN** the Inbound Webhooks editor is open +- **AND** no valid webhook routes exist +- **WHEN** the operator toggles `Enabled` to true and saves +- **THEN** `Webhooks.Enabled = true` is written to config +- **AND** a non-blocking advisory is shown instructing the operator to + add a route with `netclaw webhooks set` +- **AND** the save is not blocked + +#### Scenario: Invalid execution timeout blocks save + +- **GIVEN** the operator has entered a non-integer or out-of-range value + in the execution timeout field +- **WHEN** the operator saves +- **THEN** an error is shown describing the valid range +- **AND** no config file is modified + +#### Scenario: Route summary reflects current route state without editor ownership + +- **GIVEN** routes have been authored via `netclaw webhooks set` +- **WHEN** the operator opens the Inbound Webhooks editor +- **THEN** the summary row displays the current total, enabled, + disabled, and invalid route counts +- **AND** the editor offers no affordance to create or modify route + files directly + +### Requirement: Search editor uses progressive disclosure per backend + +The Search editor SHALL reveal only the configuration field relevant to +the selected backend: + +- Selecting `Brave` SHALL reveal the Brave API key field (stored in + `secrets.json`) and hide the SearXNG endpoint field. +- Selecting `SearXNG` SHALL reveal the SearXNG instance URL field + (stored in `netclaw.json`) and hide the Brave API key field. +- Selecting `DuckDuckGo` SHALL hide both backend-specific fields, as + DuckDuckGo requires no additional configuration. + +Fields for inactive backends SHALL NOT be rendered in the editor or +prompted for input. + +#### Scenario: Selecting Brave reveals only the Brave API key field + +- **GIVEN** the operator opens the Search editor +- **WHEN** the operator selects `Brave` as the backend +- **THEN** the Brave API key input field is shown +- **AND** the SearXNG endpoint field is not shown + +#### Scenario: Selecting SearXNG reveals only the SearXNG endpoint field + +- **GIVEN** the operator opens the Search editor +- **WHEN** the operator selects `SearXNG` as the backend +- **THEN** the SearXNG instance URL field is shown +- **AND** the Brave API key field is not shown + +#### Scenario: Selecting DuckDuckGo shows no backend-specific field + +- **GIVEN** the operator opens the Search editor +- **WHEN** the operator selects `DuckDuckGo` as the backend +- **THEN** no backend-specific credential or endpoint field is shown +- **AND** saving requires no further input diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/netclaw-onboarding/spec.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/netclaw-onboarding/spec.md new file mode 100644 index 000000000..2f005052c --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/netclaw-onboarding/spec.md @@ -0,0 +1,337 @@ +# netclaw-onboarding Delta Spec + +This is a **delta spec** — it records only the requirements that have been +added, modified, or removed relative to +`openspec/specs/netclaw-onboarding/spec.md`. Unchanged requirements are +omitted. Apply this delta on top of the canonical spec. + +--- + +## REMOVED Requirements + +### Requirement: Memory provider selection during onboarding + +**Reason**: The Memorizer-vs-local-files wizard step was a pre-build exploration +that shipped as neither. Memory is the always-on auto-memory system backed by +SQLite — no wizard step is needed or present. `HealthCheckStepViewModel` (via +`IdentityStepViewModel.ContributeHealthChecksAsync`) reports +"Memory backend (SQLite)" as a passing health-check item, confirming the fixed +backend. There is no runtime choice. + +**Migration**: No operator action is required. Memory is automatic and +SQLite-backed. Remote memory MCP, if ever wanted, is configured after install +via `netclaw config`. `TotalSteps` no longer appears in the spec for this +capability — see the MODIFIED "TUI wizard delivery mechanism" and "Guided +onboarding" requirements for the correct step count. + +--- + +### Requirement: Memorizer MCP connection configuration + +**Reason**: Memorizer connection configuration was a pre-build exploration that +was never implemented. The Memorizer MCP server entry +(`McpServers.memorizer`) is not written by the init wizard and the substep that +would collect transport/URL/command details does not exist. + +**Migration**: Operators who need a remote MCP memory server configure it after +install via `netclaw config → MCP Servers`. + +--- + +## MODIFIED Requirements + +### Requirement: Guided onboarding + +`netclaw init` SHALL provide bootstrap-first guided setup. The flow SHALL +collect provider configuration, identity, and security posture. Security +Posture, Enabled Features, and Audience Profiles are distinct concepts. + +If the operator selects `Personal`, the bootstrap flow SHALL skip Enabled +Features. + +If the operator selects `Team` or `Public`, the bootstrap flow SHALL +automatically continue into Enabled Features before final write. + +Audience Profiles editing SHALL NOT be part of init bootstrap; it belongs +to `netclaw config`. + +The wizard SHALL continue to write `SOUL.md` and `TOOLING.md`. Identity +remains init-owned in this branch. + +The bootstrap wizard SHALL consist of exactly **5 steps** in canonical order: +Provider → Identity → Security Posture → Enabled Features → Health Check. +`TotalSteps` is **5** for `Team`/`Public` postures and **4** for `Personal` +posture (Enabled Features is omitted). Step-progress indicators SHALL reflect +the dynamic count. + +#### Scenario: Personal posture skips enabled-features bootstrap step + +- **GIVEN** the operator selected `Personal` +- **WHEN** the posture step completes +- **THEN** init does not open an Enabled Features step +- **AND** the wizard proceeds directly to Health Check (step 4 of 4) + +#### Scenario: Team posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Team` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +#### Scenario: Public posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Public` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +--- + +### Requirement: TUI wizard delivery mechanism + +The `netclaw init` onboarding wizard SHALL be delivered through Termina TUI +as an interactive wizard with progress indication, validation, and +back-navigation. The wizard SHALL have **5 steps** for `Team`/`Public` posture +and **4 steps** for `Personal` posture. Step-progress indicators (e.g., +"Step 2 of 5" or "Step 2 of 4") SHALL reflect the dynamic total. There is no +fixed 9-step wizard. + +#### Scenario: Wizard renders in TUI + +- **WHEN** operator runs `netclaw init` +- **THEN** a Termina TUI application launches +- **AND** the wizard displays step progress (e.g., "Step 2 of 5") +- **AND** the wizard displays a progress bar + +#### Scenario: Step-specific components rendered + +- **GIVEN** the wizard is on a step requiring text input +- **WHEN** the step is displayed +- **THEN** the wizard renders TextInputNode components for text/secret fields +- **AND** renders SelectionListNode components for choice fields + +#### Scenario: Back navigation between steps + +- **GIVEN** the wizard is on step 3 +- **WHEN** the operator presses Esc +- **THEN** the wizard navigates back to step 2 +- **AND** previous input values are preserved + +#### Scenario: Live validation during wizard + +- **GIVEN** the wizard is on the Provider step +- **WHEN** the operator enters provider credentials +- **THEN** the wizard validates the credentials +- **AND** displays success or failure before allowing progression + +--- + +### Requirement: Phase 2 conversational personality bootstrap + +The system SHALL trigger a conversational personality bootstrap on the first +conversation if identity files (`SOUL.md`, `TOOLING.md`) do not already carry +operator-enriched content. The bootstrap is delivered as an initial chat message +injected by the init wizard's navigate callback when `LaunchChat()` fires. The +bootstrap message SHALL ask the operator about communication preferences, tone, +name preferences, and working style, then instruct the agent to update `SOUL.md` +with what it learns. `AGENTS.md` is loaded from embedded resources at runtime +and is NOT written to disk by the wizard. + +#### Scenario: First conversation triggers bootstrap + +- **GIVEN** the operator completed the init wizard successfully +- **WHEN** the health check step auto-launches chat via `LaunchChat()` +- **THEN** the agent receives a pre-filled onboarding trigger message +- **AND** the message instructs it to introduce itself, ask the operator about + their primary use case, ask about background and preferences, and then update + `SOUL.md` with the learned details + +#### Scenario: Bootstrap writes soul files + +- **GIVEN** the personality bootstrap conversation is complete +- **WHEN** the operator has answered the agent's preference questions +- **THEN** the agent updates `SOUL.md` in the config directory with what it + learned +- **AND** `TOOLING.md` is already in place from the init wizard's + `WriteIdentityFiles` call + +#### Scenario: Bootstrap skipped when files exist + +- **GIVEN** `SOUL.md` already exists in the config directory with enriched + content +- **WHEN** a new conversation starts +- **THEN** no personality bootstrap trigger is injected +- **AND** the existing `SOUL.md` is loaded normally + +--- + +### Requirement: Environment discovery during onboarding + +`netclaw init` SHALL NOT perform environment discovery in the shipped first-run flow (DEFERRED — unimplemented Phase 2 work). + +**[DEFERRED — not part of the shipped first-run flow.]** This requirement +describes planned Phase 2 behavior that has not been implemented. Environment +discovery does NOT run during `netclaw init` and is NOT triggered by the health +check step. When implemented, it SHALL be gated by an explicit PRD update and +SHALL NOT be silently enabled in the bootstrap wizard. + +The system SHALL scan for installed tools and host capabilities as part of Phase +2 onboarding. Discovery results SHALL be persisted to the environment inventory +file for use in session context and capability self-awareness. + +#### Scenario: Tool discovery during onboarding + +- **WHEN** Phase 2 onboarding runs environment discovery +- **THEN** the system scans for installed tools (git, gh, claude, opencode, + dotnet, node) +- **AND** checks git credential status +- **AND** writes results to the environment inventory file + +#### Scenario: MCP server reachability check during onboarding + +- **GIVEN** MCP servers are configured +- **WHEN** Phase 2 onboarding runs environment discovery +- **THEN** the system checks reachability of each configured MCP server +- **AND** records reachability status in the environment inventory + +--- + +### Requirement: Project registration during onboarding + +`netclaw init` SHALL NOT perform project registration in the shipped first-run flow (DEFERRED — unimplemented Phase 2 work). + +**[DEFERRED — not part of the shipped first-run flow.]** This requirement +describes planned Phase 2 behavior that has not been implemented. Project +registration does NOT occur during `netclaw init`. When implemented, it SHALL +be gated by an explicit PRD update. + +The system SHALL ask the operator about repositories to register as part of +Phase 2 onboarding. Registered projects are added to the project registry +with their paths, capabilities, and AGENTS.md locations. + +#### Scenario: Register projects during onboarding + +- **WHEN** Phase 2 onboarding reaches the project registration step +- **THEN** the system asks the operator about repositories to register +- **AND** scans provided paths for AGENTS.md files + +#### Scenario: Skip project registration + +- **WHEN** Phase 2 onboarding reaches the project registration step +- **AND** the operator indicates no projects to register +- **THEN** onboarding proceeds with an empty project registry + +--- + +## ADDED Requirements + +### Requirement: Identity step collects exactly four substeps + +The Identity wizard step SHALL collect exactly **4 substeps** in order: +agent name → communication style → operator name → timezone. `SubStepCount` +SHALL equal 4. The Identity step SHALL NOT collect a workspaces directory path +or a notification-webhook URL; those are post-install settings owned by +`netclaw config`. + +#### Scenario: Identity step has four substeps + +- **WHEN** the wizard enters the Identity step +- **THEN** `SubStepCount` equals 4 +- **AND** the substeps are agent name (0), communication style (1), operator + name (2), and timezone (3) + +#### Scenario: Workspaces directory not collected in init + +- **WHEN** the operator completes the Identity step +- **THEN** no workspaces directory is written to `netclaw.json` +- **AND** `WizardConfigBuilder.Workspaces` is null after `ContributeConfig` + +#### Scenario: Notification webhook not collected in init + +- **WHEN** the operator completes the Identity step +- **THEN** no notification webhook is written to `netclaw.json` +- **AND** `WizardConfigBuilder.Notifications` is null after `ContributeConfig` + +#### Scenario: Identity step prefills from existing config on re-entry + +- **GIVEN** `netclaw.json` exists with `Identity.AgentName`, `Identity.CommunicationStyle`, + `Identity.UserName`, and `Identity.UserTimezone` +- **WHEN** the operator re-enters the Identity step +- **THEN** all four non-secret fields are prefilled from the existing config + +--- + +### Requirement: Health check auto-launches chat on success + +The health check step SHALL launch `netclaw chat` automatically on a clean bootstrap. + +On a clean bootstrap (all health check probes passing), the health check step +SHALL invoke `LaunchChat()` automatically without requiring a second Enter +keypress. `LaunchChat()` SHALL route to `/chat` via the wired `Navigate` +delegate. On warnings or failure the step SHALL remain on the summary and exit +on Enter without routing to chat. + +#### Scenario: Clean bootstrap auto-launches chat + +- **GIVEN** all health-check probes passed +- **WHEN** `RunHealthCheckCoreAsync` completes +- **THEN** `LaunchChat()` is called automatically +- **AND** the Navigate delegate receives `"/chat"` +- **AND** `Succeeded` is `true` + +#### Scenario: Failed health check does not launch chat + +- **GIVEN** one or more health-check probes failed +- **WHEN** `RunHealthCheckCoreAsync` completes +- **THEN** `LaunchChat()` is NOT called +- **AND** the step displays the failure summary +- **AND** `Succeeded` is `false` + +#### Scenario: Failure summary status message + +- **GIVEN** the health check completed with at least one failure +- **WHEN** the operator views the summary +- **THEN** the status message reads: "Setup complete with warnings. Run + `netclaw daemon start`, then `netclaw chat`. Adjust settings with + `netclaw config`." + +--- + +### Requirement: Health check surfaces container-supervisor deferral reason on timeout + +A health-check failure SHALL surface the container-supervisor deferral reason when the supervised daemon never arrives. + +When the daemon is externally supervised (`NETCLAW_CONTAINER_SUPERVISOR` marker +set) but the supervisor never actually brings the daemon up within the readiness +poll window, the health-check failure item SHALL surface the actionable +container-supervisor deferral reason (including the hint that the marker may be +set without a supervisor present) rather than the generic "Daemon did not become +ready" message. When a startup-abort crash log is present, the failure message +SHALL include both the abort reason and the crash-log path. + +#### Scenario: Supervisor marker set but daemon never starts — surfaces deferral reason + +- **GIVEN** `NETCLAW_CONTAINER_SUPERVISOR` is set (i.e., `IsExternallySupervised` is `true`) +- **AND** no supervisor process actually starts the daemon (e.g., the image replaced + the entrypoint) +- **AND** no `DaemonApi` is wired (poll loop is skipped) +- **WHEN** `StartIfNeededAndPollAsync` times out +- **THEN** the failing health-check item label contains "container supervisor" +- **AND** contains "marker may be set without a supervisor present" +- **AND** does NOT contain "Daemon did not become ready" +- **AND** `Succeeded` is `false` + +#### Scenario: Startup-abort crash log surfaces specific failure message + +- **GIVEN** the daemon binary exits immediately (bad config or fatal startup error) +- **AND** a crash log exists in the logs directory containing + "Daemon startup aborted: …" +- **WHEN** `StartIfNeededAndPollAsync` detects the crash log +- **THEN** the failing health-check item label contains the specific abort reason +- **AND** contains the crash-log path +- **AND** does NOT contain "Daemon did not become ready" + +#### Scenario: Generic not-ready message is suppressed when a diagnostic is available + +- **GIVEN** either a crash log or a supervisor deferral reason is available +- **WHEN** the health-check step records the failure item +- **THEN** the generic "Daemon did not become ready" string is absent from the + failure label diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/security-posture-tui/spec.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/security-posture-tui/spec.md new file mode 100644 index 000000000..16ac835f0 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/specs/security-posture-tui/spec.md @@ -0,0 +1,147 @@ +# security-posture-tui Delta Spec + +Reconciles `openspec/specs/security-posture-tui/spec.md` to shipped code as of +the `simplify-netclaw-init` refactor. Two requirements are corrected; one new +post-install requirement is added. + +--- + +## MODIFIED Requirements + +### Requirement: Security posture selection step + +The wizard SHALL present an interactive step where the user selects a +deployment posture (Personal, Team, or Public) with explanatory text for +each option. + +#### Scenario: User selects Personal posture + +- **GIVEN** the wizard is at the SecurityPosture step +- **WHEN** the user selects "Personal" +- **THEN** deployment posture is set to Personal in WizardContext +- **AND** shell execution mode defaults to HostAllowed +- **AND** audience profiles are seeded with Personal-posture defaults + +#### Scenario: User selects Team posture + +- **GIVEN** the wizard is at the SecurityPosture step +- **WHEN** the user selects "Team" +- **THEN** deployment posture is set to Team in WizardContext +- **AND** shell execution mode defaults to Off +- **AND** audience profiles are seeded with Team-posture defaults + +#### Scenario: User selects Public posture + +- **GIVEN** the wizard is at the SecurityPosture step +- **WHEN** the user selects "Public" +- **THEN** deployment posture is set to Public in WizardContext +- **AND** shell execution mode defaults to Off +- **AND** audience profiles are seeded with Public-posture defaults + +> **Rationale:** The posture step writes `DeploymentPosture`, `ShellExecutionMode`, +> and `AudienceProfiles` into `WizardContext`. Channel and DM audience defaults are +> NOT applied here; they are derived from `WizardContext.SelectedPosture` by the +> channel-picker step (e.g. `SlackStepViewModel.OnLeave`) when it builds +> `ChannelEntry` records. Removing the old per-posture DM/channel assertions +> prevents false specification of where those values originate. + +--- + +### Requirement: Posture step position in wizard flow + +The SecurityPosture step SHALL appear after the Provider step and before the +Feature Selection step in the wizard flow. The Provider step combines LLM +provider selection and authentication/chat-service configuration; there is no +separate ChatServices step. For non-Personal postures, the Feature Selection +step SHALL appear immediately after SecurityPosture so that feature +availability is configured before channel audience assignment. + +#### Scenario: Step order with Feature Selection + +- **WHEN** the user completes the SecurityPosture step +- **AND** the selected posture is Team or Public +- **THEN** the next step is Feature Selection +- **AND** after Feature Selection, the next applicable step follows + +#### Scenario: Step order without Feature Selection + +- **WHEN** the user completes the SecurityPosture step +- **AND** the selected posture is Personal +- **THEN** the Feature Selection step is skipped +- **AND** the next applicable step follows directly + +> **Rationale:** `InitWizardViewModel` builds the step sequence as +> `Provider → Identity → SecurityPosture → FeatureSelection → HealthCheck`. +> The old spec named "ChatServices" as the preceding step, which no longer +> exists; chat-service auth is part of the Provider step. + +--- + +## ADDED Requirements + +### Requirement: Post-install posture cascade in netclaw config + +A posture change in `netclaw config` with customized audience profiles SHALL require a cascade confirmation before writing. + +When the operator changes the deployment posture via `netclaw config` and the +existing audience profiles have been customized (differ from the current +posture's defaults), the editor SHALL present a three-option cascade +confirmation before writing any changes: + +- **Cancel** — abort the posture change; leave posture and profiles untouched. +- **Apply new posture, overwrite profiles** — save the new posture and reset + all audience profiles to the new posture's defaults. +- **Apply new posture, keep custom profiles** — save the new posture and shell + defaults only; leave existing audience profile overrides in place. + +The editor MUST NOT apply the posture change without this confirmation when +profiles are customized. If profiles are at their posture defaults (not +customized), the editor SHALL apply the new posture directly without +presenting the cascade screen. + +#### Scenario: Posture change with customized profiles triggers cascade + +- **GIVEN** the operator opens `netclaw config` → Security → Security Posture +- **AND** the current audience profiles differ from the current posture's + defaults (i.e. `AudienceProfilesCustomized()` returns true) +- **WHEN** the operator selects a different posture and confirms +- **THEN** the editor transitions to the PostureCascade confirmation screen +- **AND** no config file changes are written yet + +#### Scenario: Cascade — cancel preserves existing state + +- **GIVEN** the PostureCascade screen is showing +- **WHEN** the operator selects "Cancel - keep current posture" +- **THEN** the pending posture is discarded +- **AND** the editor returns to the Posture selection screen +- **AND** the config file is unchanged + +#### Scenario: Cascade — overwrite applies posture and resets profiles + +- **GIVEN** the PostureCascade screen is showing +- **WHEN** the operator selects "Apply new posture, overwrite profiles" +- **THEN** the new posture and its shell execution mode are written to config +- **AND** all audience profiles are reset to the new posture's defaults +- **AND** the editor returns to the appropriate next screen + +#### Scenario: Cascade — keep custom applies posture only + +- **GIVEN** the PostureCascade screen is showing +- **WHEN** the operator selects "Apply new posture, keep custom profiles" +- **THEN** the new posture and its shell execution mode are written to config +- **AND** existing audience profile overrides are preserved unchanged + +#### Scenario: Posture change without customized profiles applies directly + +- **GIVEN** the operator opens `netclaw config` → Security → Security Posture +- **AND** the current audience profiles match the current posture's defaults +- **WHEN** the operator selects a different posture and confirms +- **THEN** the new posture is applied immediately (no cascade screen) +- **AND** audience profiles are reset to the new posture's defaults + +#### Scenario: Selecting the already-active posture is a no-op + +- **GIVEN** the operator opens the posture editor +- **WHEN** the operator selects the posture that is already active +- **THEN** no changes are written to the config file +- **AND** a status message informs the operator that the posture is already active diff --git a/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/tasks.md b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/tasks.md new file mode 100644 index 000000000..471ba2a30 --- /dev/null +++ b/openspec/changes/archive/2026-06-17-reconcile-config-onboarding-specs/tasks.md @@ -0,0 +1,37 @@ + + +## 1. Verify netclaw-onboarding deltas against shipped code + +- [x] 1.1 Confirm `InitWizardViewModel` builds the 5-step flow (Provider → Identity → Security Posture → Enabled Features → Health Check) and Personal posture skips Enabled Features (`FeatureSelectionStepViewModel.IsApplicable`); the spec's 5/4 dynamic step count matches. +- [x] 1.2 Confirm `IdentityStepViewModel.SubStepCount == 4` and `ContributeConfig` writes only AgentName/CommunicationStyle/UserName/UserTimezone (no workspaces directory, no notification webhook). +- [x] 1.3 Confirm `HealthCheckStepViewModel.RunHealthCheckCoreAsync` calls `LaunchChat()` on success with no Enter gate, and stays on the summary for warnings/failure — `HealthCheckStepViewModelTests` auto-launch assertions. +- [x] 1.4 Confirm the container-supervisor deferral reason is surfaced when the daemon is supervised-but-absent — `HealthCheckStepViewModelTests.RunWithOrchestrator_SupervisorMarkerSetButNoSupervisor_SurfacesActionableReason`. +- [x] 1.5 Confirm NO Memory/Memorizer wizard step exists and memory health is reported as SQLite (`IdentityStepViewModel.ContributeHealthChecksAsync` "Memory backend (SQLite)") — the REMOVED requirements correspond to no shipped code. +- [x] 1.6 Confirm the onboarding trigger updates `SOUL.md` (+ `TOOLING.md`) via `BuildOnboardingTrigger`/`WriteIdentityFiles`; `PERSONALITY.md`/`INSTRUCTIONS.md`/`USER.md` are not written. Confirm environment-discovery / project-registration remain unimplemented (DEFERRED). + +## 2. Verify channel-audience-tui deltas + +- [x] 2.1 Confirm `ChannelsConfigViewModel.ValidateSlackChannelsAsync` blocks only on a genuine probe failure (non-empty `ErrorMessage`) and persists unresolved channel names non-blockingly (inert in the ACL) — `ChannelsConfigViewModelTests`. +- [x] 2.2 Confirm `NormalizeSlackChannelNamesToIds` runs on the background label refresh and auto-persists; confirm `GetEffectiveSecret` blank-preserve on credential rotation. +- [x] 2.3 Confirm the add-channel flow is resolve-before-add single-entry (`BeginAddChannel`/`ApplyAddChannelAsync`/`ResolveSingleChannelAsync`) — no type-to-filter `conversations.list` search exists. + +## 3. Verify netclaw-config-command deltas + +- [x] 3.1 Confirm `ConfigDashboardViewModel.Items` lists `Workspaces Directory` as the 10th domain area. +- [x] 3.2 Confirm Skill Sources "add a local folder" and Workspaces use `FilePickerNode` directory pickers (`SkillSourcesConfigPage`/`WorkspacesConfigPage`): autosave on selection, Ctrl+N new folder. +- [x] 3.3 Confirm `InboundWebhooksConfigViewModel` enable + `ExecutionTimeoutSeconds` behavior and the no-routes advisory; route authoring stays CLI-owned (`netclaw webhooks set`). +- [x] 3.4 Confirm `SearchSectionSpec` progressive disclosure (backend selection reveals Brave/SearXNG field) and that Channels handles the Mattermost adapter. + +## 4. Verify minor deltas + +- [x] 4.1 security-posture-tui: confirm posture step ordering (after Provider; no ChatServices step), audience defaults applied by `SlackStepViewModel.OnLeave` from `WizardContext` posture, and the `SecurityAccessViewModel` posture cascade. +- [x] 4.2 feature-selection-wizard: confirm Personal posture omits Enabled flags (`FeatureSelectionStepViewModel.IsApplicable` + `LoadEnabledFeatures` default-true) and `SavePosture` auto-opens Enabled Features. +- [x] 4.3 inbound-webhooks: confirm `Webhooks.ExecutionTimeoutSeconds` (range 1–3600, default 300) and the no-routes advisory scenario. + +## 5. Validate and sync + +- [x] 5.1 `openspec validate reconcile-config-onboarding-specs --strict` passes (all deltas parse; MODIFIED headers match existing specs). +- [x] 5.2 `/opsx-verify` — confirm each delta still matches the cited code/tests. +- [x] 5.3 On merge with the implementation branch, `/opsx-sync` then `/opsx-archive` to fold the deltas into `openspec/specs/`. diff --git a/openspec/specs/channel-audience-tui/spec.md b/openspec/specs/channel-audience-tui/spec.md index 46e13ddb2..892829b4e 100644 --- a/openspec/specs/channel-audience-tui/spec.md +++ b/openspec/specs/channel-audience-tui/spec.md @@ -4,9 +4,7 @@ Define the interactive TUI step for per-channel audience assignment with dynamic channel add/remove and keyboard-driven audience cycling. - ## Requirements - ### Requirement: Channel list with audience cycling The wizard SHALL present a channel list where each row shows the channel @@ -33,26 +31,6 @@ value on the focused row. - **THEN** the wizard advances to the next step - **AND** the current audience assignments are preserved -### Requirement: Dynamic channel adding via Slack API - -The wizard SHALL allow adding channels by pressing `a`, which opens a -type-to-filter search populated from `conversations.list`. - -#### Scenario: Add channel by name search - -- **GIVEN** the user presses `a` on the channel list -- **WHEN** a text input appears and the user types "gen" -- **THEN** a filtered list shows channels matching "gen" (e.g., #general) -- **AND** pressing Enter on a match adds it to the channel list with the - posture default audience - -#### Scenario: Channel already in list - -- **GIVEN** #general is already in the channel list -- **WHEN** the user tries to add #general again -- **THEN** the channel is not duplicated -- **AND** a status message indicates it's already added - ### Requirement: Channel removal The wizard SHALL allow removing channels by pressing `d` on the focused row. @@ -96,27 +74,73 @@ ChatServices. No `ChannelAudiences` are written to config. ### Requirement: Block on API failure with actionable error -If `conversations.list` fails, the Channels step SHALL display an actionable -error message and block until the user resolves the issue. No silent fallback -to manual entry. +A channel save SHALL block only on a genuine probe failure, never on a merely-unresolved channel name. + +If a channel probe reports a **genuine failure** (invalid or expired token, +missing scope, network error, or any other condition that sets a non-empty +`ErrorMessage` on the resolution result), the save SHALL be blocked with an +actionable error message and no data SHALL be persisted. The user must fix the +credential or scope and retry before the save is accepted. + +If the probe call **succeeds** (no `ErrorMessage`) but one or more channel +names or IDs could not be resolved (the probe's `Unresolved` list is +non-empty), the save SHALL proceed: the entire adapter persists (token + all +channel entries, with resolved names rewritten to their canonical IDs and +unresolved entries kept verbatim). The unresolved entries are flagged +non-blockingly with a warning status message identifying each unresolved entry. + +Security invariant: an unresolved name or ID that persists verbatim in the +`AllowedChannelIds` list is inert — the runtime ACL matches against canonical +channel IDs, so an unresolved name grants access to no real channel. It is a +harmless placeholder until the bot can see the channel, at which point the +background label refresh will canonicalize it automatically. + +The distinction between a blocking failure and a non-blocking unresolved entry +is determined solely by the presence of a non-empty `ErrorMessage` on the +resolution result, NOT by the result's `Success` flag. `Success` is false +whenever any entry failed to resolve (including the non-blocking case), so +checking `Success` alone would incorrectly block saves where only some names +are unresolved. + +#### Scenario: Probe fails with invalid auth — blocks save, persists nothing -#### Scenario: conversations.list fails with missing scope +- **GIVEN** Slack is enabled with a valid-format bot token and at least one channel name configured +- **WHEN** the save is attempted and the Slack probe returns `ErrorMessage = "invalid_auth"` (with `Success = false`) +- **THEN** the save returns false and `IsSaved` remains false +- **AND** the status message is `"Slack channel lookup failed: invalid_auth"` at `Error` tone +- **AND** the config file and secrets file are unchanged from before the save -- **GIVEN** the Slack token is valid but lacks `channels:read` scope -- **WHEN** the Channels step loads -- **THEN** an error message is shown: "Failed to list channels: missing - channels:read scope. Add this scope to your Slack app and press Enter - to retry." -- **AND** the user cannot advance until the API call succeeds or they - press Esc to go back and re-enter credentials +#### Scenario: Probe fails with missing scope — blocks save, persists nothing -#### Scenario: conversations.list fails with network error +- **GIVEN** the Slack token lacks `channels:read` scope +- **WHEN** the Channels step save is attempted +- **THEN** an error status is shown with the scope failure reason +- **AND** the user cannot advance until the credential is corrected or they navigate back -- **GIVEN** the Slack API is unreachable -- **WHEN** the Channels step loads -- **THEN** an error message is shown with the failure reason -- **AND** Enter retries the API call -- **AND** Esc goes back to the previous step +#### Scenario: Probe succeeds but one name does not resolve — saves with warning + +- **GIVEN** Slack has channels `"openclaw, fake-channel"` configured and the bot token is valid +- **WHEN** the probe resolves `"openclaw"` to `"C99"` and returns `"fake-channel"` in `Unresolved` (with `Success = false`, `ErrorMessage = null`) +- **THEN** the save returns true and `IsSaved` is true +- **AND** the status tone is `Warning` and the message identifies `#fake-channel` as unresolved +- **AND** the persisted `AllowedChannelIds` contains `["C99", "fake-channel"]` (resolved name replaced with its ID; unresolved name kept verbatim) +- **AND** the unresolved channel row is marked `IsUnresolved = true` in the channel permission list + +#### Scenario: Probe succeeds and all names resolve — saves cleanly + +- **GIVEN** all configured channel names resolve successfully +- **WHEN** the save is attempted +- **THEN** the save returns true at `Success` tone with no unresolved warning +- **AND** all channel names are rewritten to their canonical IDs before persistence + +#### Scenario: Network error reaching Slack API — blocks save + +- **GIVEN** the Slack API is unreachable and the probe surfaces a non-empty `ErrorMessage` +- **WHEN** the save is attempted +- **THEN** the save is blocked with the failure reason in the error status +- **AND** nothing is persisted + +--- ### Requirement: Audience defaults from posture @@ -128,3 +152,141 @@ selected in the SecurityPosture step. Users can override per-channel. - **GIVEN** posture is Team - **WHEN** the user adds a new channel - **THEN** the new channel's audience defaults to Team + +### Requirement: Single-entry resolve-before-add channel flow + +Adding a channel SHALL open a single free-text input (`ChannelsConfigScreen.AddChannel`) +where the operator types a channel name or ID. The typed entry is resolved +against the live adapter before it is added to the channel list. A non-resolving +entry SHALL be rejected at add time with an error status; the operator stays on +the add screen. A successfully resolved entry SHALL be added to the channel +list at the deployment-posture default audience, its row SHALL be focused in +the channel permission list, and the change SHALL be autosaved immediately. + +For Slack: if the typed value matches the canonical channel ID format +(`C…` or `G…` followed by uppercase alphanumerics), it is accepted directly +without a name-lookup probe call. If the typed value is a channel name, the +probe is called; a successful resolution returns the canonical ID. A +non-resolving name is rejected. + +Duplicate entries (where the resolved ID is already in the channel list) SHALL +be rejected with a status message indicating the channel is already configured. + +#### Scenario: Add channel by ID (Slack — skips probe) + +- **GIVEN** the operator is on the AddChannel screen for Slack +- **WHEN** the operator types `"C09"` (a valid Slack channel ID format) and confirms +- **THEN** no probe call is made +- **AND** `"C09"` is added to the channel list at the default audience +- **AND** the screen advances to `ChannelPermissions` with the new row focused +- **AND** the change is autosaved + +#### Scenario: Add channel by name (Slack — probe resolves to ID) + +- **GIVEN** the operator is on the AddChannel screen for Slack +- **WHEN** the operator types `"netclaw-support"` and confirms +- **THEN** the probe is called once with `["netclaw-support"]` and the bot token +- **AND** the probe returns resolved ID `"C09"` for that name +- **AND** `"C09"` is added at the default audience and the new row is focused +- **AND** the change is autosaved and `IsSaved` is true + +#### Scenario: Add channel by name — probe finds no match + +- **GIVEN** the operator types `"ghost"` on the AddChannel screen +- **WHEN** the probe returns `"ghost"` in `Unresolved` and `ErrorMessage` is null +- **THEN** the save does NOT occur and the screen stays on `AddChannel` +- **AND** the status shows `"Slack channel not found: #ghost"` at `Error` tone +- **AND** the channel list and persisted config are unchanged + +#### Scenario: Add channel already in list — rejected + +- **GIVEN** `"C01"` is already in the Slack channel list +- **WHEN** the operator types `"C01"` on the AddChannel screen and confirms +- **THEN** the channel is not duplicated +- **AND** the status message indicates `"C01 is already configured"` at `Error` tone + +#### Scenario: Escape from AddChannel screen discards draft + +- **GIVEN** the operator has typed a partial entry in the AddChannel input +- **WHEN** the operator presses Esc +- **THEN** the screen returns to `ChannelPermissions` +- **AND** no config or secrets files are modified + +### Requirement: Lazy Slack channel name-to-ID normalization on label refresh + +Stored Slack channel names SHALL be canonicalized to channel IDs lazily during the background label refresh. + +When the channel permission list is opened and a background label refresh is +triggered for Slack, the refresh SHALL detect any stored entries that are +channel names (not canonical IDs) that now resolve to a canonical ID and SHALL +rewrite them to their ID in-place. The rewritten entries and their audience +assignments SHALL be persisted immediately (without requiring a manual save) and +`IsSaved` SHALL be set to true. If all stored entries are already canonical IDs, +no write occurs. + +Security rationale: the runtime Slack ACL (`SlackAclPolicy`) matches +`AllowedChannelIds` against the Slack channel ID, not the channel name. A name +stored verbatim in the allow-list is inert and grants access to no channel. Once +the bot can see the channel, the normalization step makes the ACL effective +without operator intervention. + +Audience assignments travel with the ID rewrite: the audience keyed under the +old name is moved to the new canonical ID key, and the stale name key is +removed. + +#### Scenario: Background refresh normalizes stored name to ID and persists + +- **GIVEN** the config contains `AllowedChannelIds: ["C01", "netclaw-test"]` where `"netclaw-test"` is a name, not an ID +- **AND** the channel audience for `"netclaw-test"` is `"public"` +- **WHEN** the operator opens channel permissions and the background refresh runs +- **AND** the probe resolves `"netclaw-test"` to `"C99"` +- **THEN** the persisted `AllowedChannelIds` becomes `["C01", "C99"]` +- **AND** the audience for `"C99"` is `"public"` and the `"netclaw-test"` audience key is removed +- **AND** the channel row renders as `"#netclaw-test"` (display name from probe result) +- **AND** `IsSaved` is true without a manual save + +#### Scenario: Background refresh does not rewrite already-canonical IDs + +- **GIVEN** all entries in `AllowedChannelIds` are already canonical Slack channel IDs +- **WHEN** the background refresh completes successfully +- **THEN** the config file is not modified + +### Requirement: Credential blank-preserve on re-edit + +A blank credential field on re-edit SHALL preserve the existing stored secret rather than clearing it. + +When an operator re-edits a channel adapter's credentials (via the rotate +credentials screen) and leaves a secret field blank, the existing stored secret +for that field SHALL be preserved — the blank input SHALL NOT overwrite or clear +the persisted secret. Only a non-blank typed value replaces the existing secret. + +This applies to all adapter secret fields: Slack bot token, Slack app token, +Discord bot token, and Mattermost bot token. Non-secret fields (Mattermost +server URL, callback URL) are updated unconditionally from the typed value. + +The credential field display SHALL show a hint (`"configured - leave blank to +keep"`) for any field that has a persisted secret, so the operator knows the +current state without the secret value being shown. + +#### Scenario: Rotate credentials — blank field preserves existing secret + +- **GIVEN** Slack is configured with a persisted bot token `"xoxb-test"` and app token `"xapp-test"` +- **WHEN** the operator opens rotate credentials, types `"xoxb-new"` for the bot token, and leaves the app token field blank +- **AND** the operator confirms and saves +- **THEN** the persisted bot token is `"xoxb-new"` +- **AND** the persisted app token remains `"xapp-test"` (blank input did not clear it) + +#### Scenario: Rotate credentials — both fields blank keeps both existing secrets + +- **GIVEN** Slack has persisted bot and app tokens +- **WHEN** the operator opens rotate credentials and confirms without typing anything +- **AND** the operator saves +- **THEN** both existing tokens are preserved unchanged + +#### Scenario: Credential field hint shown for persisted secret + +- **GIVEN** a Slack bot token is already persisted for the adapter +- **WHEN** the operator opens the rotate credentials screen +- **THEN** the bot token field displays the hint `"configured - leave blank to keep"` +- **AND** the app token field displays the same hint if an app token is also persisted + diff --git a/openspec/specs/config-tui-resilience/spec.md b/openspec/specs/config-tui-resilience/spec.md new file mode 100644 index 000000000..b75fff8bf --- /dev/null +++ b/openspec/specs/config-tui-resilience/spec.md @@ -0,0 +1,128 @@ +# config-tui-resilience Specification + +## Purpose +TBD - created by archiving change harden-config-tui-io-and-failloud. Update Purpose after archive. +## Requirements +### Requirement: Atomic config persistence + +Config, secrets, and the paired-device registry SHALL be written atomically — to a +sibling temporary file that is flushed and then renamed over the destination — so that an +interrupted or concurrent write can never leave a partially-written or corrupted file. + +#### Scenario: Interrupted write leaves the prior file intact + +- **WHEN** a config or `devices.json` write is interrupted (process kill, crash) part-way +- **THEN** the destination file still contains the last fully-written content, never a + truncated or partial document + +#### Scenario: All persistence paths use the shared atomic writer + +- **WHEN** any of the config editor, the wizard config builder, or the device-registry + writer persists to disk +- **THEN** it goes through the single shared atomic write helper, not a direct + non-atomic `File.WriteAllText` + +### Requirement: Serialized config writes + +The config TUI SHALL serialize disk writes for a given file so that a background task and +a user-triggered save can never write the same file concurrently. + +#### Scenario: Background refresh in flight during a save + +- **WHEN** a background channel-label refresh is in flight and the operator triggers a save +- **THEN** the background task is cancelled and awaited before the save writes to disk, so + the two writers never overlap + +### Requirement: Tracked, cancellable background tasks + +Config viewmodels SHALL track their background probe and refresh tasks (retaining the +`Task` handle and cancellation source) and cancel-and-await them before a save and on +dispose, rather than discarding them as fire-and-forget. + +#### Scenario: Dispose with a probe in flight + +- **WHEN** a config viewmodel is disposed while a background probe is still running +- **THEN** the probe is cancelled and its continuation performs no further state mutation + or disk write + +#### Scenario: Stale probe result cannot clobber reloaded state + +- **WHEN** a background probe completes after the viewmodel state has been reset by a save +- **THEN** the stale result is discarded rather than overwriting the freshly-loaded state + or being persisted + +### Requirement: Responsive event loop + +The config TUI SHALL NOT block the single-threaded event loop on asynchronous I/O — +network probes and disk operations run off the loop and there is no synchronous wait on +an async result from the input/render path. + +#### Scenario: Reachability probe keeps the UI responsive + +- **WHEN** a skill-feed or channel reachability probe runs +- **THEN** the input loop continues to process keystrokes and render while the probe is in + flight, rather than freezing until it completes + +### Requirement: Fail-loud config parsing on render and autosave paths + +Config parse and read operations invoked from a render or autosave path SHALL surface a +status message and remain usable, never throw an unhandled exception into the event loop. + +#### Scenario: Dashboard renders against a malformed config + +- **WHEN** the config dashboard renders and a section of the config is malformed +- **THEN** the affected summary shows an error indicator and the dashboard stays usable, + instead of the render crashing the TUI + +#### Scenario: Parse failure does not wedge the wizard + +- **WHEN** an unexpected exception occurs during a wizard health-check or config write +- **THEN** the wizard reports the failure and remains interactive, rather than being left + permanently in a running/incomplete state + +### Requirement: Deny-by-default on unparseable security values + +The editor SHALL deny by default when a security-relevant config value cannot be parsed or +has an unrecognized shape — treating it as the most-restrictive interpretation (disabled / +no-grant) and warning the operator — and MUST NOT silently assume a permissive default. + +#### Scenario: Unparseable deployment posture + +- **WHEN** the persisted deployment posture cannot be parsed +- **THEN** the editor surfaces an error rather than silently assuming the `Personal` + posture + +#### Scenario: Unrecognized server-enabled shape + +- **WHEN** a server entry's enabled flag has an unrecognized JSON shape +- **THEN** the server is treated as disabled, not enabled + +### Requirement: Persist secrets only after validation + +A credential entered in a config editor SHALL be persisted to disk only after its +validating probe succeeds; a failed probe MUST leave any previously stored secret +unchanged. + +#### Scenario: Fix-credentials probe fails + +- **WHEN** the operator submits a new credential and its probe fails +- **THEN** the new secret is not written to disk and the prior credential is preserved + +### Requirement: Audience changes are never silently lost + +An in-place change to a channel or DM audience — which sets the ACL trust tier — SHALL be +persisted immediately like every other editor mutation, and MUST NOT be silently discarded +when the operator navigates away. + +#### Scenario: Cycle a channel audience and navigate back + +- **WHEN** the operator cycles a channel's audience with the arrow keys and then navigates + out of the screen +- **THEN** the new audience is persisted to config rather than reverting on the next load + +#### Scenario: Unresolved channel name is inert, not a wrong ACL key + +- **WHEN** a channel cannot be resolved to an ID during save +- **THEN** the unresolved name is not written as an ACL key that the runtime cannot match; + it is omitted or flagged so it grants nothing + diff --git a/openspec/specs/feature-selection-wizard/spec.md b/openspec/specs/feature-selection-wizard/spec.md index fa31614f5..8bcefb695 100644 --- a/openspec/specs/feature-selection-wizard/spec.md +++ b/openspec/specs/feature-selection-wizard/spec.md @@ -2,9 +2,7 @@ Define the bootstrap and post-install behavior of deployment-wide runtime feature enablement, separate from posture and per-audience access policy. - ## Requirements - ### Requirement: Feature selection wizard step The init wizard SHALL present a Feature Selection step after the Security @@ -34,7 +32,9 @@ exposure remains governed by explicit tool/server allowlists. - **GIVEN** the operator selected Personal posture - **WHEN** the Security Posture step completes - **THEN** the Feature Selection step is skipped -- **AND** all features are enabled by default +- **AND** the wizard writes no per-feature `Enabled` flags to the config +- **AND** the runtime treats absent `Enabled` flags as `true` (schema default), + so all features are effectively on without the wizard writing explicit values #### Scenario: Operator toggles features @@ -58,7 +58,9 @@ The configuration schema SHALL include `Enabled` boolean properties for Memory, Search, SkillSync, SubAgents, and Webhooks sections, plus a new top- level `Scheduling` section whose only property is `Enabled`. The Feature Selection wizard step SHALL write these flags to the config during -`ContributeConfig()`. +`ContributeConfig()` only when the step actually runs (i.e., for non-Personal +postures). For Personal posture, `ContributeConfig()` is never called and no +`Enabled` flags are written; the runtime defaults missing flags to `true`. These flags MAY be set during bootstrap and SHALL be editable post-install through the `Enabled Features` leaf. The post-install editor and bootstrap @@ -90,11 +92,13 @@ serialization is not required. - **THEN** `Scheduling.Enabled` is `false` in `netclaw.json` - **AND** `Scheduling` contains no other properties in this change -#### Scenario: Personal posture default keeps all features enabled +#### Scenario: Personal posture omits Enabled flags from config - **GIVEN** the operator selected Personal posture (Feature Selection skipped) - **WHEN** config is finalized -- **THEN** all `Enabled` flags default to `true` +- **THEN** no per-feature `Enabled` flags are written to `netclaw.json` +- **AND** the runtime loads each absent flag as `true` via the default-true + fallback in `LoadEnabledFeatures`, making all features effectively enabled ### Requirement: Post-install runtime feature editing moves to Enabled Features @@ -114,7 +118,6 @@ own per-audience runtime feature toggles. - **THEN** the change is made in `Enabled Features` - **AND** Audience Profiles is not used for that runtime toggle - ### Requirement: Feature flags respected at runtime Runtime subsystems SHALL check their respective `Enabled` config flag before @@ -143,3 +146,30 @@ profiles still control which audiences may discover or use it. - **WHEN** a Public session starts - **THEN** search runtime may exist for the deployment - **BUT** `web_search` and `web_fetch` are not exposed to that session + +### Requirement: Post-install posture change opens Enabled Features editor + +A non-Personal posture change applied in `netclaw config` SHALL open the Enabled Features editor. + +When the operator applies a non-Personal posture change in `netclaw config`, +the Security & Access view SHALL immediately transition to the Enabled Features +editor after saving the posture, so the operator can review and adjust +deployment-wide feature gates without a separate navigation step. + +#### Scenario: Non-Personal posture save transitions to Enabled Features + +- **WHEN** the operator saves a posture change to Team or Public posture in + `netclaw config -> Security & Access -> Security Posture` +- **THEN** the view transitions directly to the Enabled Features sub-editor + (`SecurityAccessEditorMode.Features`) +- **AND** the Enabled Features editor reflects the current on-disk feature + flag state (re-loaded from config after the posture save) + +#### Scenario: Personal posture save returns to Security & Access menu + +- **WHEN** the operator saves a posture change to Personal posture in + `netclaw config -> Security & Access -> Security Posture` +- **THEN** the view returns to the Security & Access menu + (`SecurityAccessEditorMode.Menu`) and does not open the Enabled Features + editor + diff --git a/openspec/specs/inbound-webhooks/spec.md b/openspec/specs/inbound-webhooks/spec.md index c3433ba71..670eaf284 100644 --- a/openspec/specs/inbound-webhooks/spec.md +++ b/openspec/specs/inbound-webhooks/spec.md @@ -5,9 +5,7 @@ Define config-driven inbound webhook routes, verified delivery handling, autonomous session launch, prompt overlay injection, operational receipt alerts, and reminder-style human notification behavior. - ## Requirements - ### Requirement: Named webhook routes The daemon SHALL expose named inbound webhook routes from one JSON file per @@ -305,3 +303,70 @@ never emitted in production. - **WHEN** the agent completes its turn without invoking any notification tool - **THEN** the webhook execution is marked failed with the "no notification tool was invoked" reason + +### Requirement: Execution timeout bounding webhook-triggered autonomous runs + +The top-level `netclaw.json` config SHALL support a `Webhooks.ExecutionTimeoutSeconds` +field that sets an upper bound (in seconds) on an inbound-webhook-triggered +autonomous run. The field MUST accept only integer values in the range 1–3600 +inclusive, and SHALL default to 300 when absent or unset. An out-of-range or +non-integer value SHALL be rejected before the config is persisted, and the UI +MUST surface the validation error without saving. + +#### Scenario: Valid timeout is accepted and persisted + +- **WHEN** an operator enters a whole-number timeout value between 1 and 3600 in + the inbound-webhooks config UI and saves +- **THEN** `Webhooks.ExecutionTimeoutSeconds` is written to `netclaw.json` with + the entered value +- **AND** the UI reports a success status + +#### Scenario: Out-of-range timeout is rejected before persistence + +- **WHEN** an operator enters a timeout value outside the range 1–3600 (e.g., 0 + or 9999) and attempts to save +- **THEN** the config file is not modified +- **AND** the UI surfaces an error message indicating the valid range + +#### Scenario: Non-integer timeout is rejected before persistence + +- **WHEN** an operator enters a non-integer string (e.g., `"fast"` or `"30.5"`) + in the execution-timeout field and attempts to save +- **THEN** the config file is not modified +- **AND** the UI surfaces an error message indicating that a whole number is + required + +#### Scenario: Missing timeout defaults to 300 on load + +- **GIVEN** `netclaw.json` does not contain `Webhooks.ExecutionTimeoutSeconds` +- **WHEN** the inbound-webhooks config UI loads +- **THEN** the timeout field is pre-populated with `300` + +### Requirement: Enable-without-routes emits non-blocking advisory + +Setting `Webhooks.Enabled = true` when no routes are enabled SHALL persist the +toggle and SHALL emit a non-blocking advisory directing the operator to author a +route with `netclaw webhooks set`. This MUST NOT block or fail the save: the +gateway fails closed per-route at runtime (returning `404 Not Found` for all +requests) until routes exist, so enabling without routes is the intended setup +order, not an error condition. + +#### Scenario: Enabling with no active routes persists toggle and shows advisory + +- **GIVEN** inbound webhooks are currently disabled +- **AND** no route files exist under `config/webhooks`, or all existing routes + are disabled or invalid +- **WHEN** an operator enables the feature and saves +- **THEN** `Webhooks.Enabled = true` is written to `netclaw.json` +- **AND** the UI displays a warning-tone advisory instructing the operator to add + a route with `netclaw webhooks set` +- **AND** the save succeeds (is not blocked or treated as an error) + +#### Scenario: Enabling with at least one active route shows success status + +- **GIVEN** at least one route file under `config/webhooks` is enabled and valid +- **WHEN** an operator enables the feature and saves +- **THEN** `Webhooks.Enabled = true` is written to `netclaw.json` +- **AND** the UI displays a success-tone status message +- **AND** no advisory is shown + diff --git a/openspec/specs/netclaw-cli/spec.md b/openspec/specs/netclaw-cli/spec.md index 40bf49cc3..802ea9659 100644 --- a/openspec/specs/netclaw-cli/spec.md +++ b/openspec/specs/netclaw-cli/spec.md @@ -1,37 +1,50 @@ ## Purpose Define operator-facing CLI surface area for Netclaw: the `netclaw init` wizard, -the `netclaw doctor` diagnostic, and the `netclaw approvals` command for -managing persistent tool approvals. +the `netclaw doctor` diagnostic, the `netclaw config` settings surface, and the +`netclaw approvals` command for managing persistent tool approvals. ## Requirements -### Requirement: Init wizard approval mode selection - -The `netclaw init` wizard SHALL ask about shell approval mode when configuring -each audience profile that has shell access enabled. The wizard SHALL present -three options: Approval (recommended default), Unrestricted (HostAllowed with -no approval), and Off (shell disabled). The selected mode SHALL be written to -the audience profile's `ApprovalPolicy` in `netclaw.json`. For Personal, -selecting Approval SHALL explicitly write -`Tools.AudienceProfiles.Personal.ApprovalPolicy.ToolOverrides.shell_execute = "approval"` -rather than relying on runtime audience defaults. +### Requirement: Config command surface + +The CLI SHALL expose `netclaw config` as a top-level command. The command +SHALL operate on local config files and SHALL behave per the +`netclaw-config-command` capability. + +If no config exists, `netclaw config` SHALL print a plain message directing +the operator to `netclaw init` and exit non-zero without launching Termina. + +#### Scenario: Help text describes config as post-install settings surface + +- **WHEN** the operator runs `netclaw config --help` +- **THEN** the command exits zero +- **AND** help text describes `netclaw config` as the main post-install + settings surface +- **AND** help text references `netclaw init` as the bootstrap companion -#### Scenario: Init wizard prompts for Personal shell mode +#### Scenario: No-args invocation launches dashboard on configured install -- **GIVEN** the user is running `netclaw init` -- **WHEN** the wizard configures the Personal audience profile -- **AND** shell mode is not Off -- **THEN** the wizard asks: "Shell approval mode for Personal?" -- **AND** offers Approval (default), Unrestricted, and Off +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw config` +- **THEN** the domain-oriented dashboard launches -#### Scenario: Init wizard skips approval for audiences with shell off +#### Scenario: Missing install refuses with plain message -- **GIVEN** the user is running `netclaw init` -- **WHEN** the wizard configures an audience with shell mode Off -- **THEN** the wizard does NOT ask about approval mode for that audience +- **GIVEN** `netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** stderr contains ``No configuration found. Run `netclaw init` first.`` +- **AND** the command exits non-zero +- **AND** no partial TUI starts -#### Scenario: Selection written to config +### Requirement: Personal shell approval defaults are explicit -- **GIVEN** the user selects "Approval" for Personal audience +When bootstrap selects `Personal` posture, the written config SHALL make the +recommended shell approval default explicit by writing +`Tools.AudienceProfiles.Personal.ApprovalPolicy.ToolOverrides.shell_execute = "approval"` +rather than relying on runtime-only implicit defaults. + +#### Scenario: Personal bootstrap writes explicit shell approval default + +- **GIVEN** the operator completes `netclaw init` with `Personal` posture - **WHEN** the wizard writes the config - **THEN** `netclaw.json` includes `Tools.AudienceProfiles.Personal.ApprovalPolicy.ToolOverrides.shell_execute = "approval"` @@ -278,7 +291,6 @@ SHALL remain a superset of the previous shape: existing `verb` and - **WHEN** the approvals list page renders - **THEN** each row shows the grant's relative creation time alongside its scope label - ### Requirement: CLI derives local control-plane endpoint from daemon bind config When no explicit daemon endpoint override exists, the CLI SHALL derive a usable local control-plane endpoint from `Daemon.Host` and `Daemon.Port` in daemon configuration instead of always falling back to `http://127.0.0.1:5199`. @@ -330,4 +342,3 @@ The daemon-host CLI SHALL decide whether to attach a bearer token based on wheth - **AND** daemon config exposure mode is `local` - **WHEN** the CLI builds its daemon connection - **THEN** it does not attach a bearer token by default - diff --git a/openspec/specs/netclaw-config-command/spec.md b/openspec/specs/netclaw-config-command/spec.md new file mode 100644 index 000000000..e726192e5 --- /dev/null +++ b/openspec/specs/netclaw-config-command/spec.md @@ -0,0 +1,501 @@ +## Purpose + +Define the post-install `netclaw config` dashboard, its domain-oriented +navigation model, and the rules for how configuration editing routes or saves. +## Requirements +### Requirement: Config command launches a domain-oriented dashboard + +`netclaw config` SHALL launch a domain-oriented settings dashboard. The +root SHALL be navigation-first and SHALL NOT be a flat list of every +registered leaf editor. + +The root SHALL include: + +- `Inference Providers` +- `Models` +- `Channels` +- `Inbound Webhooks` +- `Skill Sources` +- `Search` +- `Browser Automation` +- `Telemetry & Alerting` +- `Security & Access` +- `Workspaces Directory` + +#### Scenario: Root dashboard shows domain entries + +- **GIVEN** a configured install +- **WHEN** the operator runs `netclaw config` +- **THEN** the root dashboard opens with the documented domain entries +- **AND** `Workspaces Directory` appears as the tenth entry, after + `Security & Access` +- **AND** it does not render a flat dump of every registered leaf editor + +### Requirement: Missing install refuses before TUI startup + +`netclaw config` SHALL detect a missing install/config before starting the +TUI. It SHALL print ``No configuration found. Run `netclaw init` first.`` +to stderr and exit non-zero. + +#### Scenario: No install refusal renders no TUI + +- **GIVEN** `~/.netclaw/config/netclaw.json` does not exist +- **WHEN** the operator runs `netclaw config` +- **THEN** the command prints the refusal message to stderr +- **AND** exits non-zero +- **AND** no partial TUI is rendered + +### Requirement: Routed handoffs are first-class config outcomes + +The config dashboard SHALL allow specific domain entries to route into +existing commands instead of re-hosting the full editor inline. In this +branch, `Inference Providers` SHALL route to `netclaw provider` and +`Models` SHALL route to `netclaw model`. + +#### Scenario: Inference Providers routes to provider command + +- **GIVEN** the operator selects `Inference Providers` +- **WHEN** the handoff is activated +- **THEN** the flow routes to `netclaw provider` +- **AND** no config-dashboard back-stack refactor is required + +### Requirement: Security & Access separates posture, features, profiles, and exposure + +The `Security & Access` area SHALL contain separate entries for Security +Posture, Enabled Features, Audience Profiles, and Exposure Mode. + +Security Posture, Enabled Features, and Audience Profiles SHALL remain +distinct concepts: + +- Security Posture selects the deployment stance. +- Enabled Features controls deployment-wide runtime enablement. +- Audience Profiles edits curated per-audience high-level access rules. + +#### Scenario: Team posture continues into enabled-features flow + +- **GIVEN** the operator changes Security Posture to `Team` +- **WHEN** the posture change flow completes +- **THEN** the config flow continues into Enabled Features + +#### Scenario: Personal posture skips enabled-features continuation + +- **GIVEN** the operator changes Security Posture to `Personal` +- **WHEN** the posture change flow completes +- **THEN** the config flow does not force an Enabled Features continuation + +### Requirement: Audience Profiles is curated and excludes MCP editing + +The Audience Profiles editor SHALL be a curated high-level editor. It SHALL +focus on: + +- Tool Access (non-MCP) +- File Access +- Incoming Attachments +- Reset to posture default + +It SHALL NOT expose: + +- per-audience runtime feature toggles +- per-audience shell mode +- MCP grants/access editing +- raw approval-policy editing + +MCP access/grants/approval editing SHALL route to `netclaw mcp permissions`. + +#### Scenario: Audience Profiles omits per-audience feature toggles + +- **WHEN** the operator opens Audience Profiles +- **THEN** the UI does not offer per-audience runtime feature toggles +- **AND** runtime enablement remains owned by Enabled Features + +#### Scenario: Reset to posture default resets full underlying profile + +- **GIVEN** an audience has customized visible settings and hidden MCP or + approval settings +- **WHEN** the operator activates `Reset to posture default` +- **THEN** the full underlying audience profile is reset to posture + defaults +- **AND** hidden MCP and approval settings for that audience are reset as + well + +### Requirement: Exposure Mode preserves current config shape + +The Exposure Mode editor SHALL keep the existing `Daemon` config shape. It +SHALL use `Daemon.ExposureMode` as the single active selector and SHALL NOT +introduce per-mode active flags. + +Supported explicit modes are: + +- `Local` +- `Reverse Proxy` +- `Tailscale Serve` +- `Tailscale Funnel` +- `Cloudflare Tunnel` + +Each non-local mode SHALL use its own mode-specific dialog. `Local` +requires no extra setup. Inactive old values SHALL be preserved and ignored +when inactive. + +#### Scenario: Switching modes preserves inactive values + +- **GIVEN** the config contains previously saved Cloudflare Tunnel values +- **AND** `Daemon.ExposureMode` is currently `Reverse Proxy` +- **WHEN** the operator edits Reverse Proxy settings and saves +- **THEN** the inactive Cloudflare values remain preserved in config +- **AND** the active mode remains determined only by `Daemon.ExposureMode` + +### Requirement: First non-local exposure enablement may bootstrap pairing + +The flow SHALL auto-pair the current configuring client when the operator +first enables a non-local exposure mode from `netclaw config` and no +bootstrap/pairing state exists. + +If bootstrap state is orphaned or mismatched, the flow SHALL block and +direct the operator to `netclaw doctor`, formal docs, and issue `#875`. + +#### Scenario: Missing bootstrap state auto-pairs current client + +- **GIVEN** the operator enables `Tailscale Serve` +- **AND** no bootstrap or pairing state exists yet +- **WHEN** the save flow runs +- **THEN** the current configuring client is auto-paired before the mode is + finalized + +#### Scenario: Orphaned bootstrap state blocks save + +- **GIVEN** the operator enables a non-local exposure mode +- **AND** existing bootstrap state is orphaned or mismatched +- **WHEN** the save flow validates exposure setup +- **THEN** the save is blocked +- **AND** the operator is directed to `netclaw doctor`, formal docs, and + issue `#875` + +### Requirement: Leaf validation is generalized + +Every config leaf editor SHALL validate what it edits before save. +Validation SHALL cover local structural validity and any relevant probes +such as paths, URIs, auth, binary presence, or remote reachability. + +Structurally invalid config SHALL block save without override. +Runtime/probe failures MAY present `Save anyway`. + +#### Scenario: Structural error blocks save with no override + +- **GIVEN** a leaf editor contains an invalid URI or malformed config + reference +- **WHEN** the operator saves +- **THEN** save is blocked +- **AND** no `Save anyway` affordance is shown + +#### Scenario: Probe failure offers Save anyway + +- **GIVEN** a leaf editor is structurally valid +- **AND** a remote reachability or runtime probe fails +- **WHEN** the operator saves +- **THEN** the editor may show `Save anyway` +- **AND** the operator can choose to persist the structurally valid config + +### Requirement: Inline config editors autosave completed actions consistently + +Every inline `netclaw config` leaf editor SHALL use a shared autosave +interaction contract. The UI SHALL NOT require an explicit save key for +ordinary config edits. + +Completed actions SHALL save immediately after validation. Completed actions +include accepted text or multi-field forms, toggles, audience changes, +enable/disable actions, add/remove actions, and confirmed reset actions. +Incomplete text input SHALL remain an in-memory draft until accepted with +`Enter` or an equivalent Apply action. + +`Esc` SHALL only navigate back or cancel incomplete input. It SHALL NOT save +pending edits and SHALL NOT be required to complete a save. + +All autosaves SHALL be atomic: validation SHALL complete before files are +written, and failed validation SHALL leave persisted config and secrets +unchanged. + +#### Scenario: Completed toggle autosaves immediately + +- **GIVEN** an inline config leaf editor contains a boolean toggle +- **WHEN** the operator toggles the setting +- **THEN** the editor validates the resulting state +- **AND** persists the change immediately when validation succeeds +- **AND** shows a saved status without asking the operator to press a save key + +#### Scenario: Esc cancels draft text without persisting + +- **GIVEN** an inline config leaf editor contains a text field +- **AND** the operator has typed a draft value but has not accepted it +- **WHEN** the operator presses `Esc` +- **THEN** the editor navigates back or cancels the draft +- **AND** the persisted config is unchanged + +#### Scenario: Invalid completed action writes nothing + +- **GIVEN** an inline config leaf editor contains a structurally invalid draft +- **WHEN** the operator accepts the action +- **THEN** validation fails +- **AND** no config or secrets file is modified +- **AND** the UI shows the validation error + +### Requirement: Inline config persistence is section-preserving + +Inline config leaf editors SHALL persist only the sections, providers, +fields, and sidecar files they own. Saving one provider or sub-area SHALL NOT +delete or reset unrelated providers, inactive values, secrets, audiences, or +sidecar files. + +Disable actions SHALL preserve dormant configuration and secrets while writing +only the runtime-enabled flag. Destructive removal SHALL require an explicit +reset/confirm action and SHALL be scoped to the confirmed target. + +#### Scenario: Disabling one channel provider preserves its dormant setup + +- **GIVEN** Slack has saved channels, audiences, allowed users, and secrets +- **WHEN** the operator disables Slack from the Channels config area +- **THEN** Slack `Enabled` is persisted as `false` +- **AND** Slack channels, audiences, allowed users, and secrets remain + persisted + +#### Scenario: Saving one channel provider does not wipe another provider + +- **GIVEN** Slack and Discord both have saved channel configuration +- **WHEN** the operator adds a Discord channel and the action autosaves +- **THEN** the Discord addition is persisted +- **AND** the saved Slack configuration remains present and unchanged except + for any explicit Slack action the operator completed + +#### Scenario: Reset is the only provider-destructive action + +- **GIVEN** a provider has saved channel configuration and secrets +- **WHEN** the operator confirms reset for that provider +- **THEN** only that provider's config and secrets are removed +- **AND** other providers remain unchanged + +### Requirement: Root dashboard summarizes each area's live state + +The root dashboard SHALL display, for each domain entry, a short live status +summary read fresh from the current configuration (for example the configured +search backend, the deployment posture with enabled-feature count, the count of +configured channels, or the count of outbound webhooks). Status summaries SHALL +NOT render secret values. The focused entry's description SHALL be shown as a +help line. + +#### Scenario: Dashboard summarizes configured state without secrets + +- **GIVEN** a configured install with a search backend and channels set +- **WHEN** the operator opens `netclaw config` +- **THEN** each area row shows its current state summary +- **AND** no secret value (API key, bearer token, channel token) appears in any + summary + +### Requirement: Channels resolve a target before adding it + +When the operator adds a channel to a configured adapter, `netclaw config` SHALL +resolve the channel against that adapter (confirming it exists / is visible to +the bot) BEFORE persisting it. A channel that does not resolve SHALL NOT be +added. A resolved channel SHALL be added at the deployment posture's default +audience and SHALL remain editable afterward. + +#### Scenario: Non-resolving channel is rejected + +- **GIVEN** the operator types a channel the adapter cannot resolve +- **WHEN** they confirm the add +- **THEN** an error is shown +- **AND** the channel is not written to config + +#### Scenario: Resolved channel is added at the default audience + +- **GIVEN** the operator types a channel the adapter resolves +- **WHEN** they confirm the add +- **THEN** the resolved channel is added at the deployment posture's default + audience + +### Requirement: Telemetry exposes multiple outbound webhooks + +The Telemetry & Alerting area SHALL edit the full list of outbound webhooks +(`Notifications.Webhooks`) — add, edit, and remove — rather than a single +webhook. Each entry SHALL carry a name, URL, and an optional authorization +header (masked on display), with the webhook format auto-detected from the URL +and shown read-only. + +#### Scenario: Multiple webhooks round-trip + +- **GIVEN** the operator adds two outbound webhooks +- **WHEN** the editor saves and is reopened +- **THEN** both webhooks are present with their names, URLs, and detected + formats + +### Requirement: Config selection uses a uniform highlight bar + +The config dashboard and its sub-editor lists SHALL indicate the focused row +with one uniform full-width highlight bar style, applied consistently across +areas rather than a mix of marker glyphs. + +#### Scenario: Focused row is highlighted consistently + +- **WHEN** the operator navigates any config list +- **THEN** the focused row is shown with the uniform highlight bar + +### Requirement: Coverage follows leaf ownership + +Leaf editors SHALL receive substantive round-trip and smoke coverage. +Routed handoffs SHALL receive shallow routing coverage only. Preservation +assertions SHALL be semantic, not byte-identical. + +#### Scenario: Routed handoff does not require leaf round-trip suite + +- **GIVEN** `Inference Providers` routes to `netclaw provider` +- **WHEN** coverage is defined for the config dashboard +- **THEN** the handoff requires routing coverage +- **AND** it does not require a duplicate leaf-editor round-trip suite in + this change + +### Requirement: Channels area supports Slack, Discord, and Mattermost adapters + +The `Channels` domain area SHALL support three channel adapters: Slack, +Discord, and Mattermost. Each adapter SHALL be independently enabled, +configured, and managed from the same Channels editor. + +#### Scenario: Mattermost adapter is available alongside Slack and Discord + +- **GIVEN** the operator opens the Channels config area +- **WHEN** the adapter list is rendered +- **THEN** Slack, Discord, and Mattermost each appear as configurable + adapter entries +- **AND** enabling Mattermost leads to credential entry (server URL and + bot token) followed by channel resolution + +### Requirement: Directory pickers use an interactive file-picker widget + +The Skill Sources local-folder add flow and the Workspaces Directory editor SHALL use an interactive directory picker. + +The Skill Sources "add a local folder" flow and the Workspaces Directory +editor SHALL present a Termina `FilePickerNode` directory picker instead +of a typed path field. The picker SHALL be scoped to directories only and +SHALL fill the content area. + +Selecting a directory in the picker SHALL save immediately +(autosave-on-selection) without requiring a separate confirm step. + +A `Ctrl+N` affordance SHALL be available throughout both pickers. When +activated, it SHALL open an inline naming overlay that lets the operator +name and create a new folder inside the currently focused picker +directory. On successful creation the folder SHALL be selectable +immediately without restarting the picker. On `Esc` the naming overlay +SHALL be dismissed and the picker SHALL remain active. + +#### Scenario: Selecting a directory in the Workspaces Directory picker saves immediately + +- **GIVEN** the operator opens the Workspaces Directory editor +- **WHEN** the operator navigates the picker and confirms a directory +- **THEN** the selected path is saved to `Workspaces.Directory` + immediately +- **AND** no separate save key is required + +#### Scenario: Ctrl+N creates a new folder from within the directory picker + +- **GIVEN** the operator is in a directory picker (Skill Sources or + Workspaces Directory) +- **WHEN** the operator presses `Ctrl+N`, enters a folder name, and + confirms with `Enter` +- **THEN** the folder is created inside the currently focused directory +- **AND** the naming overlay is dismissed +- **AND** the new folder is available for selection in the same picker + session + +#### Scenario: Esc cancels new-folder naming without affecting the picker + +- **GIVEN** the operator has opened the new-folder naming overlay via + `Ctrl+N` +- **WHEN** the operator presses `Esc` +- **THEN** the naming overlay is dismissed +- **AND** the directory picker remains active with no folder created + +### Requirement: Inbound Webhooks editor manages global enablement and execution timeout + +The Inbound Webhooks editor SHALL provide two editable settings: + +- A global `Enabled` boolean toggle that persists to + `Webhooks.Enabled`. +- An `ExecutionTimeoutSeconds` integer field (1–3600 seconds) that + persists to `Webhooks.ExecutionTimeoutSeconds`. + +Route authoring SHALL remain owned by the `netclaw webhooks` CLI +(`netclaw webhooks set|list|validate`). The editor SHALL NOT create, +edit, or delete route files. It SHALL display a live route summary +(total, enabled, disabled, invalid counts) so the operator can assess +configuration health without leaving the TUI. + +Enabling the global toggle with no valid routes present SHALL still +persist `Webhooks.Enabled = true`. The editor SHALL surface a +non-blocking advisory directing the operator to run `netclaw webhooks +set` to add routes; it SHALL NOT block the save or require routes to +exist before enabling. + +Saving SHALL be blocked only when `ExecutionTimeoutSeconds` contains a +structurally invalid value (non-integer, or outside 1–3600). + +#### Scenario: Toggling Enabled with no routes persists true and shows advisory + +- **GIVEN** the Inbound Webhooks editor is open +- **AND** no valid webhook routes exist +- **WHEN** the operator toggles `Enabled` to true and saves +- **THEN** `Webhooks.Enabled = true` is written to config +- **AND** a non-blocking advisory is shown instructing the operator to + add a route with `netclaw webhooks set` +- **AND** the save is not blocked + +#### Scenario: Invalid execution timeout blocks save + +- **GIVEN** the operator has entered a non-integer or out-of-range value + in the execution timeout field +- **WHEN** the operator saves +- **THEN** an error is shown describing the valid range +- **AND** no config file is modified + +#### Scenario: Route summary reflects current route state without editor ownership + +- **GIVEN** routes have been authored via `netclaw webhooks set` +- **WHEN** the operator opens the Inbound Webhooks editor +- **THEN** the summary row displays the current total, enabled, + disabled, and invalid route counts +- **AND** the editor offers no affordance to create or modify route + files directly + +### Requirement: Search editor uses progressive disclosure per backend + +The Search editor SHALL reveal only the configuration field relevant to +the selected backend: + +- Selecting `Brave` SHALL reveal the Brave API key field (stored in + `secrets.json`) and hide the SearXNG endpoint field. +- Selecting `SearXNG` SHALL reveal the SearXNG instance URL field + (stored in `netclaw.json`) and hide the Brave API key field. +- Selecting `DuckDuckGo` SHALL hide both backend-specific fields, as + DuckDuckGo requires no additional configuration. + +Fields for inactive backends SHALL NOT be rendered in the editor or +prompted for input. + +#### Scenario: Selecting Brave reveals only the Brave API key field + +- **GIVEN** the operator opens the Search editor +- **WHEN** the operator selects `Brave` as the backend +- **THEN** the Brave API key input field is shown +- **AND** the SearXNG endpoint field is not shown + +#### Scenario: Selecting SearXNG reveals only the SearXNG endpoint field + +- **GIVEN** the operator opens the Search editor +- **WHEN** the operator selects `SearXNG` as the backend +- **THEN** the SearXNG instance URL field is shown +- **AND** the Brave API key field is not shown + +#### Scenario: Selecting DuckDuckGo shows no backend-specific field + +- **GIVEN** the operator opens the Search editor +- **WHEN** the operator selects `DuckDuckGo` as the backend +- **THEN** no backend-specific credential or endpoint field is shown +- **AND** saving requires no further input + diff --git a/openspec/specs/netclaw-onboarding/spec.md b/openspec/specs/netclaw-onboarding/spec.md index 1ec013b5a..83151ba63 100644 --- a/openspec/specs/netclaw-onboarding/spec.md +++ b/openspec/specs/netclaw-onboarding/spec.md @@ -2,7 +2,9 @@ ## Purpose -Define first-run and resumable onboarding experience for Netclaw operators. +Define bootstrap-first, first-run, and resumable onboarding experiences for +Netclaw operators, including identity-file behavior and existing-install +branches. ## Requirements ### Requirement: Stepwise setup wizard @@ -39,103 +41,101 @@ exposure mode controls daemon network reachability. ### Requirement: Guided onboarding -The CLI SHALL provide guided setup through `netclaw init`. The onboarding -wizard SHALL collect Slack credentials, provider configuration, ACL inputs, -search backend, browser automation, memory provider selection, MCP server -configuration, and exposure mode selection. On completion, the wizard SHALL -run a health check to verify the baseline configuration is functional. If -daemon startup fails because configuration validation rejects the selected -exposure mode or remote-auth topology, the wizard SHALL surface that failure -as a structured setup error with remediation guidance. - -The wizard SHALL NOT write `AGENTS.md` to disk during identity file -generation. AGENTS.md is binary-controlled firmware loaded from embedded -resources at runtime. The wizard SHALL continue to write `SOUL.md` and -`TOOLING.md` as operator-mutable identity files. - -For non-Personal postures, the wizard SHALL also present a Feature Selection -step that writes deployment-wide `Enabled` switches. These switches SHALL NOT -implicitly rewrite Public audience allowlists. - -#### Scenario: First-time setup - -- **WHEN** operator runs `netclaw init` on a fresh install -- **THEN** guided setup collects provider, Slack, ACL, search, browser - automation, memory, and exposure mode inputs -- **AND** writes a runnable baseline configuration -- **AND** writes SOUL.md and TOOLING.md to `~/.netclaw/identity/` -- **AND** does NOT write AGENTS.md (or writes a reference-only stub) - -#### Scenario: Identity files written on completion - -- **WHEN** the wizard completes and writes config -- **THEN** `SOUL.md` is written from the embedded SOUL template -- **AND** `TOOLING.md` is written from the embedded TOOLING template -- **AND** `AGENTS.md` is NOT written from a template - -#### Scenario: Public posture defaults search off without mutating Public tool allowlist - -- **GIVEN** the operator selected Public posture -- **WHEN** the Feature Selection step is shown -- **THEN** Search defaults to disabled -- **AND** enabling Search there affects only the deployment-wide runtime switch -- **AND** `Tools.AudienceProfiles.Public.AllowedTools` is not implicitly widened - -#### Scenario: Exposure-mode startup validation failure shown cleanly - -- **GIVEN** the operator completes `netclaw init` -- **AND** the written configuration causes `ExposureModeValidationService` to reject - daemon startup -- **WHEN** the health-check step starts the daemon -- **THEN** the wizard shows a failed health-check item containing the validation - message -- **AND** the wizard includes remediation guidance for fixing the exposure/auth - configuration -- **AND** the operator is not shown a raw stack trace - -#### Scenario: Startup validation failure does not degrade to generic readiness timeout - -- **GIVEN** daemon startup fails immediately because exposure validation rejects the - configuration -- **WHEN** the health-check step polls daemon readiness -- **THEN** the wizard reports the actual startup validation failure -- **AND** it does NOT report only `Daemon did not become ready` unless the failure - reason is genuinely unavailable +`netclaw init` SHALL provide bootstrap-first guided setup. The flow SHALL +collect provider configuration, identity, and security posture. Security +Posture, Enabled Features, and Audience Profiles are distinct concepts. + +If the operator selects `Personal`, the bootstrap flow SHALL skip Enabled +Features. + +If the operator selects `Team` or `Public`, the bootstrap flow SHALL +automatically continue into Enabled Features before final write. + +Audience Profiles editing SHALL NOT be part of init bootstrap; it belongs +to `netclaw config`. + +The wizard SHALL continue to write `SOUL.md` and `TOOLING.md`. Identity +remains init-owned in this branch. + +The bootstrap wizard SHALL consist of exactly **5 steps** in canonical order: +Provider → Identity → Security Posture → Enabled Features → Health Check. +`TotalSteps` is **5** for `Team`/`Public` postures and **4** for `Personal` +posture (Enabled Features is omitted). Step-progress indicators SHALL reflect +the dynamic count. + +#### Scenario: Personal posture skips enabled-features bootstrap step + +- **GIVEN** the operator selected `Personal` +- **WHEN** the posture step completes +- **THEN** init does not open an Enabled Features step +- **AND** the wizard proceeds directly to Health Check (step 4 of 4) + +#### Scenario: Team posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Team` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +#### Scenario: Public posture continues into enabled-features bootstrap step + +- **GIVEN** the operator selected `Public` +- **WHEN** the posture step completes +- **THEN** init automatically continues into Enabled Features + +--- ### Requirement: Phase 2 conversational personality bootstrap The system SHALL trigger a conversational personality bootstrap on the first -conversation if personality files (PERSONALITY.md, INSTRUCTIONS.md, USER.md) -do not exist. The bootstrap conversation SHALL ask the operator about -communication preferences, tone, name preferences, and working style, then -write the resulting soul files to the standard config directory. +conversation if identity files (`SOUL.md`, `TOOLING.md`) do not already carry +operator-enriched content. The bootstrap is delivered as an initial chat message +injected by the init wizard's navigate callback when `LaunchChat()` fires. The +bootstrap message SHALL ask the operator about communication preferences, tone, +name preferences, and working style, then instruct the agent to update `SOUL.md` +with what it learns. `AGENTS.md` is loaded from embedded resources at runtime +and is NOT written to disk by the wizard. #### Scenario: First conversation triggers bootstrap -- **GIVEN** no personality files exist in the config directory -- **WHEN** the operator starts their first conversation with Netclaw -- **THEN** the agent initiates a personality bootstrap conversation -- **AND** asks about communication preferences and working style +- **GIVEN** the operator completed the init wizard successfully +- **WHEN** the health check step auto-launches chat via `LaunchChat()` +- **THEN** the agent receives a pre-filled onboarding trigger message +- **AND** the message instructs it to introduce itself, ask the operator about + their primary use case, ask about background and preferences, and then update + `SOUL.md` with the learned details #### Scenario: Bootstrap writes soul files - **GIVEN** the personality bootstrap conversation is complete -- **WHEN** the operator has answered all preference questions -- **THEN** the system writes PERSONALITY.md, INSTRUCTIONS.md, and USER.md to - the config directory +- **WHEN** the operator has answered the agent's preference questions +- **THEN** the agent updates `SOUL.md` in the config directory with what it + learned +- **AND** `TOOLING.md` is already in place from the init wizard's + `WriteIdentityFiles` call #### Scenario: Bootstrap skipped when files exist -- **GIVEN** personality files already exist in the config directory +- **GIVEN** `SOUL.md` already exists in the config directory with enriched + content - **WHEN** a new conversation starts -- **THEN** no personality bootstrap is triggered -- **AND** the existing personality files are loaded normally +- **THEN** no personality bootstrap trigger is injected +- **AND** the existing `SOUL.md` is loaded normally + +--- ### Requirement: Environment discovery during onboarding -The system SHALL scan for installed tools and host capabilities as part of -Phase 2 onboarding. Discovery results SHALL be persisted to the environment -inventory file for use in session context and capability self-awareness. +`netclaw init` SHALL NOT perform environment discovery in the shipped first-run flow (DEFERRED — unimplemented Phase 2 work). + +**[DEFERRED — not part of the shipped first-run flow.]** This requirement +describes planned Phase 2 behavior that has not been implemented. Environment +discovery does NOT run during `netclaw init` and is NOT triggered by the health +check step. When implemented, it SHALL be gated by an explicit PRD update and +SHALL NOT be silently enabled in the bootstrap wizard. + +The system SHALL scan for installed tools and host capabilities as part of Phase +2 onboarding. Discovery results SHALL be persisted to the environment inventory +file for use in session context and capability self-awareness. #### Scenario: Tool discovery during onboarding @@ -152,8 +152,17 @@ inventory file for use in session context and capability self-awareness. - **THEN** the system checks reachability of each configured MCP server - **AND** records reachability status in the environment inventory +--- + ### Requirement: Project registration during onboarding +`netclaw init` SHALL NOT perform project registration in the shipped first-run flow (DEFERRED — unimplemented Phase 2 work). + +**[DEFERRED — not part of the shipped first-run flow.]** This requirement +describes planned Phase 2 behavior that has not been implemented. Project +registration does NOT occur during `netclaw init`. When implemented, it SHALL +be gated by an explicit PRD update. + The system SHALL ask the operator about repositories to register as part of Phase 2 onboarding. Registered projects are added to the project registry with their paths, capabilities, and AGENTS.md locations. @@ -170,53 +179,7 @@ with their paths, capabilities, and AGENTS.md locations. - **AND** the operator indicates no projects to register - **THEN** onboarding proceeds with an empty project registry -### Requirement: Memory provider selection during onboarding - -The init wizard SHALL include a Memory step (step 6, after BrowserAutomation) -that allows operators to choose between "Local files" (default) and -"Memorizer" as the cross-session memory backend. The step SHALL always render -and SHALL NOT be conditionally skipped. `TotalSteps` SHALL be 9. - -#### Scenario: Operator selects local files - -- **WHEN** the wizard reaches the Memory step -- **AND** the operator selects "Local files (default)" -- **THEN** the wizard writes `"Memory": { "Provider": "files" }` to - `netclaw.json` -- **AND** advances to the next step without further substeps - -#### Scenario: Operator selects Memorizer - -- **WHEN** the wizard reaches the Memory step -- **AND** the operator selects "Memorizer" -- **THEN** the wizard advances to the Memorizer connection substep - -#### Scenario: Default selection is local files - -- **WHEN** the wizard reaches the Memory step -- **THEN** "Local files (default)" is pre-selected - -### Requirement: Memorizer MCP connection configuration - -When the operator selects Memorizer, the wizard SHALL collect MCP server -connection details: transport type (stdio or http) and the corresponding -connection parameters (URL for http, command + arguments for stdio). The -wizard SHALL write both `Memory.Provider` and a `McpServers.memorizer` entry -to `netclaw.json`. - -#### Scenario: Configure HTTP transport - -- **GIVEN** the operator selected Memorizer -- **WHEN** the wizard reaches the connection substep -- **AND** the operator selects "HTTP" transport and enters a URL -- **THEN** the wizard writes `"McpServers": { "memorizer": { "Transport": "http", "Url": "", "Enabled": true } }` - -#### Scenario: Configure stdio transport - -- **GIVEN** the operator selected Memorizer -- **WHEN** the wizard reaches the connection substep -- **AND** the operator selects "stdio" transport and enters command + arguments -- **THEN** the wizard writes the corresponding stdio MCP server entry +--- ### Requirement: Memorizer connectivity validation during onboarding @@ -251,14 +214,17 @@ timeout. On failure, the wizard SHALL offer retry or fallback to local files. ### Requirement: TUI wizard delivery mechanism The `netclaw init` onboarding wizard SHALL be delivered through Termina TUI -as an interactive 9-step wizard with progress indication, validation, and -back-navigation. +as an interactive wizard with progress indication, validation, and +back-navigation. The wizard SHALL have **5 steps** for `Team`/`Public` posture +and **4 steps** for `Personal` posture. Step-progress indicators (e.g., +"Step 2 of 5" or "Step 2 of 4") SHALL reflect the dynamic total. There is no +fixed 9-step wizard. #### Scenario: Wizard renders in TUI - **WHEN** operator runs `netclaw init` - **THEN** a Termina TUI application launches -- **AND** the wizard displays step progress (e.g., "Step 2 of 9") +- **AND** the wizard displays step progress (e.g., "Step 2 of 5") - **AND** the wizard displays a progress bar #### Scenario: Step-specific components rendered @@ -277,11 +243,13 @@ back-navigation. #### Scenario: Live validation during wizard -- **GIVEN** the wizard is on the Memory step with Memorizer selected -- **WHEN** the operator enters connection details -- **THEN** the wizard validates connectivity with a SpinnerNode +- **GIVEN** the wizard is on the Provider step +- **WHEN** the operator enters provider credentials +- **THEN** the wizard validates the credentials - **AND** displays success or failure before allowing progression +--- + ### Requirement: Onboarding bootstrap aligns with daemon-owned first-launch bootstrap The init wizard SHALL remain compatible with daemon-owned first-launch bootstrap seeding. Wizard-written bootstrap state SHALL NOT be required for first-launch success, and wizard finalization SHALL NOT overwrite an existing daemon-owned bootstrap credential. @@ -299,3 +267,223 @@ The init wizard SHALL remain compatible with daemon-owned first-launch bootstrap - **WHEN** the operator later runs `netclaw init` - **THEN** wizard finalization does not overwrite the existing bootstrap credential automatically +### Requirement: Existing-install init menu + +When `netclaw init` runs on an existing install, it SHALL open an action menu +with exactly these options: + +- `Redo identity setup` +- `Open configuration editor` +- `Start over from scratch` +- `Cancel` + +#### Scenario: Existing install opens action menu + +- **GIVEN** `netclaw.json` exists +- **WHEN** the operator runs `netclaw init` +- **THEN** init opens the existing-install menu with the documented four + options + +#### Scenario: Existing install routes to config editor + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Open configuration editor` +- **THEN** control routes to `netclaw config` + +#### Scenario: Existing install routes to init-owned identity flow + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Redo identity setup` +- **THEN** control routes to the init-owned identity flow + +### Requirement: Start-over flow is double-confirmed + +Choosing `Start over from scratch` SHALL open a second dialog with exactly: + +- `Reset setup only` +- `Full reset` +- `Cancel` + +Either destructive option SHALL require double confirmation before files are +mutated. + +#### Scenario: Start-over dialog presents reset choices + +- **GIVEN** the existing-install menu is open +- **WHEN** the operator chooses `Start over from scratch` +- **THEN** the second dialog presents `Reset setup only`, `Full reset`, and + `Cancel` + +#### Scenario: Destructive reset requires double confirmation + +- **GIVEN** the operator selected either `Reset setup only` or `Full reset` +- **WHEN** the destructive flow proceeds +- **THEN** two distinct confirmations are required before mutation + +### Requirement: No init-force flag in this flow + +This bootstrap flow SHALL NOT rely on a `netclaw init --force` mode. +Existing-install reset behavior SHALL be owned by the in-TUI existing-install +menu and start-over dialogs. + +#### Scenario: Existing-install reset does not require hidden flag + +- **GIVEN** an existing install +- **WHEN** the operator wants to restart setup +- **THEN** the path is available from the existing-install init menu +- **AND** it does not depend on `netclaw init --force` + +### Requirement: Init-owned editor re-entry uses existing config state + +Init-owned editor re-entry on an existing install SHALL load existing config +into `WizardContext.ExistingConfig` and prefill non-secret values from that +state. Secret-bearing fields SHALL remain masked and empty. + +#### Scenario: Provider re-entry keeps credential field masked + +- **GIVEN** an existing provider configuration with stored credentials +- **WHEN** an init-owned provider flow re-enters +- **THEN** provider choice and non-secret fields are prefilled +- **AND** credential inputs remain blank with configured/not-set hint text + +#### Scenario: Identity re-entry prefills init-owned fields + +- **GIVEN** an existing install with agent name, operator name, and + timezone already set +- **WHEN** an init-owned identity flow re-enters +- **THEN** those non-secret fields are prefilled + +### Requirement: Init-owned writes use semantic merge + +Init-owned editor flows SHALL write changes through semantic merge-on-save. +Unrelated config meaning and unrelated stored secrets SHALL be preserved even +if the serialized file text changes. + +#### Scenario: Identity-only edit preserves unrelated config meaning + +- **GIVEN** an existing install with configured channels, search, and + exposure settings +- **WHEN** an init-owned identity flow updates only identity-owned data +- **THEN** the unrelated config sections remain semantically unchanged + +#### Scenario: Blank secret submission preserves existing secret + +- **GIVEN** an init-owned flow includes a secret-bearing field with an + existing stored value +- **WHEN** the operator leaves that field blank and saves +- **THEN** the existing secret remains stored +- **AND** no decrypted value is shown in the UI + +### Requirement: Identity step collects exactly four substeps + +The Identity wizard step SHALL collect exactly **4 substeps** in order: +agent name → communication style → operator name → timezone. `SubStepCount` +SHALL equal 4. The Identity step SHALL NOT collect a workspaces directory path +or a notification-webhook URL; those are post-install settings owned by +`netclaw config`. + +#### Scenario: Identity step has four substeps + +- **WHEN** the wizard enters the Identity step +- **THEN** `SubStepCount` equals 4 +- **AND** the substeps are agent name (0), communication style (1), operator + name (2), and timezone (3) + +#### Scenario: Workspaces directory not collected in init + +- **WHEN** the operator completes the Identity step +- **THEN** no workspaces directory is written to `netclaw.json` +- **AND** `WizardConfigBuilder.Workspaces` is null after `ContributeConfig` + +#### Scenario: Notification webhook not collected in init + +- **WHEN** the operator completes the Identity step +- **THEN** no notification webhook is written to `netclaw.json` +- **AND** `WizardConfigBuilder.Notifications` is null after `ContributeConfig` + +#### Scenario: Identity step prefills from existing config on re-entry + +- **GIVEN** `netclaw.json` exists with `Identity.AgentName`, `Identity.CommunicationStyle`, + `Identity.UserName`, and `Identity.UserTimezone` +- **WHEN** the operator re-enters the Identity step +- **THEN** all four non-secret fields are prefilled from the existing config + +--- + +### Requirement: Health check auto-launches chat on success + +The health check step SHALL launch `netclaw chat` automatically on a clean bootstrap. + +On a clean bootstrap (all health check probes passing), the health check step +SHALL invoke `LaunchChat()` automatically without requiring a second Enter +keypress. `LaunchChat()` SHALL route to `/chat` via the wired `Navigate` +delegate. On warnings or failure the step SHALL remain on the summary and exit +on Enter without routing to chat. + +#### Scenario: Clean bootstrap auto-launches chat + +- **GIVEN** all health-check probes passed +- **WHEN** `RunHealthCheckCoreAsync` completes +- **THEN** `LaunchChat()` is called automatically +- **AND** the Navigate delegate receives `"/chat"` +- **AND** `Succeeded` is `true` + +#### Scenario: Failed health check does not launch chat + +- **GIVEN** one or more health-check probes failed +- **WHEN** `RunHealthCheckCoreAsync` completes +- **THEN** `LaunchChat()` is NOT called +- **AND** the step displays the failure summary +- **AND** `Succeeded` is `false` + +#### Scenario: Failure summary status message + +- **GIVEN** the health check completed with at least one failure +- **WHEN** the operator views the summary +- **THEN** the status message reads: "Setup complete with warnings. Run + `netclaw daemon start`, then `netclaw chat`. Adjust settings with + `netclaw config`." + +--- + +### Requirement: Health check surfaces container-supervisor deferral reason on timeout + +A health-check failure SHALL surface the container-supervisor deferral reason when the supervised daemon never arrives. + +When the daemon is externally supervised (`NETCLAW_CONTAINER_SUPERVISOR` marker +set) but the supervisor never actually brings the daemon up within the readiness +poll window, the health-check failure item SHALL surface the actionable +container-supervisor deferral reason (including the hint that the marker may be +set without a supervisor present) rather than the generic "Daemon did not become +ready" message. When a startup-abort crash log is present, the failure message +SHALL include both the abort reason and the crash-log path. + +#### Scenario: Supervisor marker set but daemon never starts — surfaces deferral reason + +- **GIVEN** `NETCLAW_CONTAINER_SUPERVISOR` is set (i.e., `IsExternallySupervised` is `true`) +- **AND** no supervisor process actually starts the daemon (e.g., the image replaced + the entrypoint) +- **AND** no `DaemonApi` is wired (poll loop is skipped) +- **WHEN** `StartIfNeededAndPollAsync` times out +- **THEN** the failing health-check item label contains "container supervisor" +- **AND** contains "marker may be set without a supervisor present" +- **AND** does NOT contain "Daemon did not become ready" +- **AND** `Succeeded` is `false` + +#### Scenario: Startup-abort crash log surfaces specific failure message + +- **GIVEN** the daemon binary exits immediately (bad config or fatal startup error) +- **AND** a crash log exists in the logs directory containing + "Daemon startup aborted: …" +- **WHEN** `StartIfNeededAndPollAsync` detects the crash log +- **THEN** the failing health-check item label contains the specific abort reason +- **AND** contains the crash-log path +- **AND** does NOT contain "Daemon did not become ready" + +#### Scenario: Generic not-ready message is suppressed when a diagnostic is available + +- **GIVEN** either a crash log or a supervisor deferral reason is available +- **WHEN** the health-check step records the failure item +- **THEN** the generic "Daemon did not become ready" string is absent from the + failure label + diff --git a/openspec/specs/section-editor-abstraction/spec.md b/openspec/specs/section-editor-abstraction/spec.md new file mode 100644 index 000000000..4b92d3ca5 --- /dev/null +++ b/openspec/specs/section-editor-abstraction/spec.md @@ -0,0 +1,114 @@ +# section-editor-abstraction Specification + +## Purpose + +Define the reusable CLI leaf-editor contract shared by bootstrap-only init +flows and future post-install config flows, including semantic persistence, +secret-safe re-entry, and audit obligations. + +## Requirements + +### Requirement: Leaf editor interface + +The CLI SHALL define an `ISectionEditor` contract for reusable editable +leaf surfaces. A leaf editor SHALL declare a stable `SectionId`, a +user-facing `DisplayName`, optional `Category`, `ShowInMenu`, status and +summary methods, relevant validation checks, and a factory that returns an +`IWizardStepViewModel` runnable in either init-owned flows or config-owned +single-step hosting. + +The contract SHALL describe leaf editing only. It SHALL NOT imply that the +top-level `netclaw config` IA is flat or identical to registry order. + +#### Scenario: Registered leaf editor does not define dashboard shape + +- **GIVEN** a registered leaf editor with `SectionId = "Search"` +- **WHEN** the config dashboard is later composed +- **THEN** the dashboard MAY place that leaf under a grouped page such as + `Search` or `Security & Access` +- **AND** the leaf editor contract remains valid regardless of the + top-level navigation shape + +#### Scenario: Synthetic init-owned editor is allowed + +- **GIVEN** an editor such as `Identity` spans generated files and config + leaves +- **WHEN** it is registered with `ShowInMenu = false` +- **THEN** it MAY use a synthetic identifier when documented in the + exemption list +- **AND** it SHALL remain absent from the config dashboard menu + +### Requirement: Semantic merge-on-save + +Leaf editors SHALL persist changes through semantic merge-on-save. The merge +writer SHALL preserve unrelated sections and inactive values semantically. +Formatting, property order, and byte-for-byte file identity are NOT part of +the contract. + +#### Scenario: Editing one leaf preserves unrelated meaning + +- **GIVEN** `netclaw.json` contains configured `Providers`, `Slack`, + `Search`, and inactive exposure-mode values for modes other than the + current `Daemon.ExposureMode` +- **WHEN** the operator edits only the Search leaf and saves +- **THEN** `Search` reflects the requested change +- **AND** the unrelated sections and inactive exposure-mode values remain + semantically unchanged + +#### Scenario: No-op save may rewrite formatting without changing meaning + +- **GIVEN** an existing config file with non-canonical property order +- **WHEN** an editor performs a no-op save +- **THEN** the resulting file MAY differ in byte representation +- **AND** the resulting parsed config SHALL be semantically equivalent to + the original + +### Requirement: Reentrancy contract for init-owned flows + +Init-owned re-entry flows SHALL prefill non-secret fields from +`WizardContext.ExistingConfig` when they reuse a leaf editor against existing +state. Secret-bearing fields SHALL remain empty and masked, using +existence-only hint text. + +#### Scenario: Existing non-secret values prefill + +- **GIVEN** an init-owned flow enters the Security Posture editor with an + existing posture already configured +- **WHEN** the editor loads +- **THEN** the current posture is preselected + +#### Scenario: Stored secrets never rehydrate + +- **GIVEN** an editor owns a secret-bearing field whose value exists in + `secrets.json` +- **WHEN** the editor loads +- **THEN** the field renders empty +- **AND** the hint indicates only whether a value exists +- **AND** the decrypted value is never displayed + +### Requirement: Secret-presence lookup without decryption + +`ConfigFileHelper` SHALL expose an existence-only secret lookup API used by +leaf editors to decide between "configured - leave blank to keep" and +"(not set)". + +#### Scenario: Presence lookup does not decrypt + +- **GIVEN** `secrets.json` contains an encrypted value for a leaf editor +- **WHEN** `SecretPresent(...)` is called +- **THEN** the result indicates presence or absence only +- **AND** the decrypted value is not materialized for UI display + +### Requirement: Audit applies to registered leaf editors + +The test project SHALL audit registered leaf editors for round-trip test +coverage and declared validation checks. `ShowInMenu = false` leaves remain +subject to round-trip coverage but are exempt from config-dashboard tape +requirements. + +#### Scenario: Menu-hidden init-owned editor still needs a round-trip test + +- **GIVEN** `Identity` is registered with `ShowInMenu = false` +- **WHEN** the registry audit runs +- **THEN** the audit requires a leaf-editor round-trip test class +- **AND** it does NOT require a config-dashboard smoke tape for Identity diff --git a/openspec/specs/security-posture-tui/spec.md b/openspec/specs/security-posture-tui/spec.md index 06ab04148..0a69123a8 100644 --- a/openspec/specs/security-posture-tui/spec.md +++ b/openspec/specs/security-posture-tui/spec.md @@ -4,9 +4,7 @@ Define the interactive TUI step for deployment posture selection during `netclaw init`. - ## Requirements - ### Requirement: Security posture selection step The wizard SHALL present an interactive step where the user selects a @@ -17,35 +15,43 @@ each option. - **GIVEN** the wizard is at the SecurityPosture step - **WHEN** the user selects "Personal" -- **THEN** deployment posture is set to Personal +- **THEN** deployment posture is set to Personal in WizardContext - **AND** shell execution mode defaults to HostAllowed -- **AND** DM audience defaults to Personal -- **AND** channel audience defaults to Team +- **AND** audience profiles are seeded with Personal-posture defaults #### Scenario: User selects Team posture - **GIVEN** the wizard is at the SecurityPosture step - **WHEN** the user selects "Team" -- **THEN** deployment posture is set to Team +- **THEN** deployment posture is set to Team in WizardContext - **AND** shell execution mode defaults to Off -- **AND** DM audience defaults to Team -- **AND** channel audience defaults to Team +- **AND** audience profiles are seeded with Team-posture defaults #### Scenario: User selects Public posture - **GIVEN** the wizard is at the SecurityPosture step - **WHEN** the user selects "Public" -- **THEN** deployment posture is set to Public +- **THEN** deployment posture is set to Public in WizardContext - **AND** shell execution mode defaults to Off -- **AND** DM audience defaults to Public -- **AND** channel audience defaults to Public +- **AND** audience profiles are seeded with Public-posture defaults + +> **Rationale:** The posture step writes `DeploymentPosture`, `ShellExecutionMode`, +> and `AudienceProfiles` into `WizardContext`. Channel and DM audience defaults are +> NOT applied here; they are derived from `WizardContext.SelectedPosture` by the +> channel-picker step (e.g. `SlackStepViewModel.OnLeave`) when it builds +> `ChannelEntry` records. Removing the old per-posture DM/channel assertions +> prevents false specification of where those values originate. + +--- ### Requirement: Posture step position in wizard flow -The SecurityPosture step SHALL appear after ChatServices and before the -Feature Selection step in the wizard flow. For non-Personal postures, the -Feature Selection step SHALL appear immediately after SecurityPosture so -that feature availability is configured before channel audience assignment. +The SecurityPosture step SHALL appear after the Provider step and before the +Feature Selection step in the wizard flow. The Provider step combines LLM +provider selection and authentication/chat-service configuration; there is no +separate ChatServices step. For non-Personal postures, the Feature Selection +step SHALL appear immediately after SecurityPosture so that feature +availability is configured before channel audience assignment. #### Scenario: Step order with Feature Selection @@ -60,3 +66,78 @@ that feature availability is configured before channel audience assignment. - **AND** the selected posture is Personal - **THEN** the Feature Selection step is skipped - **AND** the next applicable step follows directly + +> **Rationale:** `InitWizardViewModel` builds the step sequence as +> `Provider → Identity → SecurityPosture → FeatureSelection → HealthCheck`. +> The old spec named "ChatServices" as the preceding step, which no longer +> exists; chat-service auth is part of the Provider step. + +--- + +### Requirement: Post-install posture cascade in netclaw config + +A posture change in `netclaw config` with customized audience profiles SHALL require a cascade confirmation before writing. + +When the operator changes the deployment posture via `netclaw config` and the +existing audience profiles have been customized (differ from the current +posture's defaults), the editor SHALL present a three-option cascade +confirmation before writing any changes: + +- **Cancel** — abort the posture change; leave posture and profiles untouched. +- **Apply new posture, overwrite profiles** — save the new posture and reset + all audience profiles to the new posture's defaults. +- **Apply new posture, keep custom profiles** — save the new posture and shell + defaults only; leave existing audience profile overrides in place. + +The editor MUST NOT apply the posture change without this confirmation when +profiles are customized. If profiles are at their posture defaults (not +customized), the editor SHALL apply the new posture directly without +presenting the cascade screen. + +#### Scenario: Posture change with customized profiles triggers cascade + +- **GIVEN** the operator opens `netclaw config` → Security → Security Posture +- **AND** the current audience profiles differ from the current posture's + defaults (i.e. `AudienceProfilesCustomized()` returns true) +- **WHEN** the operator selects a different posture and confirms +- **THEN** the editor transitions to the PostureCascade confirmation screen +- **AND** no config file changes are written yet + +#### Scenario: Cascade — cancel preserves existing state + +- **GIVEN** the PostureCascade screen is showing +- **WHEN** the operator selects "Cancel - keep current posture" +- **THEN** the pending posture is discarded +- **AND** the editor returns to the Posture selection screen +- **AND** the config file is unchanged + +#### Scenario: Cascade — overwrite applies posture and resets profiles + +- **GIVEN** the PostureCascade screen is showing +- **WHEN** the operator selects "Apply new posture, overwrite profiles" +- **THEN** the new posture and its shell execution mode are written to config +- **AND** all audience profiles are reset to the new posture's defaults +- **AND** the editor returns to the appropriate next screen + +#### Scenario: Cascade — keep custom applies posture only + +- **GIVEN** the PostureCascade screen is showing +- **WHEN** the operator selects "Apply new posture, keep custom profiles" +- **THEN** the new posture and its shell execution mode are written to config +- **AND** existing audience profile overrides are preserved unchanged + +#### Scenario: Posture change without customized profiles applies directly + +- **GIVEN** the operator opens `netclaw config` → Security → Security Posture +- **AND** the current audience profiles match the current posture's defaults +- **WHEN** the operator selects a different posture and confirms +- **THEN** the new posture is applied immediately (no cascade screen) +- **AND** audience profiles are reset to the new posture's defaults + +#### Scenario: Selecting the already-active posture is a no-op + +- **GIVEN** the operator opens the posture editor +- **WHEN** the operator selects the posture that is already active +- **THEN** no changes are written to the config file +- **AND** a status message informs the operator that the posture is already active + diff --git a/ralph-opencode.sh b/ralph-opencode.sh index 9b4677e65..0df22b972 100755 --- a/ralph-opencode.sh +++ b/ralph-opencode.sh @@ -6,10 +6,11 @@ # ./ralph-opencode.sh 10 # Run 10 iterations # ./ralph-opencode.sh --model # Run with model override # ./ralph-opencode.sh --postmortem-model +# ./ralph-opencode.sh --variant high # Run with model variant override # ./ralph-opencode.sh --review-interval 3 # Run adversarial review every 3 iterations # ./ralph-opencode.sh --model 10 -# RALPH_MODEL=openai/gpt-4.5 ./ralph-opencode.sh -# ./ralph-opencode.sh --model openai/gpt-5.2-codex --postmortem-model github-copilot/claude-opus-4.5 9 +# RALPH_MODEL=openai/gpt-5.5 RALPH_VARIANT=xhigh ./ralph-opencode.sh +# ./ralph-opencode.sh --model openai/gpt-5.5 --variant xhigh --postmortem-model github-copilot/claude-opus-4.5 --postmortem-variant max 9 # Postmortem runs automatically after the loop. # # Each iteration is a FRESH OpenCode context window. @@ -24,38 +25,59 @@ trap 'echo ""; echo "RALPH loop interrupted."; exit 130' INT TERM PLAN_FILE="IMPLEMENTATION_PLAN.md" ITERATIONS=5 -MODEL="${RALPH_MODEL:-github-copilot/claude-opus-4.5}" +MODEL="${RALPH_MODEL:-openai/gpt-5.5}" +VARIANT="${RALPH_VARIANT:-xhigh}" POSTMORTEM_MODEL="${RALPH_POSTMORTEM_MODEL:-$MODEL}" +POSTMORTEM_VARIANT="${RALPH_POSTMORTEM_VARIANT:-$VARIANT}" REVIEW_INTERVAL="${RALPH_REVIEW_INTERVAL:-0}" # 0 = disabled L3_GATE_ENABLED="${RALPH_L3_GATE:-true}" # Block commits without L3 evidence L3_GATE_BYPASS=false -# --- arg parsing (allow: [--model X] [--review-interval N] [iterations]) --- +# --- arg parsing (allow: [--model X] [--variant X] [--review-interval N] [iterations]) --- while [[ $# -gt 0 ]]; do case "$1" in --model|-m) if [[ $# -lt 2 ]]; then echo "Missing value for $1" - echo "Usage: $0 [--model ] [--postmortem-model ] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model ] [--variant ] [--postmortem-model ] [--postmortem-variant ] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi MODEL="$2" POSTMORTEM_MODEL="${RALPH_POSTMORTEM_MODEL:-$MODEL}" shift 2 ;; + --variant) + if [[ $# -lt 2 ]]; then + echo "Missing value for $1" + echo "Usage: $0 [--model ] [--variant ] [--postmortem-model ] [--postmortem-variant ] [--review-interval N] [--skip-l3-gate] [iterations]" + exit 1 + fi + VARIANT="$2" + POSTMORTEM_VARIANT="${RALPH_POSTMORTEM_VARIANT:-$VARIANT}" + shift 2 + ;; --postmortem-model) if [[ $# -lt 2 ]]; then echo "Missing value for $1" - echo "Usage: $0 [--model ] [--postmortem-model ] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model ] [--variant ] [--postmortem-model ] [--postmortem-variant ] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi POSTMORTEM_MODEL="$2" shift 2 ;; + --postmortem-variant) + if [[ $# -lt 2 ]]; then + echo "Missing value for $1" + echo "Usage: $0 [--model ] [--variant ] [--postmortem-model ] [--postmortem-variant ] [--review-interval N] [--skip-l3-gate] [iterations]" + exit 1 + fi + POSTMORTEM_VARIANT="$2" + shift 2 + ;; --review-interval) if [[ $# -lt 2 ]]; then echo "Missing value for $1" - echo "Usage: $0 [--model ] [--postmortem-model ] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model ] [--variant ] [--postmortem-model ] [--postmortem-variant ] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi REVIEW_INTERVAL="$2" @@ -72,7 +94,7 @@ while [[ $# -gt 0 ]]; do shift else echo "Unknown arg: $1" - echo "Usage: $0 [--model ] [--postmortem-model ] [--review-interval N] [--skip-l3-gate] [iterations]" + echo "Usage: $0 [--model ] [--variant ] [--postmortem-model ] [--postmortem-variant ] [--review-interval N] [--skip-l3-gate] [iterations]" exit 1 fi ;; @@ -93,7 +115,9 @@ mkdir -p "$RUN_DIR" echo "==========================================" echo " RALPH Loop (OpenCode)" echo " Model: $MODEL" +echo " Variant: $VARIANT" echo " Postmortem Model: $POSTMORTEM_MODEL" +echo " Postmortem Variant: $POSTMORTEM_VARIANT" echo " Review Interval: $REVIEW_INTERVAL (0=disabled)" echo " L3 Gate: $L3_GATE_ENABLED (bypass=$L3_GATE_BYPASS)" echo " Iterations: $ITERATIONS" @@ -121,7 +145,9 @@ LAST_REVIEW_COMMIT="$START_COMMIT" echo "Run ID: $RUN_ID" echo "Branch: $BRANCH" echo "Model: $MODEL" + echo "Variant: $VARIANT" echo "Postmortem model: $POSTMORTEM_MODEL" + echo "Postmortem variant: $POSTMORTEM_VARIANT" echo "Review interval: $REVIEW_INTERVAL" echo "Run start commit: $START_COMMIT" echo "Started: $(date)" @@ -149,7 +175,7 @@ run_mid_review() { [[ -f "$f" ]] && prior_reviews="$prior_reviews\n- $f" done - if ! opencode run --model "$POSTMORTEM_MODEL" "Run a full adversarial review using the adversarial review skill. + if ! opencode run --model "$POSTMORTEM_MODEL" --variant "$POSTMORTEM_VARIANT" "Run a full adversarial review using the adversarial review skill. ## Context - RUN_ID: $RUN_ID @@ -224,6 +250,37 @@ ${prior_reviews:- (none — this is the first review)} return 0 } +verify_signed_iteration_commit() { + local before_commit=$1 + local after_commit + after_commit=$(git rev-parse HEAD) + + if [[ "$after_commit" == "$before_commit" ]]; then + echo "Iteration did not create a commit; skipping signature check." + return 0 + fi + + local signature_status + signature_status=$(git log -1 --format=%G? "$after_commit") + case "$signature_status" in + G|U) + echo "Signed commit verified: $after_commit (status=$signature_status)" + ;; + *) + echo "" + echo "==========================================" + echo " SIGNED COMMIT GATE FAILED" + echo "==========================================" + echo "" + echo "Latest commit is not GPG-signed or has an invalid signature." + echo "Commit: $after_commit" + echo "Signature status: $signature_status" + echo "Do not bypass signing. Fix GPG signing and rerun." + return 1 + ;; + esac +} + # Function to verify L3 evidence if L3 was claimed verify_l3_evidence() { local iter_log=$1 @@ -245,27 +302,22 @@ verify_l3_evidence() { local missing_evidence=() - # Check for application running evidence - if ! grep -qi "aspire run\|dotnet run\|npm start\|yarn dev\|Application Started\|resources healthy\|Server started\|listening on" "$iter_log" 2>/dev/null; then - missing_evidence+=("Application running (start command with evidence)") + # Netclaw L3 means a native smoke/interactive CLI proof, not just web-route checks. + if ! grep -qi "run-smoke.sh\|native smoke\|VHS\|tape:" "$iter_log" 2>/dev/null; then + missing_evidence+=("Native smoke evidence (run-smoke.sh, VHS, or named tape)") fi - # Check for routes checked evidence - if ! grep -qi "Routes Checked\|Routes checked\|Route.*200\|Route.*rendered" "$iter_log" 2>/dev/null; then - missing_evidence+=("Routes navigated (Routes Checked section)") + if ! grep -qi "All smoke checks passed\|assertions passed\|: OK\|smoke.*passed" "$iter_log" 2>/dev/null; then + missing_evidence+=("Smoke result and assertion outcome") fi - # Check for console errors evidence - if ! grep -qi "Console errors: none\|Console errors:.*none\|no console errors" "$iter_log" 2>/dev/null; then - # Also check if they documented errors (which is valid if they then fix them) - if ! grep -qi "Console errors:" "$iter_log" 2>/dev/null; then - missing_evidence+=("Console errors checked (Console errors: none)") - fi + if ! grep -qi "semantic assertion\|assertion script\|canonical\|persisted.*value\|config.*assert" "$iter_log" 2>/dev/null; then + missing_evidence+=("Semantic assertion evidence, not only screen text") fi - # Check for viewport evidence - if ! grep -qi "Viewport.*pass\|viewport check\|1024.*1280.*1920\|1024px\|viewport sanity" "$iter_log" 2>/dev/null; then - missing_evidence+=("Viewport sanity check (1024/1280/1920)") + if grep -q "Level: L4" "$iter_log" 2>/dev/null && \ + ! grep -qi "Aspire\|Docker\|container.*healthy\|resources healthy\|live provider\|demo smoke" "$iter_log" 2>/dev/null; then + missing_evidence+=("L4 runtime evidence (Aspire, Docker, live provider, or demo smoke)") fi if [[ ${#missing_evidence[@]} -gt 0 ]]; then @@ -280,7 +332,7 @@ verify_l3_evidence() { echo " - $item" done echo "" - echo " Per ralph-loop.md L3 Verification Checklist, these are MANDATORY when claiming L3." + echo " Per IMPLEMENTATION_PLAN.md Automation-First QA Floor, these are MANDATORY when claiming L3/L4." echo "" echo " Options:" echo " 1. Fix the iteration to include proper L3 evidence" @@ -315,8 +367,9 @@ for ((i=1; i<=ITERATIONS; i++)); do ITER_PAD=$(printf "%02d" "$i") ITER_LOG="${RUN_DIR}/iter-${ITER_PAD}.md" + ITER_START_COMMIT=$(git rev-parse HEAD) - if ! opencode run --model "$MODEL" "You are running RALPH iteration $i. + if ! opencode run --model "$MODEL" --variant "$VARIANT" "You are running RALPH iteration $i. ## Run Metadata (MUST USE) - RUN_ID: $RUN_ID @@ -333,56 +386,71 @@ for ((i=1; i<=ITERATIONS; i++)); do ## Instructions (ONE TASK ONLY) 1) Find the next incomplete task in IMPLEMENTATION_PLAN.md: - - Look for '### Task:' blocks with unchecked 'Done when:' items + - Look for '#### Task N.N:' blocks with unchecked 'Done when:' items - Work on the FIRST incomplete task you find - A task is complete only when ALL its Done-when checkboxes are satisfied 2) Determine MODE from Task Routing in AGENTS.md/CLAUDE.md (engineering/ux/marketing/ops/etc.) -3) Load relevant skills from .claude/skills/: - - REQUIRED for code: testing-strategy.md (if present — integration vs unit; no fakes) - - REQUIRED: ralph-loop.md (process discipline) - - If UI impacted: ui-smoke-validation.md (or follow UI validation policy) +3) Apply IMPLEMENTATION_PLAN.md quality gates: + - Identify the downstream consumer for any config/event/message/tool/schema/persistence output + - Prove canonical representation at the producer/consumer boundary + - Add negative-path coverage for invalid, unresolved, denied, malformed, or missing inputs + - For TUI input: add headless typed-key coverage and native smoke for critical flows + - For dynamic validation: add fake-failure tests proving save is blocked before persistence + - For old config migration/porting: add load/round-trip tests from old shape to runtime-consumed shape + +4) Load relevant skills from .claude/skills/: + - REQUIRED for code: testing-strategy.md (if present) + - REQUIRED: ralph-loop.md (process discipline, if present) + - If UI impacted: ui-smoke-validation.md (or follow IMPLEMENTATION_PLAN.md TUI gate) - If schema/events touched: extend-only-design.md (if present) -4) BEFORE coding: choose Verification Level (L0-L4) and state why: +5) BEFORE coding: choose Verification Level (L0-L4) and state why: - I/O coordination (DB/HTTP/actors/external) => L2+ (integration tests required) - - UI or UI dependency changed => L3+ (UI smoke / Playwright required) + - TUI/config/init changes => L3+ (native smoke required) + - Live provider/demo/container proof => L4 -5) Implement to satisfy ALL unchecked Done-when criteria for the chosen task. +6) Implement to satisfy ALL unchecked Done-when criteria for the chosen task. -6) Verify (must match chosen level): +7) Verify (must match chosen level): - Minimum: build + test (language-appropriate commands) - - If Level >= L3: run UI smoke/Playwright and check for console errors + - If Level >= L3: run native smoke and semantic assertion scripts - Follow any additional quality gates from AGENTS.md/CLAUDE.md -7) FLIGHT RECORDER (MANDATORY): +8) FLIGHT RECORDER (MANDATORY): - Write $ITER_LOG BEFORE committing. - Include: - - Task selected (exact title) - - Surface area classification - - Verification level chosen + reason - - Skills consulted - - Commands run + outcomes - - Deviations/skips + justification - - Follow-ups noticed but deferred + why + - Task selected (exact title) + - Surface area classification + - Verification level chosen + reason + - Producer/consumer contract identified (or why none applies) + - Skills consulted + - Commands run + outcomes + - Positive behavior verified + - Negative behavior verified + - Runtime/smoke/eval evidence or explicit blocker + - Deviations/skips + justification + - Follow-ups noticed but deferred + why - If you claim a command was run, it must appear in the log with outcome. - 'Log or it didn't happen.' -8) If verification passes: - - Commit to the current feature branch with a descriptive message +9) If verification passes: + - Commit to the current feature branch with a descriptive message using git commit -S - Update IMPLEMENTATION_PLAN.md checkboxes in the SAME commit - Update TOOLING.md if you used or discovered a new tool/resource + - Never use --no-gpg-sign -9) Stop at checkpoints (UI approval, architecture decisions, credential setup) and ask the user if needed. +10) Stop at checkpoints (UI approval, architecture decisions, credential setup) and ask the user if needed. -10) Exit - do NOT continue to additional tasks. +11) Exit - do NOT continue to additional tasks. ## Constraints (Constitution) - ONE iteration = ONE task block - Never commit to dev/main/master +- Never create unsigned commits - Follow constraints from AGENTS.md/CLAUDE.md -- Test against real infrastructure (per testing-strategy) +- Automate all proof that can reasonably be automated; manual testing is last-mile only "; then EXIT_CODE=$? echo "" @@ -394,6 +462,11 @@ for ((i=1; i<=ITERATIONS; i++)); do echo "" echo "Iteration $i complete" + if ! verify_signed_iteration_commit "$ITER_START_COMMIT"; then + echo "RALPH loop paused due to signed commit gate failure at iteration $i" + exit 1 + fi + # Run L3 verification gate if L3 was claimed if ! verify_l3_evidence "$ITER_LOG"; then echo "RALPH loop paused due to L3 verification gate failure at iteration $i" @@ -432,13 +505,13 @@ echo "==========================================" echo "" echo "Remaining incomplete tasks:" -grep -B5 '^\- \[ \]' "$PLAN_FILE" | grep '### Task:' | head -5 || echo "(none or legacy format)" +grep -B5 '^\- \[ \]' "$PLAN_FILE" | grep -E '#### Task [0-9]+\.[0-9]+:' | head -5 || echo "(none or legacy format)" echo "" echo "Running postmortem (skill): ralph-after-action" # Note: OpenCode doesn't have OpenProse plugin, so we invoke the skill directly. # This runs sequentially. For parallel execution, use ralph.sh with Claude Code. -if ! opencode run --model "$POSTMORTEM_MODEL" "/ralph-after-action RUN_ID=$RUN_ID RUN_DIR=$RUN_DIR branch=$(git branch --show-current)"; then +if ! opencode run --model "$POSTMORTEM_MODEL" --variant "$POSTMORTEM_VARIANT" "/ralph-after-action RUN_ID=$RUN_ID RUN_DIR=$RUN_DIR branch=$(git branch --show-current)"; then POSTMORTEM_EXIT=$? echo "" echo "Postmortem exited with code $POSTMORTEM_EXIT" diff --git a/scripts/smoke/lib/common.sh b/scripts/smoke/lib/common.sh index 08b371cee..3f0a0dbb6 100755 --- a/scripts/smoke/lib/common.sh +++ b/scripts/smoke/lib/common.sh @@ -11,12 +11,14 @@ # START_TIMEOUT_SECONDS daemon start/health timeout (default: 180) # STOP_TIMEOUT_SECONDS daemon stop timeout (default: 90) # STEP_TIMEOUT_SECONDS per-command timeout (default: 120) -# DAEMON_HEALTH_URL health endpoint base (default loopback:5199) +# DAEMON_BASE_URL health endpoint base (default loopback:56199) +# DAEMON_PORT daemon listen port (default: port from DAEMON_BASE_URL or 56199) START_TIMEOUT_SECONDS="${START_TIMEOUT_SECONDS:-180}" STOP_TIMEOUT_SECONDS="${STOP_TIMEOUT_SECONDS:-90}" STEP_TIMEOUT_SECONDS="${STEP_TIMEOUT_SECONDS:-120}" -DAEMON_BASE_URL="${DAEMON_BASE_URL:-http://127.0.0.1:5199}" +DAEMON_BASE_URL="${DAEMON_BASE_URL:-http://127.0.0.1:56199}" +DAEMON_PORT="${DAEMON_PORT:-${DAEMON_BASE_URL##*:}}" # ── Output / counters ──────────────────────────────────────────────────────── @@ -105,7 +107,7 @@ pid_is_smoke_daemon() { [[ -n "$exe" && "$exe" == "$NETCLAW_SMOKE_DAEMON" ]] } -# ensure_daemon_port_free — block until 127.0.0.1:5199 has no LISTEN socket. +# ensure_daemon_port_free — block until the configured smoke daemon port has no LISTEN socket. # Every tape and scenario daemon binds the same fixed port; a daemon orphaned # by an earlier NETCLAW_HOME is invisible to `netclaw daemon stop` (which only # signals the PID in the current home's PID file) and will squat the port, @@ -114,7 +116,7 @@ pid_is_smoke_daemon() { # port is still held after the timeout OR if it is held by a non-smoke # process we refuse to touch. ensure_daemon_port_free() { - local port=5199 + local port="$DAEMON_PORT" local deadline=$((SECONDS + 30)) while (( SECONDS < deadline )); do local holders diff --git a/scripts/smoke/run-native-tape.sh b/scripts/smoke/run-native-tape.sh index c18060eb0..905d433de 100755 --- a/scripts/smoke/run-native-tape.sh +++ b/scripts/smoke/run-native-tape.sh @@ -24,6 +24,7 @@ # KEEP_TEMP set to 1 to retain the combined tape for inspection # TAPE_PREAMBLE preamble file to prepend (default: /preamble.tape) # TAPE_BODY_DIR directory holding .tape (default: TAPES_DIR) +# TAPE_USER_HOME per-tape HOME dir; default /user-home- # # TAPE_PREAMBLE / TAPE_BODY_DIR let the `screenshots` mode of run-smoke.sh # point this runner at screenshot-preamble.tape and tests/smoke/tapes/ @@ -58,6 +59,7 @@ NETCLAW_BIN_DIR="$(cd "$(dirname "$NETCLAW_SMOKE_CLI")" && pwd)" # Per-tape NETCLAW_HOME on the host filesystem. NETCLAW_HOME="${NETCLAW_HOME:-$(mktemp -d)/tape-home-${TAPE_NAME}}" +TAPE_USER_HOME="${TAPE_USER_HOME:-$(mktemp -d "${TMPDIR:-/tmp}/user-home-${TAPE_NAME}.XXXXXX")}" # Preamble + body dir are overridable so the screenshots mode can swap in # screenshot-preamble.tape + tests/smoke/tapes/screenshots/. Defaults keep @@ -66,6 +68,13 @@ preamble="${TAPE_PREAMBLE:-${TAPES_DIR}/preamble.tape}" body="${TAPE_BODY_DIR:-${TAPES_DIR}}/${TAPE_NAME}.tape" assertion="${ASSERT_DIR}/${TAPE_NAME}.sh" +requires_assertion=false +case "$TAPE_NAME" in + init-wizard|provider-add|provider-rename|config-*) + requires_assertion=true + ;; +esac + if [[ ! -f "$preamble" ]]; then echo "ERROR: preamble not found at $preamble" >&2 exit 1 @@ -121,6 +130,7 @@ collect_failure_artifacts() { # means body tapes can use any token, not just the preamble. cat "$preamble" "$body" | sed \ -e "s|__NETCLAW_HOME__|${NETCLAW_HOME}|g" \ + -e "s|__NETCLAW_USER_HOME__|${TAPE_USER_HOME}|g" \ -e "s|__NETCLAW_BIN_DIR__|${NETCLAW_BIN_DIR}|g" \ -e "s|__NETCLAW_DAEMON__|${NETCLAW_SMOKE_DAEMON}|g" \ -e "s|__TAPE_NAME__|${TAPE_NAME}|g" \ @@ -163,7 +173,15 @@ if [[ -x "$assertion" ]]; then exit "$assert_status" fi elif [[ -f "$assertion" ]]; then + if [[ "$requires_assertion" == "true" ]]; then + echo "FAIL: $assertion exists but is not executable; config-writing tapes require semantic assertions." >&2 + exit 1 + fi + echo "WARNING: $assertion exists but is not executable; skipping." >&2 +elif [[ "$requires_assertion" == "true" ]]; then + echo "FAIL: missing semantic assertion script for config-writing tape ${TAPE_NAME}: ${assertion}" >&2 + exit 1 fi echo "==> ${TAPE_NAME}: OK" diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index 8ebff13b1..45e2cf1b0 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -36,6 +36,7 @@ # SMOKE_OLLAMA_MODEL primary model (default: qwen2:0.5b) # SMOKE_OLLAMA_ALT_MODEL alternate model (default: all-minilm:latest) # SMOKE_LOG_DIR artifact dir (default: ./smoke-logs) +# SMOKE_DAEMON_PORT isolated daemon port (default: 56199) # KEEP_RUN_ROOT set 1 to keep the temp run root set -euo pipefail @@ -53,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard init-wizard-reverse-proxy provider-add provider-rename tui-cleanup mcp-permissions approvals model-manager sessions-tui) +LIGHT_TAPES=(help init-wizard init-existing provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions approvals model-manager sessions-tui) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( @@ -73,7 +74,7 @@ FULL_SCENARIOS=("${LIGHT_SCENARIOS[@]}") # may emit several `Screenshot "/tmp/shot-.png"` directives. SHOT_FRAMES # is the full set of frame names the harness compares against baselines — it # MUST stay in sync with the Screenshot paths in those tapes. -SHOT_TAPES=(help wizard-screens provider-manager mcp-permissions) +SHOT_TAPES=(help wizard-screens provider-manager mcp-permissions config-search) SHOT_FRAMES=( help wizard-provider-picker @@ -81,6 +82,9 @@ SHOT_FRAMES=( provider-manager-empty mcp-permissions-server-list mcp-permissions-tool-grid + config-search-selection + config-search-brave-entry + config-search-saved ) usage() { @@ -154,6 +158,9 @@ RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/netclaw-smoke.XXXXXX")" export RUN_ROOT mkdir -p "${RUN_ROOT}/home" +SMOKE_DAEMON_PORT="${SMOKE_DAEMON_PORT:-56199}" +SMOKE_DAEMON_BASE_URL="http://127.0.0.1:${SMOKE_DAEMON_PORT}" + teardown_done=0 teardown() { [[ $teardown_done -eq 1 ]] && return 0 @@ -268,8 +275,17 @@ run_one_tape() { echo "Tape: ${tape}" echo "════════════════════════════════════════════════════════" local home="${RUN_ROOT}/home/tape-${tape}" + local user_home="${RUN_ROOT}/home/user-tape-${tape}" rm -rf "$home" - if ! NETCLAW_HOME="$home" \ + rm -rf "$user_home" + mkdir -p "$user_home" + if ! HOME="$user_home" \ + NETCLAW_HOME="$home" \ + TAPE_USER_HOME="$user_home" \ + NETCLAW_DAEMON_ENDPOINT="$SMOKE_DAEMON_BASE_URL" \ + NETCLAW_DAEMON__PORT="$SMOKE_DAEMON_PORT" \ + DAEMON_BASE_URL="$SMOKE_DAEMON_BASE_URL" \ + DAEMON_PORT="$SMOKE_DAEMON_PORT" \ NETCLAW_SMOKE_CLI="$NETCLAW_SMOKE_CLI" \ NETCLAW_SMOKE_DAEMON="$NETCLAW_SMOKE_DAEMON" \ ARTIFACT_DIR="${SMOKE_LOG_DIR}/tapes/${tape}" \ @@ -285,9 +301,18 @@ run_one_scenario() { echo "Scenario: ${scenario}" echo "════════════════════════════════════════════════════════" local home="${RUN_ROOT}/home/scenario-${scenario}" + local user_home="${RUN_ROOT}/home/user-scenario-${scenario}" rm -rf "$home" + rm -rf "$user_home" mkdir -p "$home" - if ! NETCLAW_HOME="$home" \ + mkdir -p "$user_home" + if ! HOME="$user_home" \ + NETCLAW_HOME="$home" \ + TAPE_USER_HOME="$user_home" \ + NETCLAW_DAEMON_ENDPOINT="$SMOKE_DAEMON_BASE_URL" \ + NETCLAW_DAEMON__PORT="$SMOKE_DAEMON_PORT" \ + DAEMON_BASE_URL="$SMOKE_DAEMON_BASE_URL" \ + DAEMON_PORT="$SMOKE_DAEMON_PORT" \ NETCLAW_SMOKE_CLI="$NETCLAW_SMOKE_CLI" \ NETCLAW_SMOKE_DAEMON="$NETCLAW_SMOKE_DAEMON" \ NETCLAW_DAEMON_PATH="$NETCLAW_SMOKE_DAEMON" \ @@ -311,8 +336,16 @@ run_shot_tape() { echo "Screenshot tape: ${tape}" echo "════════════════════════════════════════════════════════" local home="${RUN_ROOT}/home/shot-${tape}" + local user_home="${RUN_ROOT}/home/user-shot-${tape}" rm -rf "$home" - if ! NETCLAW_HOME="$home" \ + rm -rf "$user_home" + mkdir -p "$user_home" + if ! HOME="$user_home" \ + NETCLAW_HOME="$home" \ + NETCLAW_DAEMON_ENDPOINT="$SMOKE_DAEMON_BASE_URL" \ + NETCLAW_DAEMON__PORT="$SMOKE_DAEMON_PORT" \ + DAEMON_BASE_URL="$SMOKE_DAEMON_BASE_URL" \ + DAEMON_PORT="$SMOKE_DAEMON_PORT" \ NETCLAW_SMOKE_CLI="$NETCLAW_SMOKE_CLI" \ NETCLAW_SMOKE_DAEMON="$NETCLAW_SMOKE_DAEMON" \ ARTIFACT_DIR="${SMOKE_LOG_DIR}/tapes/shot-${tape}" \ diff --git a/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs b/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs index 9ecb47534..151c9d01d 100644 --- a/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs +++ b/src/Netclaw.Actors/Tools/ToolAudienceProfileResolver.cs @@ -173,21 +173,5 @@ private static bool IsMcpToolAllowed(McpServerName serverName, ToolName toolName } private static bool IsProfileManagedTool(ToolName toolName) - => toolName.Value is "shell_execute" - or "file_read" - or "file_write" - or "file_edit" - or "file_list" - or "attach_file" - or "web_search" - or "web_fetch" - or "skill_manage" - or "set_webhook" - or "list_webhooks" - or "delete_webhook" - or "set_reminder" - or "list_reminders" - or "cancel_reminder" - or "get_reminder_history" - or "set_working_directory"; + => ToolAudienceProfileToolCatalog.IsProfileManaged(toolName.Value); } diff --git a/src/Netclaw.Channels.Slack/SlackProbe.cs b/src/Netclaw.Channels.Slack/SlackProbe.cs index 06e60ff67..88ebe2add 100644 --- a/src/Netclaw.Channels.Slack/SlackProbe.cs +++ b/src/Netclaw.Channels.Slack/SlackProbe.cs @@ -40,7 +40,7 @@ public interface ISlackProbe Task ProbeAsync(string botToken, CancellationToken ct = default); /// - /// Resolves user-provided channel names to Slack channel IDs via conversations.list. + /// Resolves user-provided channel names or IDs to Slack channel IDs via conversations.list. /// Task ResolveChannelNamesAsync( string botToken, IReadOnlyList channelNames, CancellationToken ct = default); @@ -161,11 +161,13 @@ public async Task ResolveChannelNamesAsync( if (id is null) continue; // Check if this channel matches any remaining name (case-insensitive) + // or an already-resolved channel ID from an existing config. string? matchedInput = null; foreach (var input in remaining) { if (string.Equals(input, name, StringComparison.OrdinalIgnoreCase) || - string.Equals(input, nameNormalized, StringComparison.OrdinalIgnoreCase)) + string.Equals(input, nameNormalized, StringComparison.OrdinalIgnoreCase) || + string.Equals(input, id, StringComparison.Ordinal)) { matchedInput = input; break; @@ -174,7 +176,7 @@ public async Task ResolveChannelNamesAsync( if (matchedInput is not null) { - resolved.Add(new ResolvedSlackChannel(matchedInput, id)); + resolved.Add(new ResolvedSlackChannel(name ?? nameNormalized ?? matchedInput, id)); remaining.Remove(matchedInput); } } diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonApiAuthenticationTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonApiAuthenticationTests.cs index a301480b7..b9bc99192 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonApiAuthenticationTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonApiAuthenticationTests.cs @@ -98,6 +98,30 @@ public async Task ListPairedDevices_ReverseProxyLoopbackEndpoint_AttachesBearerT Assert.Empty(devices); } + [Fact] + public async Task ListPairedDevices_ReverseProxyWrittenAfterConstruction_AttachesBearerToken() + { + File.WriteAllText(_paths.NetclawConfigPath, "{\"configVersion\":1,\"Daemon\":{\"ExposureMode\":\"local\"}}"); + HttpRequestMessage? capturedRequest = null; + var api = CreateDaemonApi( + "http://127.0.0.1:5199", + request => + { + capturedRequest = request; + return FakeHttpMessageHandler.JsonResponse(Array.Empty()); + }); + + File.WriteAllText(_paths.NetclawConfigPath, "{\"configVersion\":1,\"Daemon\":{\"ExposureMode\":\"reverse-proxy\"}}"); + WriteDeviceToken("fresh-bootstrap-device-token"); + + var devices = await api.ListPairedDevicesAsync(TestContext.Current.CancellationToken); + + Assert.NotNull(capturedRequest); + Assert.Equal("Bearer", capturedRequest!.Headers.Authorization?.Scheme); + Assert.Equal("fresh-bootstrap-device-token", capturedRequest.Headers.Authorization?.Parameter); + Assert.Empty(devices); + } + [Fact] public void ResolveEndpoint_FallsBackToDaemonBindConfig() { diff --git a/src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs b/src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs new file mode 100644 index 000000000..a27cf0e1e --- /dev/null +++ b/src/Netclaw.Cli.Tests/Config/ConfigCommandTests.cs @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Config; + +public sealed class ConfigCommandTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + private readonly StringWriter _output = new(); + private readonly StringWriter _error = new(); + + public ConfigCommandTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() + { + _output.Dispose(); + _error.Dispose(); + _dir.Dispose(); + } + + [Fact] + public void Help_describes_post_install_dashboard() + { + var exitCode = ConfigCommand.Run(["config", "--help"], _paths, _output, _error); + + Assert.Equal(0, exitCode); + Assert.Contains("main post-install settings dashboard", _output.ToString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("netclaw init", _output.ToString(), StringComparison.Ordinal); + Assert.Equal(string.Empty, _error.ToString()); + } + + [Fact] + public void Missing_install_refuses_before_tui_startup() + { + var exitCode = ConfigCommand.Run(["config"], _paths, _output, _error); + + Assert.Equal(1, exitCode); + Assert.Equal(ConfigCommand.MissingConfigMessage + Environment.NewLine, _error.ToString()); + Assert.Equal(string.Empty, _output.ToString()); + } + + [Fact] + public void Configured_install_allows_dashboard_launch() + { + File.WriteAllText(_paths.NetclawConfigPath, "{\"configVersion\":1}"); + + var exitCode = ConfigCommand.Run(["config"], _paths, _output, _error); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, _output.ToString()); + Assert.Equal(string.Empty, _error.ToString()); + } + + [Fact] + public void Unexpected_arguments_return_usage_error() + { + var exitCode = ConfigCommand.Run(["config", "extra"], _paths, _output, _error); + + Assert.Equal(1, exitCode); + Assert.Contains("Usage: netclaw config", _output.ToString(), StringComparison.Ordinal); + } +} diff --git a/src/Netclaw.Cli.Tests/Doctor/InboundWebhookRoutesDoctorCheckTests.cs b/src/Netclaw.Cli.Tests/Doctor/InboundWebhookRoutesDoctorCheckTests.cs index e228e6105..596f8dcc7 100644 --- a/src/Netclaw.Cli.Tests/Doctor/InboundWebhookRoutesDoctorCheckTests.cs +++ b/src/Netclaw.Cli.Tests/Doctor/InboundWebhookRoutesDoctorCheckTests.cs @@ -36,6 +36,43 @@ public async Task ReturnsPass_WhenNoRouteFilesExist() Assert.Contains("No inbound webhook route files", result.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task ReturnsWarning_WhenInboundWebhooksEnabledWithoutRoutes() + { + // Enable-first is a valid setup order: `Webhooks.Enabled` is only the feature + // toggle and the gateway is inert (404s) until routes are added — advisory, not error. + File.WriteAllText(_paths.NetclawConfigPath, "{\"configVersion\":1,\"Webhooks\":{\"Enabled\":true}}"); + var check = new InboundWebhookRoutesDoctorCheck(_paths); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Warning, result.Severity); + Assert.Contains("enabled but no route files", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("netclaw webhooks set", result.Remediation, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReturnsWarning_WhenInboundWebhooksEnabledButAllRoutesDisabled() + { + File.WriteAllText(_paths.NetclawConfigPath, "{\"configVersion\":1,\"Webhooks\":{\"Enabled\":true}}"); + WriteRouteFile("github-issues", new WebhookRouteConfig + { + Enabled = false, + Prompt = "triage this event", + Verification = new WebhookVerificationConfig + { + Kind = WebhookVerifierKind.Hmac, + Secret = new SensitiveString("secret") + } + }); + var check = new InboundWebhookRoutesDoctorCheck(_paths); + + var result = await check.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(DoctorSeverity.Warning, result.Severity); + Assert.Contains("no valid enabled route", result.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ReturnsPass_WhenRouteFileIsValid() { diff --git a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs index 723f60c7c..280f04f22 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsPageTests.cs @@ -328,6 +328,19 @@ public async Task ToolGrid_ManyTools_HeaderRowsNotOverwrittenByScrollContent() $"Expected 'Server default' row not overwritten by tool list. Screen:\n{terminal}"); } + [Fact] + public async Task Loading_Escape_QuitsInsteadOfStalling() + { + var (_, app, vm) = CreateHeadlessApp(out var input); + + input.EnqueueKey(ConsoleKey.Escape); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(ToolPermissionsState.Loading, vm.CurrentState.Value); + } + // ── Helpers ────────────────────────────────────────────────────────────── private (VirtualTerminal Terminal, TerminaApplication App, McpToolPermissionsViewModel Vm) diff --git a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs index d1c02432f..02c34d477 100644 --- a/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Mcp/McpToolPermissionsViewModelTests.cs @@ -28,59 +28,101 @@ public McpToolPermissionsViewModelTests() public void Dispose() => _dir.Dispose(); - private McpToolPermissionsViewModel CreateVm() + private McpToolPermissionsViewModel CreateVm(McpToolPermissionsNavigationState? navigationState = null) { var configuration = new ConfigurationBuilder().Build(); var daemonApi = new DaemonApi(new NoopHttpClientFactory(), configuration, _paths); - return new McpToolPermissionsViewModel(_paths, daemonApi); + return new McpToolPermissionsViewModel(_paths, daemonApi, navigationState); } [Fact] - public void CycleServerDefault_StartingFromAuto_LandsOnDenyAfterTwoCycles() + public void InitializeForTests_AppliesRequestedInitialAudience() { - var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); + var navigationState = new McpToolPermissionsNavigationState(); + navigationState.RequestInitialAudience(TrustAudience.Team); + var vm = CreateVm(navigationState); - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Deny, vm.GetServerDefault()); + Assert.Equal(TrustAudience.Team, vm.SelectedAudience); + } - vm.CycleServerDefault(); - Assert.Equal(ToolApprovalMode.Auto, vm.GetServerDefault()); + [Fact] + public void InitializeForTests_ThrowsForMalformedConfig() + { + var vm = CreateVm(); + File.WriteAllText(_paths.NetclawConfigPath, "{ not json"); + + Assert.ThrowsAny(() => + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" })); } [Fact] - public void CycleToolOverride_FromInherit_CyclesThroughAllModes() + public async Task LoadServers_NonObjectDaemonBody_SurfacesStatusInsteadOfThrowing() + { + // A 200 whose body is a JSON array (not the expected object map) makes EnumerateObject() + // throw. LoadServersAsync runs fire-and-forget from OnActivated, so an unhandled throw + // would fault page activation; the VM must instead surface a status message and not throw. + var configuration = new ConfigurationBuilder().Build(); + var daemonApi = new DaemonApi(new StubStatusesHttpClientFactory("[]"), configuration, _paths); + var vm = new McpToolPermissionsViewModel(_paths, daemonApi, navigationState: null); + + await vm.LoadServersAsync(); + + Assert.Empty(vm.Servers); + Assert.Contains("Could not read MCP server statuses", vm.StatusMessage.Value); + } + + public static TheoryData ServerDefaultCycles => new() + { + { false, [ToolApprovalMode.Approval, ToolApprovalMode.Deny, ToolApprovalMode.Auto] }, + { true, [ToolApprovalMode.Deny, ToolApprovalMode.Approval, ToolApprovalMode.Auto] } + }; + + public static TheoryData ToolOverrideCycles => new() + { + { false, [ToolApprovalMode.Auto, ToolApprovalMode.Approval, ToolApprovalMode.Deny] }, + { true, [ToolApprovalMode.Deny, ToolApprovalMode.Approval, ToolApprovalMode.Auto] } + }; + + [Theory] + [MemberData(nameof(ServerDefaultCycles))] + public void CycleServerDefault_CyclesThroughModes(bool reverse, ToolApprovalMode[] expectedModes) { var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); vm.SetSelectedAudienceForTests(TrustAudience.Personal); - // Initial: inherit (effective mode resolves from server default / global default). - var (_, isInherited) = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(isInherited); + foreach (var expectedMode in expectedModes) + { + CycleServerDefault(vm, reverse); + Assert.Equal(expectedMode, vm.GetServerDefault()); + } + } - vm.CycleToolOverride(new ToolName("create-pages")); - var step1 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Auto, step1.Mode); - Assert.False(step1.IsInherited); + [Theory] + [MemberData(nameof(ToolOverrideCycles))] + public void CycleToolOverride_CyclesThroughModes(bool reverse, ToolApprovalMode[] expectedModes) + { + var vm = CreateVm(); + var toolName = new ToolName("create-pages"); + vm.InitializeForTests(new McpServerName("notion"), new[] { toolName.Value }); + vm.SetSelectedAudienceForTests(TrustAudience.Personal); - vm.CycleToolOverride(new ToolName("create-pages")); - var step2 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Approval, step2.Mode); - Assert.False(step2.IsInherited); + var (_, isInherited) = vm.GetEffectiveMode(toolName); + Assert.True(isInherited); - vm.CycleToolOverride(new ToolName("create-pages")); - var step3 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Deny, step3.Mode); - Assert.False(step3.IsInherited); + foreach (var expectedMode in expectedModes) + { + CycleToolOverride(vm, toolName, reverse); + var step = vm.GetEffectiveMode(toolName); + Assert.Equal(expectedMode, step.Mode); + Assert.False(step.IsInherited); + } - vm.CycleToolOverride(new ToolName("create-pages")); - var step4 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(step4.IsInherited); + CycleToolOverride(vm, toolName, reverse); + var final = vm.GetEffectiveMode(toolName); + Assert.True(final.IsInherited); } [Fact] @@ -105,7 +147,7 @@ public void Save_WritesServerDefaultsAndOverridesAndRemovesInheritedEntries() vm.Save(); - var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); var approvalPolicy = doc.RootElement .GetProperty("Tools") .GetProperty("AudienceProfiles") @@ -156,53 +198,6 @@ public void GetEffectiveMode_ReadsExistingMcpServerDefaultsFromConfig() Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); } - [Fact] - public void CycleServerDefaultBack_StartingFromAuto_CyclesInReverse() - { - var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages", "search" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); - - vm.CycleServerDefaultBack(); - Assert.Equal(ToolApprovalMode.Deny, vm.GetServerDefault()); - - vm.CycleServerDefaultBack(); - Assert.Equal(ToolApprovalMode.Approval, vm.GetServerDefault()); - - vm.CycleServerDefaultBack(); - Assert.Equal(ToolApprovalMode.Auto, vm.GetServerDefault()); - } - - [Fact] - public void CycleToolOverrideBack_FromInherit_CyclesThroughAllModesInReverse() - { - var vm = CreateVm(); - vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); - vm.SetSelectedAudienceForTests(TrustAudience.Personal); - - var (_, isInherited) = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(isInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step1 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Deny, step1.Mode); - Assert.False(step1.IsInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step2 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Approval, step2.Mode); - Assert.False(step2.IsInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step3 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.Equal(ToolApprovalMode.Auto, step3.Mode); - Assert.False(step3.IsInherited); - - vm.CycleToolOverrideBack(new ToolName("create-pages")); - var step4 = vm.GetEffectiveMode(new ToolName("create-pages")); - Assert.True(step4.IsInherited); - } - [Fact] public void CycleToolOverride_ForwardThenBack_ReturnsToOriginalState() { @@ -254,8 +249,163 @@ public void ToggleServerAccess_DisablingClearsGrantedTools() Assert.False(vm.IsToolGranted(new ToolName(tool))); } + [Fact] + public void Save_DisablingServerFromAllowlistPreservesOtherServers() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Tools": { + "AudienceProfiles": { + "Team": { + "McpServersMode": "Allowlist", + "AllowedMcpServers": ["notion", "github"] + } + } + } + } + """); + + var vm = CreateVm(); + vm.Servers.Add(("notion", "running", 1)); + vm.Servers.Add(("github", "running", 1)); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.SetSelectedAudienceForTests(TrustAudience.Team); + + vm.ToggleServerAccess(); + Assert.True(vm.Save()); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var team = GetAudienceProfile(doc, "Team"); + Assert.Equal("Allowlist", team.GetProperty("McpServersMode").GetString()); + + var servers = ReadAllowedServers(team); + Assert.DoesNotContain("notion", servers); + Assert.Contains("github", servers); + } + + [Fact] + public void Save_DisablingServerFromAllProfileConvertsToAllowlist() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "McpServers": { + "github": { "Transport": "stdio" } + }, + "Tools": { + "AudienceProfiles": { + "Personal": { + "McpServersMode": "All" + } + } + } + } + """); + + var vm = CreateVm(); + vm.Servers.Add(("notion", "running", 1)); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.SetSelectedAudienceForTests(TrustAudience.Personal); + + vm.ToggleServerAccess(); + Assert.True(vm.Save()); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var personal = GetAudienceProfile(doc, "Personal"); + Assert.Equal("Allowlist", personal.GetProperty("McpServersMode").GetString()); + + var servers = ReadAllowedServers(personal); + Assert.DoesNotContain("notion", servers); + Assert.Contains("github", servers); + + var reloaded = CreateVm(); + reloaded.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + reloaded.SetSelectedAudienceForTests(TrustAudience.Personal); + Assert.False(reloaded.IsServerAllowedForSelectedAudience()); + } + + [Fact] + public void Save_DoesNotMutateTheLiveInMemoryProfile() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "McpServers": { "github": { "Transport": "stdio" } }, + "Tools": { + "AudienceProfiles": { + "Personal": { "McpServersMode": "All" } + } + } + } + """); + + var vm = CreateVm(); + vm.Servers.Add(("notion", "running", 1)); + vm.InitializeForTests(new McpServerName("notion"), new[] { "create-pages" }); + vm.SetSelectedAudienceForTests(TrustAudience.Personal); + Assert.Equal(ToolProfileMode.All, vm.Profiles.Personal.McpServersMode); + + vm.ToggleServerAccess(); // disable notion -> pending All->Allowlist conversion + Assert.True(vm.Save()); + + // The save writes the Allowlist conversion to disk, but must NOT coerce the live in-memory + // profile that backs runtime ACL queries (IsServerAllowed, etc.). The prior code mutated it + // mid-save, so a mid-save exception would leave the ACL in a post-save allowlist state. + Assert.Equal(ToolProfileMode.All, vm.Profiles.Personal.McpServersMode); + Assert.Empty(vm.Profiles.Personal.AllowedMcpServers); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal("Allowlist", GetAudienceProfile(doc, "Personal").GetProperty("McpServersMode").GetString()); + } + + private static void CycleServerDefault(McpToolPermissionsViewModel vm, bool reverse) + { + if (reverse) + vm.CycleServerDefaultBack(); + else + vm.CycleServerDefault(); + } + + private static void CycleToolOverride(McpToolPermissionsViewModel vm, ToolName toolName, bool reverse) + { + if (reverse) + vm.CycleToolOverrideBack(toolName); + else + vm.CycleToolOverride(toolName); + } + + private static JsonElement GetAudienceProfile(JsonDocument doc, string audienceName) + => doc.RootElement + .GetProperty("Tools") + .GetProperty("AudienceProfiles") + .GetProperty(audienceName); + + private static string[] ReadAllowedServers(JsonElement profile) + => profile.GetProperty("AllowedMcpServers") + .EnumerateArray() + .Select(static server => server.GetString() ?? string.Empty) + .ToArray(); + private sealed class NoopHttpClientFactory : IHttpClientFactory { public HttpClient CreateClient(string name) => new(); } + + // Returns a 200 with a fixed body for every request, so the daemon-statuses call succeeds and + // the VM exercises its response-shape handling rather than a connection failure. + private sealed class StubStatusesHttpClientFactory(string body) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new StubHandler(body)); + + private sealed class StubHandler(string body) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage { Content = new StringContent(body) }); + } + } } diff --git a/src/Netclaw.Cli.Tests/Tui/Config/BrowserAutomationConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/BrowserAutomationConfigViewModelTests.cs new file mode 100644 index 000000000..79781945f --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/BrowserAutomationConfigViewModelTests.cs @@ -0,0 +1,150 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class BrowserAutomationConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public BrowserAutomationConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Browser_automation_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Browser Automation")); + + Assert.Equal("/browser-automation", route); + } + + [Fact] + public void Server_entry_without_explicit_Enabled_flag_is_treated_as_disabled() + { + // Default-deny: a browser MCP server entry that exists but omits the `Enabled` field (a + // hand-edited or externally synthesized config) must NOT be treated as enabled. The prior + // code fell back to enabled=true, silently activating the server. + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"McpServers\":{\"browser_playwright\":{\"Transport\":\"stdio\",\"Command\":\"npx\"}}}"); + using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(true)); + + Assert.False(vm.Enabled.Value); + } + + [Fact] + public void Save_refuses_enablement_when_prerequisites_are_missing() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(false)); + + Assert.False(vm.ToggleEnabled()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("missing", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.False(vm.Enabled.Value); + } + + [Fact] + public void Save_persists_playwright_canonical_mcp_profile_for_runtime_binding() + { + using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(true)); + + vm.ToggleEnabled(); + + Assert.True(vm.Save()); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_playwright.Transport", out var transport)); + Assert.Equal("stdio", transport); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_playwright.GrantCategory", out var grant)); + Assert.Equal("browser_automation", grant); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_playwright.Enabled", out var enabled)); + Assert.Equal(true, enabled); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_chrome_devtools", out _)); + + var bound = BindMcpServers(); + Assert.True(bound.TryGetValue("browser_playwright", out var entry)); + Assert.Equal("stdio", entry.Transport); + Assert.Equal("browser_automation", entry.GrantCategory); + } + + [Fact] + public void Switching_backend_removes_inactive_canonical_profile() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"McpServers\":{\"browser_playwright\":{\"Transport\":\"stdio\",\"Command\":\"npx\",\"Enabled\":true}}}"); + using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(true)); + + vm.CycleBackend(1); + + Assert.True(vm.Save()); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_playwright", out _)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_chrome_devtools.Transport", out var transport)); + Assert.Equal("stdio", transport); + } + + [Fact] + public void Disable_removes_only_canonical_browser_profiles() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"McpServers\":{\"browser_playwright\":{\"Transport\":\"stdio\",\"Enabled\":true},\"memorizer\":{\"Transport\":\"stdio\",\"Command\":\"uvx\",\"Enabled\":true}}}"); + using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(true)); + + vm.ToggleEnabled(); + + Assert.True(vm.Save()); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_playwright", out _)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "McpServers.memorizer.Transport", out var transport)); + Assert.Equal("stdio", transport); + } + + [Fact] + public void Mcp_permissions_route_is_forwarded_without_raw_grant_editing() + { + using var vm = new BrowserAutomationConfigViewModel(_paths, new FakeProbe(true)); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.OpenMcpPermissions(); + + Assert.Equal("/mcp-tools", route); + } + + private Dictionary BindMcpServers() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(_paths.NetclawConfigPath) + .Build(); + return configuration.GetSection("McpServers").Get>()!; + } + + private sealed class FakeProbe(bool canEnable) : IBrowserAutomationPrerequisiteProbe + { + public BrowserAutomationPrerequisiteStatus Detect(BrowserAutomationBackend backend) + => canEnable + ? new BrowserAutomationPrerequisiteStatus(true, "ok", [], []) + : new BrowserAutomationPrerequisiteStatus(false, "missing", ["Node.js with npx"], ["Install Node.js manually."]); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs new file mode 100644 index 000000000..70272c2ca --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigNavigationTests.cs @@ -0,0 +1,709 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Channels; +using Netclaw.Channels.Slack; +using Netclaw.Cli.Config; +using Netclaw.Cli.Discord; +using Netclaw.Cli.Tests.Tui; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ChannelsConfigNavigationTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ChannelsConfigNavigationTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { "Enabled": true, "AllowedChannelIds": ["C01"] }, + "Discord": { "Enabled": true, "AllowedChannelIds": ["123456789"] }, + "Mattermost": { + "Enabled": true, + "ServerUrl": "https://mattermost.example.com", + "AllowedChannelIds": ["town-square"] + } + } + """); + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Slack": { "BotToken": "xoxb-existing", "AppToken": "xapp-existing" }, + "Discord": { "BotToken": "discord-existing" }, + "Mattermost": { "BotToken": "mattermost-existing" } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task Channels_Escape_ReturnsToDashboardUsingTerminaHistory() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + dashboardVm.SelectedIndex.Value = dashboardVm.Items + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Label == "Channels") + .index; + + dashboardVm.ActivateSelected(); + input.EnqueueKey(ConsoleKey.Escape); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.NotNull(getChannelsVm()); + Assert.Equal("/config", app.CurrentPath); + } + + [Fact] + public async Task Channels_DoneAddingChannelsRow_ReturnsToDashboardUsingTerminaHistory() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.NotNull(getChannelsVm()); + Assert.Equal("/config", app.CurrentPath); + } + + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Channels_RotateCredentials_AcceptsTypedCredentialInput(ChannelType channelType) + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + MoveToAdapter(input, channelType); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured adapter management. + MoveToRotateCredentials(input); + input.EnqueueKey(ConsoleKey.Enter); // Rotate credentials. + TypeCredentials(input, channelType); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + AssertPersistedCredentials(channelType, typed: true); + Assert.Equal("Credential changes saved.", channelsVm.Status.Value.Text); + } + + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Channels_FirstTimeAdapterSetup_AcceptsTypedCredentialInput(ChannelType channelType) + { + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + MoveToAdapter(input, channelType); + + input.EnqueueKey(ConsoleKey.Enter); // Enable selected adapter and enter first-time setup. + TypeFirstTimeSetup(input, channelType); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, channelsVm.Screen.Value); + Assert.Equal(channelType, channelsVm.ActiveAdapterType); + AssertFirstTimeSetupPersisted(channelsVm, channelType); + } + + [Fact] + public async Task Channels_FirstTimeSlackSetup_AcceptsPastedCredentialInput() + { + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Enable Slack and enter first-time setup. + input.EnqueuePaste("xoxb-pasted-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueuePaste("xapp-pasted-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.Equal("xoxb-pasted-token", slack.BotToken); + Assert.Equal("xapp-pasted-token", slack.AppToken); + } + + [Fact] + public async Task Channels_SlackAllowedUserIds_AcceptsPasteAfterTyping() + { + // Regression: Termina auto-routes a bracketed paste straight into the focused + // TextInputNode, bypassing the page's PasteEvent handler. Because the node is rebuilt + // and re-seeded from the view-model every render, a paste that lands only in the node + // was wiped by the next reseed unless it was synced back. After typing one ID by hand, + // pasting a second must land in the view-model immediately (via TextChanged sync). + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Enable Slack -> bot token substep. + input.EnqueuePaste("xoxb-token"); + input.EnqueueKey(ConsoleKey.Enter); // -> app token substep. + input.EnqueuePaste("xapp-token"); + input.EnqueueKey(ConsoleKey.Enter); // -> channel names substep. + input.EnqueueKey(ConsoleKey.Enter); // skip channel names -> DM substep. + input.EnqueueKey(ConsoleKey.Enter); // DM default -> user access choice substep. + input.EnqueueKey(ConsoleKey.Enter); // "Restrict to specific users" default -> allowed user IDs substep. + input.EnqueueString("U044U1S8P,"); // Type the first ID by hand. + input.EnqueuePaste("U12345678"); // Then paste a second ID into the non-empty field. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.Equal("U044U1S8P,U12345678", slack.AllowedUserIdsInput); + } + + [Fact] + public async Task Channels_SlackBotToken_AcceptsPasteAfterTyping() + { + // Same auto-routed-paste regression for a credential field, which syncs through the + // BotTokenDraft path: type a token prefix, then paste the rest into the non-empty field. + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Enable Slack -> bot token substep. + input.EnqueueString("xoxb-"); // Type the prefix by hand. + input.EnqueuePaste("0123456789"); // Paste the rest into the non-empty field. + input.EnqueueKey(ConsoleKey.Enter); // Submit -> advances, capturing the full token. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.Equal("xoxb-0123456789", slack.BotToken); + } + + [Fact] + public async Task Channels_AddChannel_AcceptsPastedChannelInput() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management. + input.EnqueueKey(ConsoleKey.DownArrow); // Add channel. + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueuePaste("#C09"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + Assert.Contains(channelsVm.GetChannelRows(), row => row.Id == "C09" && !row.IsAddAction); + Assert.Equal("Added C09 at the Team default and saved.", channelsVm.Status.Value.Text); + } + + [Fact] + public async Task Channels_ChannelPermissions_DoesNotRemoveSelectedChannelWithDoneKey() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management. + input.EnqueueKey(ConsoleKey.Enter); // Manage channels and permissions. + input.EnqueueKey(ConsoleKey.D); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + Assert.Contains(channelsVm.GetChannelRows(), row => row.Id == "C01" && !row.IsAddAction); + } + + [Fact] + public async Task Channels_ChannelPermissions_DoneRow_ReturnsToAdapterMenu() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + MoveToAdapter(input, ChannelType.Discord); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Discord management. + input.EnqueueKey(ConsoleKey.Enter); // Manage channels and permissions. + input.EnqueueKey(ConsoleKey.DownArrow); // + Add channel. + input.EnqueueKey(ConsoleKey.DownArrow); // Done adding channels. + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + Assert.Equal(ChannelsConfigScreen.AdapterMenu, channelsVm.Screen.Value); + Assert.Equal("Done adding channels. Completed changes are already saved.", channelsVm.Status.Value.Text); + } + + [Fact] + public async Task Channels_ChannelPermissions_DeleteRemovesSelectedChannel() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management. + input.EnqueueKey(ConsoleKey.Enter); // Manage channels and permissions. + input.EnqueueKey(ConsoleKey.Delete); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + Assert.DoesNotContain(channelsVm.GetChannelRows(), row => row.Id == "C01"); + Assert.Equal("Removed C01 and saved.", channelsVm.Status.Value.Text); + } + + [Fact] + public async Task Channels_ChannelPermissions_RendersResolvedDiscordLabelWithoutRawId() + { + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, + null, + [new ResolvedDiscordChannel("123456789", "general", "NetclawTest")], + []) + }; + var app = CreateHeadlessApp( + out var input, + out var dashboardVm, + out _, + out var terminal, + discordProbe: discordProbe); + OpenChannels(dashboardVm); + MoveToAdapter(input, ChannelType.Discord); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Discord management. + input.EnqueueKey(ConsoleKey.Enter); // Manage channels and permissions. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var screen = terminal.ToString(); + Assert.Contains("NetclawTest / #general", screen); + Assert.DoesNotContain("123456789", screen); + } + + [Fact] + public async Task Channels_FirstTimeSlackBotToken_ShowsValidationError() + { + WriteEmptyChannelFiles(); + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Enable Slack and enter first-time setup. + input.EnqueueString("not-a-slack-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, channelsVm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, channelsVm.Status.Value.Tone); + Assert.Equal(1, slack.CurrentSubStep); + Assert.Null(slack.BotToken); + } + + [Fact] + public async Task Channels_RotateCredentials_InvalidSlackBotToken_ShowsValidationError() + { + var app = CreateHeadlessApp(out var input, out var dashboardVm, out var getChannelsVm); + OpenChannels(dashboardVm); + + input.EnqueueKey(ConsoleKey.Enter); // Open configured Slack management. + MoveToRotateCredentials(input); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("not-a-slack-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + var slack = channelsVm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.Equal(ChannelsConfigScreen.RotateCredentials, channelsVm.Screen.Value); + Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, channelsVm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, channelsVm.Status.Value.Tone); + Assert.Null(slack.BotToken); + } + + [Fact] + public async Task Channels_EnableSlackByName_thenDiscordById_persistsBothSectionsAndSecrets() + { + // End-to-end reproduction of the reported live trace: a fresh config, enable + // Slack through the picker sub-flow entering a channel NAME (resolved to an ID + // on the completion autosave), then enable Discord entering a channel ID, then + // Escape back to the dashboard. Both sections + bot tokens must survive on disk. + WriteEmptyChannelFiles(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, null, [new ResolvedSlackChannel("general", "C100")], []) + }; + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, null, [new ResolvedDiscordChannel("555000111", "ops", "Guild")], []) + }; + var app = CreateHeadlessApp( + out var input, + out var dashboardVm, + out var getChannelsVm, + out _, + slackProbe: slackProbe, + discordProbe: discordProbe); + OpenChannels(dashboardVm); + + // Slack: Enter to enable + enter sub-flow, type tokens + channel NAME. + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("xoxb-live"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("xapp-live"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("general"); // NAME, not ID. + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone. + // Now on ChannelPermissions for Slack; go back to the picker. + input.EnqueueKey(ConsoleKey.Escape); // ChannelPermissions -> AdapterMenu. + input.EnqueueKey(ConsoleKey.Escape); // AdapterMenu -> Picker. + + // Discord: move down, Enter to enable + sub-flow, type token + channel ID. + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("discord-live"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("555000111"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone. + input.EnqueueKey(ConsoleKey.Escape); // ChannelPermissions -> AdapterMenu. + input.EnqueueKey(ConsoleKey.Escape); // AdapterMenu -> Picker. + + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var channelsVm = Assert.IsType(getChannelsVm()); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.Enabled", out var slackEnabled), "Slack.Enabled missing"); + Assert.True(Assert.IsType(slackEnabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var slackCh), "Slack channels missing"); + Assert.Equal(["C100"], ToStringArray(slackCh)); + AssertSecret(secrets, "Slack.BotToken", "xoxb-live"); + + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.Enabled", out var discordEnabled), "Discord.Enabled missing"); + Assert.True(Assert.IsType(discordEnabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.AllowedChannelIds", out var discordCh), "Discord channels missing"); + Assert.Equal(["555000111"], ToStringArray(discordCh)); + AssertSecret(secrets, "Discord.BotToken", "discord-live"); + } + + private static void OpenChannels(ConfigDashboardViewModel dashboardVm) + { + dashboardVm.SelectedIndex.Value = dashboardVm.Items + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Label == "Channels") + .index; + dashboardVm.ActivateSelected(); + } + + private static void MoveToAdapter(VirtualInputSource input, ChannelType channelType) + { + var adapterIndex = channelType switch + { + ChannelType.Slack => 0, + ChannelType.Discord => 1, + ChannelType.Mattermost => 2, + _ => throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null) + }; + + for (var i = 0; i < adapterIndex; i++) + input.EnqueueKey(ConsoleKey.DownArrow); + } + + private static void MoveToRotateCredentials(VirtualInputSource input) + { + for (var i = 0; i < 4; i++) + input.EnqueueKey(ConsoleKey.DownArrow); + } + + private static void TypeCredentials(VirtualInputSource input, ChannelType channelType) + { + switch (channelType) + { + case ChannelType.Slack: + input.EnqueueString("xoxb-typed-token"); + input.EnqueueKey(ConsoleKey.Tab); + input.EnqueueString("xapp-typed-token"); + break; + case ChannelType.Discord: + input.EnqueueString("discord-typed-token"); + break; + case ChannelType.Mattermost: + input.EnqueueKey(ConsoleKey.A, false, false, true); + input.EnqueueKey(ConsoleKey.Backspace); + input.EnqueueString("https://typed-mattermost.example.com"); + input.EnqueueKey(ConsoleKey.Tab); + input.EnqueueString("mattermost-typed-token"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private static void TypeFirstTimeSetup(VirtualInputSource input, ChannelType channelType) + { + switch (channelType) + { + case ChannelType.Slack: + input.EnqueueString("xoxb-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("xapp-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("C123456"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone in allowed channels. + break; + case ChannelType.Discord: + input.EnqueueString("discord-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("123456789012345678"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone in allowed channels. + break; + case ChannelType.Mattermost: + input.EnqueueString("https://first-time-mattermost.example.com"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("mattermost-first-time-token"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("town-square"); + input.EnqueueKey(ConsoleKey.Enter); + SelectSecondOption(input); // Disable DMs. + SelectSecondOption(input); // Allow anyone in allowed channels. + input.EnqueueKey(ConsoleKey.Enter); // Skip optional callback URL. + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private static void SelectSecondOption(VirtualInputSource input) + { + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + } + + private void AssertPersistedCredentials(ChannelType channelType, bool typed) + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + switch (channelType) + { + case ChannelType.Slack: + AssertSecret(secrets, "Slack.BotToken", typed ? "xoxb-typed-token" : "xoxb-first-time-token"); + AssertSecret(secrets, "Slack.AppToken", typed ? "xapp-typed-token" : "xapp-first-time-token"); + break; + case ChannelType.Discord: + AssertSecret(secrets, "Discord.BotToken", typed ? "discord-typed-token" : "discord-first-time-token"); + break; + case ChannelType.Mattermost: + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Mattermost.ServerUrl", out var serverUrl)); + Assert.Equal(typed ? "https://typed-mattermost.example.com" : "https://first-time-mattermost.example.com", serverUrl); + AssertSecret(secrets, "Mattermost.BotToken", typed ? "mattermost-typed-token" : "mattermost-first-time-token"); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private void AssertFirstTimeSetupPersisted(ChannelsConfigViewModel vm, ChannelType channelType) + { + AssertPersistedCredentials(channelType, typed: false); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + switch (channelType) + { + case ChannelType.Slack: + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.True(slack.HasPersistedBotToken); + Assert.True(slack.HasPersistedAppToken); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var slackChannelsRaw)); + Assert.Equal(["C123456"], ToStringArray(slackChannelsRaw)); + break; + case ChannelType.Discord: + var discord = vm.Step.GetAdapterViewModel(ChannelType.Discord); + Assert.True(discord.HasPersistedBotToken); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.AllowedChannelIds", out var discordChannelsRaw)); + Assert.Equal(["123456789012345678"], ToStringArray(discordChannelsRaw)); + break; + case ChannelType.Mattermost: + var mattermost = vm.Step.GetAdapterViewModel(ChannelType.Mattermost); + Assert.True(mattermost.HasPersistedBotToken); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Mattermost.AllowedChannelIds", out var mattermostChannelsRaw)); + Assert.Equal(["town-square"], ToStringArray(mattermostChannelsRaw)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(channelType), channelType, null); + } + } + + private void AssertSecret(Dictionary secrets, string path, string expected) + { + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, path, out var raw)); + Assert.Equal(expected, ConfigFileHelper.DecryptIfEncrypted(_paths, raw?.ToString())); + } + + private static string[] ToStringArray(object? raw) + => Assert.IsType(raw).Select(static value => value switch + { + string text => text, + System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Expected string array value.") + }).ToArray(); + + private void WriteEmptyChannelFiles() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1 + } + """); + } + + private TerminaApplication CreateHeadlessApp( + out VirtualInputSource input, + out ConfigDashboardViewModel dashboardVm, + out Func getChannelsVm) + => CreateHeadlessApp( + out input, + out dashboardVm, + out getChannelsVm, + out _, + slackProbe: null, + discordProbe: null, + mattermostProbe: null); + + private TerminaApplication CreateHeadlessApp( + out VirtualInputSource input, + out ConfigDashboardViewModel dashboardVm, + out Func getChannelsVm, + out VirtualTerminal terminal, + FakeSlackProbe? slackProbe = null, + FakeDiscordProbe? discordProbe = null, + FakeMattermostProbe? mattermostProbe = null) + { + var terminalInstance = new VirtualTerminal(120, 40); + terminal = terminalInstance; + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + var navigationState = new ConfigDashboardNavigationState(); + var tuiNavigation = new TuiNavigation(); + ConfigDashboardViewModel? capturedDashboardVm = null; + ChannelsConfigViewModel? capturedChannelsVm = null; + + var services = new ServiceCollection(); + services.AddSingleton(terminalInstance); + services.AddSingleton(tuiNavigation); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/config", builder => + { + builder.RegisterRoute( + "/config", + _ => new ConfigDashboardPage(), + _ => + { + capturedDashboardVm = new ConfigDashboardViewModel(navigationState); + return capturedDashboardVm; + }); + builder.RegisterRoute( + "/channels", + _ => new ChannelsConfigPage(), + _ => + { + capturedChannelsVm = new ChannelsConfigViewModel( + _paths, + slackProbe ?? new FakeSlackProbe(), + discordProbe ?? new FakeDiscordProbe(), + mattermostProbe ?? new FakeMattermostProbe(), + tuiNavigation); + return capturedChannelsVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService(); + tuiNavigation.Attach(app); + + dashboardVm = capturedDashboardVm!; + getChannelsVm = () => capturedChannelsVm; + return app; + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs new file mode 100644 index 000000000..06a6196cb --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ChannelsConfigViewModelTests.cs @@ -0,0 +1,1973 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Actors.Channels; +using Netclaw.Channels.Slack; +using Netclaw.Cli.Config; +using Netclaw.Cli.Discord; +using Netclaw.Cli.Mattermost; +using Netclaw.Cli.Tests.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ChannelsConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public static TheoryData ResetConnectionCases { get; } = new() + { + { ChannelType.Slack, "Slack", ["Slack.BotToken", "Slack.AppToken"] }, + { ChannelType.Discord, "Discord", ["Discord.BotToken"] }, + { ChannelType.Mattermost, "Mattermost", ["Mattermost.BotToken"] } + }; + + public static TheoryData ChannelTypes { get; } = new() + { + ChannelType.Slack, + ChannelType.Discord, + ChannelType.Mattermost + }; + + public ChannelsConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Channels_editor_hosts_original_channel_picker_adapters() + { + using var vm = CreateViewModel(); + + var labels = vm.Step.Adapters.Select(static item => item.DisplayName).ToArray(); + + Assert.Equal(["Slack", "Discord", "Mattermost"], labels); + } + + [Fact] + public void Constructor_with_unparseable_posture_fails_closed_without_throwing() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { "configVersion": 1, "Security": { "DeploymentPosture": "NotARealPosture" } } + """); + + // Before the fix LoadDeploymentPosture threw InvalidOperationException, making the entire + // Channels page inaccessible on a value the Security page reads without crashing. It now fails + // closed to Public via the shared DeploymentPostureReader instead of throwing at construction. + var exception = Record.Exception(() => CreateViewModel().Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void Channels_editor_validator_maps_static_errors_to_fields() + { + var model = new ChannelsEditorModel + { + Slack = + { + Enabled = true, + BotTokenDraft = "not-a-slack-token", + HasPersistedAppToken = true, + } + }; + var validator = new ChannelsEditorValidationAdapter(); + + var result = validator.Validate(model); + + var issue = Assert.Single(result.IssuesFor(ChannelsEditorFieldPaths.SlackBotToken)); + Assert.Equal(ChannelsEditorValidationMessages.SlackBotTokenPrefix, issue.Message); + } + + [Fact] + public void Existing_config_prefills_picker_and_adapter_drafts() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + var mattermost = vm.Step.GetAdapterViewModel(ChannelType.Mattermost); + + Assert.True(vm.Step.IsAdapterEnabled(ChannelType.Slack)); + Assert.False(vm.Step.IsAdapterEnabled(ChannelType.Discord)); + Assert.True(vm.Step.IsAdapterEnabled(ChannelType.Mattermost)); + Assert.Equal("3 channels, 2 users", vm.Step.GetAdapterSummary(0)); + Assert.Equal("disabled, saved setup", vm.Step.GetAdapterSummary(1)); + Assert.Equal("1 channel", vm.Step.GetAdapterSummary(2)); + Assert.True(slack.HasPersistedBotToken); + Assert.True(slack.HasPersistedAppToken); + Assert.Equal("C01, C02, C03", slack.ChannelNamesInput); + Assert.Equal("U01, U02", slack.AllowedUserIdsInput); + Assert.Equal("https://mattermost.example.com", mattermost.ServerUrl); + Assert.True(mattermost.HasPersistedBotToken); + } + + [Fact] + public async Task Save_preserves_blank_existing_secrets_and_updates_config() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.ChannelNamesInput = "C09"; + slack.AllowedUserIdsInput = "U09"; + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C09"], ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.DefaultChannelId", out var defaultChannel)); + Assert.Equal("C09", defaultChannel); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedUserIds", out var usersRaw)); + Assert.Equal(["U09"], ToStringArray(usersRaw)); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out var appToken)); + Assert.Equal("xapp-test", ConfigFileHelper.DecryptIfEncrypted(_paths, appToken?.ToString())); + } + + [Fact] + public async Task Save_sets_new_secret_without_serializing_plaintext() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + using var vm = CreateViewModel(); + vm.Step.LoadAdapterState(ChannelType.Discord, enabled: true, summary: "1 channel", adapter => + { + var discord = (DiscordStepViewModel)adapter; + discord.DiscordEnabled = true; + discord.BotToken = "new-discord-token"; + discord.ChannelIdsInput = "123456789"; + }); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var serializedSecrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("new-discord-token", serializedSecrets, StringComparison.Ordinal); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Discord.BotToken", out var token)); + Assert.Equal("new-discord-token", ConfigFileHelper.DecryptIfEncrypted(_paths, token?.ToString())); + } + + [Fact] + public async Task Save_disabled_existing_provider_preserves_dormant_fields_and_secrets() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + vm.Step.ToggleAdapter(0); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.Enabled", out var enabled)); + Assert.False(Assert.IsType(enabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(3, ToStringArray(channelsRaw).Length); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + } + + [Fact] + public async Task Save_blocks_enabled_provider_with_missing_required_secret() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + using var vm = CreateViewModel(); + vm.Step.LoadAdapterState(ChannelType.Slack, enabled: true, summary: "configured", adapter => + { + var slack = (SlackStepViewModel)adapter; + slack.SlackEnabled = true; + slack.AppToken = "xapp-test"; + }); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Slack bot token is required.", vm.Status.Value.Text); + } + + [Fact] + public async Task Save_blocks_invalid_slack_token_before_probe() + { + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var slackProbe = new FakeSlackProbe(); + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.Step.LoadAdapterState(ChannelType.Slack, enabled: true, summary: "configured", adapter => + { + var slack = (SlackStepViewModel)adapter; + slack.SlackEnabled = true; + slack.BotToken = "not-a-slack-token"; + slack.AppToken = "xapp-test"; + slack.ChannelNamesInput = "netclaw-support"; + }); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Slack bot token must start with xoxb-.", vm.Status.Value.Text); + Assert.Equal(0, slackProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.False(File.Exists(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_blocks_invalid_mattermost_url_before_probe() + { + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var mattermostProbe = new FakeMattermostProbe(); + using var vm = CreateViewModel(mattermostProbe: mattermostProbe); + vm.Step.LoadAdapterState(ChannelType.Mattermost, enabled: true, summary: "configured", adapter => + { + var mattermost = (MattermostStepViewModel)adapter; + mattermost.MattermostEnabled = true; + mattermost.ServerUrl = "not-a-url"; + mattermost.BotToken = "mattermost-token"; + mattermost.ChannelIdsInput = "town-square"; + }); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(vm.IsSaved.Value); + Assert.Equal("Mattermost server URL must be an absolute http:// or https:// URL.", vm.Status.Value.Text); + Assert.Equal(0, mattermostProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.False(File.Exists(_paths.SecretsPath)); + } + + [Fact] + public async Task Back_from_saved_picker_returns_to_dashboard_or_quits() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + vm.GoBack(); + + Assert.True(vm.IsSaved.Value); + Assert.True(vm.ShutdownRequestedForTest); + } + + [Fact] + public void Config_picker_exposes_done_row_without_save_action() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + Assert.True(vm.Step.ShowDonePickerRow); + Assert.False(vm.Step.ShowDoneAction); + Assert.Equal("Done adding channels", vm.Step.DonePickerRowLabel); + Assert.Equal(vm.Step.Adapters.Count + 1, vm.Step.PickerRowCount); + } + + [Fact] + public void Channel_permissions_done_row_returns_to_adapter_menu() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.ActivateManagementMenuItem(); + var doneIndex = vm.GetChannelRows() + .Select((row, index) => (row, index)) + .Single(entry => entry.row.IsDoneAction) + .index; + + vm.MoveChannelRow(doneIndex); + vm.OpenSelectedChannelAudience(); + + Assert.Equal(ChannelsConfigScreen.AdapterMenu, vm.Screen.Value); + Assert.Equal("Done adding channels. Completed changes are already saved.", vm.Status.Value.Text); + } + + [Fact] + public void Esc_from_incomplete_add_channel_draft_writes_nothing() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "C99"; + + vm.GoBack(); + + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Enable_slack_then_discord_with_channels_then_escape_preserves_both_sections() + { + // Reproduces the reported data-loss: a fresh config, enable Slack + add a + // channel through the picker sub-flow, then enable Discord + add a channel, + // then Escape back to the dashboard. Both provider sections must survive. + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("general", "C100")], + []) + }; + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, + null, + [new ResolvedDiscordChannel("555000111", "ops", "Guild")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe, discordProbe: discordProbe); + + await EnableAdapterFromPickerWithChannel(vm, ChannelType.Slack, botToken: "xoxb-test", appToken: "xapp-test", channelInput: "general"); + + // After Slack setup + add channel the config on disk must already carry Slack. + var afterSlack = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(afterSlack, "Slack.Enabled", out var slackEnabledEarly)); + Assert.True(Assert.IsType(slackEnabledEarly)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterSlack, "Slack.AllowedChannelIds", out var slackChannelsEarly)); + Assert.Equal(["C100"], ToStringArray(slackChannelsEarly)); + + await EnableAdapterFromPickerWithChannel(vm, ChannelType.Discord, botToken: "discord-token", appToken: null, channelInput: "555000111"); + + // After Discord setup both sections must be present on disk. + var afterDiscord = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(afterDiscord, "Slack.Enabled", out var slackEnabledMid)); + Assert.True(Assert.IsType(slackEnabledMid)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterDiscord, "Slack.AllowedChannelIds", out var slackChannelsMid)); + Assert.Equal(["C100"], ToStringArray(slackChannelsMid)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterDiscord, "Discord.Enabled", out var discordEnabledMid)); + Assert.True(Assert.IsType(discordEnabledMid)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterDiscord, "Discord.AllowedChannelIds", out var discordChannelsMid)); + Assert.Equal(["555000111"], ToStringArray(discordChannelsMid)); + + // Escape from the picker back to the dashboard. + vm.GoBack(); + + var afterEscape = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Slack.Enabled", out var slackEnabledFinal)); + Assert.True(Assert.IsType(slackEnabledFinal)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Slack.AllowedChannelIds", out var slackChannelsFinal)); + Assert.Equal(["C100"], ToStringArray(slackChannelsFinal)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Discord.Enabled", out var discordEnabledFinal)); + Assert.True(Assert.IsType(discordEnabledFinal)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Discord.AllowedChannelIds", out var discordChannelsFinal)); + Assert.Equal(["555000111"], ToStringArray(discordChannelsFinal)); + } + + [Fact] + public void Enable_slack_then_discord_via_subflow_channel_names_then_escape_preserves_both() + { + // Variant that mirrors the realistic wizard path: channel names are entered + // during the adapter sub-flow (Slack sub-step 3 / Discord channel-IDs sub-step), + // which get resolved on the completion autosave. Then add a second adapter and + // escape. Both sections must survive. + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1 + } + """); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("general", "C100")], + []) + }; + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, + null, + [new ResolvedDiscordChannel("555000111", "ops", "Guild")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe, discordProbe: discordProbe); + + // Slack: toggle from picker, enter token + channel names in the sub-flow. + vm.Step.CursorIndex = GetAdapterIndex(vm, ChannelType.Slack); + Assert.True(vm.TryToggleSelectedAdapterFromPicker()); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.BotToken = "xoxb-test"; + slack.AppToken = "xapp-test"; + slack.ChannelNamesInput = "general"; + for (var i = 0; i < 10 && vm.Step.IsInSubFlow; i++) + vm.GoNext(); + vm.GoBack(); // ChannelPermissions -> AdapterMenu + vm.GoBack(); // AdapterMenu -> Picker + + var afterSlack = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(afterSlack, "Slack.AllowedChannelIds", out var slackChannelsEarly)); + Assert.Equal(["C100"], ToStringArray(slackChannelsEarly)); + + // Discord: toggle from picker, enter token + channel IDs in the sub-flow. + vm.Step.CursorIndex = GetAdapterIndex(vm, ChannelType.Discord); + Assert.True(vm.TryToggleSelectedAdapterFromPicker()); + var discord = vm.Step.GetAdapterViewModel(ChannelType.Discord); + discord.BotToken = "discord-token"; + discord.ChannelIdsInput = "555000111"; + for (var i = 0; i < 10 && vm.Step.IsInSubFlow; i++) + vm.GoNext(); + vm.GoBack(); // ChannelPermissions -> AdapterMenu + vm.GoBack(); // AdapterMenu -> Picker + + // Escape to dashboard. + vm.GoBack(); + + var afterEscape = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Slack.Enabled", out var slackEnabledFinal)); + Assert.True(Assert.IsType(slackEnabledFinal)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Slack.AllowedChannelIds", out var slackChannelsFinal)); + Assert.Equal(["C100"], ToStringArray(slackChannelsFinal)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Discord.Enabled", out var discordEnabledFinal)); + Assert.True(Assert.IsType(discordEnabledFinal)); + Assert.True(ConfigFileHelper.TryGetPathValue(afterEscape, "Discord.AllowedChannelIds", out var discordChannelsFinal)); + Assert.Equal(["555000111"], ToStringArray(discordChannelsFinal)); + } + + // ── Definitive behavior: the persisted allow-list key is the platform's IMMUTABLE channel id. + // The background resolution (the label-refresh, off the loop thread — never blocking) canonicalizes + // the editor's channel references to ids: a display name that maps to an id is stored as the id; a + // display name that maps to NOTHING is never persisted; an id is always kept. This holds for all + // three adapters. The display name itself is resolved dynamically for rendering and never stored. ── + + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Background_resolution_persists_the_resolved_id_not_the_typed_display_name(ChannelType type) + { + WriteFreshConfig(); + using var vm = ViewModelResolving(type, "town-hall", id: "CANONICAL00000000000000000"); + + await StageAndRefreshAsync(vm, type, channelInput: "town-hall"); + + // The typed display name is gone; the immutable id is what reached disk. + Assert.Equal(["CANONICAL00000000000000000"], PersistedChannels(type)); + } + + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Background_resolution_does_not_persist_a_display_name_with_no_channel_id(ChannelType type) + { + WriteFreshConfig(); + using var vm = ViewModelUnresolved(type, "ghost-channel"); + + await StageAndRefreshAsync(vm, type, channelInput: "ghost-channel"); + + // A display name the bot can't map to a real channel id is inert in the ACL — it is not saved... + Assert.Empty(PersistedChannels(type)); + // ...and the operator is told, loudly, exactly what was dropped. + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("ghost-channel", vm.Status.Value.Text, StringComparison.Ordinal); + } + + [Theory] + [InlineData(ChannelType.Slack)] + [InlineData(ChannelType.Discord)] + [InlineData(ChannelType.Mattermost)] + public async Task Background_resolution_does_not_persist_names_when_the_bot_lacks_read_scope(ChannelType type) + { + // The exact missing-channels:read case: the probe errors, so nothing maps. A typed display name + // must not survive as an inert allow-list entry, and the underlying reason is surfaced. + WriteFreshConfig(); + using var vm = ViewModelProbeError(type, "netclaw-test", "Bot token lacks channels:read scope."); + + await StageAndRefreshAsync(vm, type, channelInput: "netclaw-test"); + + Assert.Empty(PersistedChannels(type)); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("channels:read", vm.Status.Value.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task Background_resolution_keeps_a_real_id_even_when_the_bot_cannot_enumerate_it() + { + // A real channel id is the stable ACL key. If the probe can't currently see it (private channel, + // bot not yet a member), a transient display-name miss must NOT delete it. + File.WriteAllText(_paths.NetclawConfigPath, + """ + { "configVersion": 1, "Slack": { "Enabled": true, "AllowedChannelIds": ["C0B9JCJASP3"] } } + """); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult(false, null, [], ["C0B9JCJASP3"]) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + + await vm.RefreshChannelLabelsAsync(ChannelType.Slack, TestContext.Current.CancellationToken); + + Assert.Equal(["C0B9JCJASP3"], PersistedChannels(ChannelType.Slack)); + } + + [Fact] + public void Mattermost_channel_row_shows_the_resolved_display_name_not_the_opaque_id() + { + // #1324: the stored ACL key is the opaque Mattermost channel id; the list view must render the + // resolved human display name (as Slack/Discord already do), not the id. + File.WriteAllText(_paths.NetclawConfigPath, + """ + { "configVersion": 1, "Mattermost": { "Enabled": true, "ServerUrl": "https://mm.example.com", "AllowedChannelIds": ["4xp9p3onpins8"] } } + """); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Mattermost); + vm.Step.GetAdapterViewModel(ChannelType.Mattermost).LastChannelResolution = + new MattermostChannelResolutionResult( + true, null, [new ResolvedMattermostChannel("4xp9p3onpins8", "town-square", "Town Square")], []); + + var row = Assert.Single(vm.GetChannelRows(includeAddAction: false), r => r.Id == "4xp9p3onpins8"); + Assert.Equal("Town Square", row.DisplayName); + } + + [Fact] + public async Task Add_channel_field_accepts_a_comma_separated_list_and_resolves_each() + { + // Regression: "openclaw, netclaw-test" used to be treated as ONE bogus channel. The add field + // now uses the same CSV parser as the first-connect sub-flow and resolves each reference. + WriteFreshConfig(); + var slackProbe = new FakeSlackProbe + { + ResolveByName = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["openclaw"] = "C01OPEN", + ["netclaw-test"] = "C02TEST", + } + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.Step.LoadAdapterState(ChannelType.Slack, enabled: true, summary: "configured", adapter => + { + var slack = (SlackStepViewModel)adapter; + slack.SlackEnabled = true; + slack.BotToken = "xoxb-test"; + slack.AppToken = "xapp-test"; + }); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "openclaw, netclaw-test"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + + Assert.Equal(["C01OPEN", "C02TEST"], PersistedChannels(ChannelType.Slack)); + } + + [Fact] + public async Task Add_channel_field_persists_the_resolved_and_reports_the_unresolved() + { + WriteFreshConfig(); + var slackProbe = new FakeSlackProbe + { + ResolveByName = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["openclaw"] = "C01OPEN", + // "ghost" is intentionally absent — it won't resolve. + } + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.Step.LoadAdapterState(ChannelType.Slack, enabled: true, summary: "configured", adapter => + { + var slack = (SlackStepViewModel)adapter; + slack.SlackEnabled = true; + slack.BotToken = "xoxb-test"; + slack.AppToken = "xapp-test"; + }); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "openclaw, ghost"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + + // The resolvable channel is saved as its id; the unresolvable one is not persisted but is flagged. + Assert.Equal(["C01OPEN"], PersistedChannels(ChannelType.Slack)); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("ghost", vm.Status.Value.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task Discord_add_then_slack_disable_then_escape_preserves_provider_config() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Discord); + vm.BeginAddChannel(); + vm.AddChannelInput = "987654321"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + vm.OpenAdapterManagement(ChannelType.Slack); + MoveToManagementAction(vm, ChannelsManagementAction.ToggleEnabled); + vm.ActivateManagementMenuItem(); + vm.GoBack(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.Enabled", out var slackEnabled)); + Assert.False(Assert.IsType(slackEnabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var slackChannelsRaw)); + Assert.Equal(["C01"], ToStringArray(slackChannelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var slackAudiencesRaw)); + Assert.Equal("team", ToStringDictionary(slackAudiencesRaw)["C01"]); + + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.Enabled", out var discordEnabled)); + Assert.True(Assert.IsType(discordEnabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.AllowedChannelIds", out var discordChannelsRaw)); + Assert.Equal(["123456789", "987654321"], ToStringArray(discordChannelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.ChannelAudiences", out var discordAudiencesRaw)); + var discordAudiences = ToStringDictionary(discordAudiencesRaw); + Assert.Equal("team", discordAudiences["123456789"]); + Assert.Equal("team", discordAudiences["987654321"]); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var slackBotToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, slackBotToken?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Discord.BotToken", out var discordBotToken)); + Assert.Equal("discord-token", ConfigFileHelper.DecryptIfEncrypted(_paths, discordBotToken?.ToString())); + } + + [Fact] + public void Configured_adapter_opens_management_menu_without_token_subflow() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + Assert.True(vm.TryOpenSelectedAdapterManagement()); + + Assert.Equal(ChannelsConfigScreen.AdapterMenu, vm.Screen.Value); + Assert.False(vm.Step.IsInSubFlow); + Assert.Equal(ChannelType.Slack, vm.ActiveAdapterType); + } + + [Fact] + public void First_time_adapter_setup_opens_channel_permissions_before_save() + { + using var vm = CreateViewModel(); + vm.Step.ToggleAdapter(0); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.BotToken = "xoxb-test"; + slack.AppToken = "xapp-test"; + slack.ChannelNamesInput = "C01"; + + for (var i = 0; i < 5; i++) + vm.GoNext(); + + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + Assert.Equal(ChannelType.Slack, vm.ActiveAdapterType); + Assert.Contains(vm.GetChannelRows(), row => row.Id == "C01" && !row.IsAddAction); + } + + [Fact] + public async Task Add_channel_preserves_credentials_and_adds_at_system_default_audience() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + // Resolve-before-add adds an entered ID directly at the deployment-posture + // default audience (no audience picker during add). + vm.AddChannelInput = "C09"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C02", "C03", "C09"], ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + var audiences = ToStringDictionary(audiencesRaw); + // Personal deployment posture -> Team channel default. + Assert.Equal("team", audiences["C09"]); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + } + + [Fact] + public async Task Add_channel_resolves_name_to_id_before_adding_and_focuses_the_new_row() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("netclaw-support", "C09")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "netclaw-support"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + + // The resolve ran with the bot token, the resolved ID was added, and we + // advanced to the channel list with the new row focused. + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.Contains("netclaw-support", slackProbe.LastResolvedNames!); + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + Assert.True(vm.IsSaved.Value); + var focusedRow = vm.GetChannelRows()[vm.ChannelRowIndex]; + Assert.Equal("C09", focusedRow.Id); + } + + [Fact] + public async Task Add_channel_resolving_to_dm_with_dms_enabled_does_not_throw() + { + WriteChannelConfig(); // Slack has AllowDirectMessages: true, so a DM row (Id="dm") exists. + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("dm-collision", "dm")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "dm-collision"; + + // The resolved id "dm" collides with the DM row's Id; this previously threw from Single(). + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + // The newly-added channel row (id "dm", NOT the DM row) is focused. + var focused = vm.GetChannelRows()[vm.ChannelRowIndex]; + Assert.Equal("dm", focused.Id); + Assert.False(focused.IsDirectMessage); + } + + [Fact] + public async Task Add_channel_that_does_not_resolve_is_dropped_with_a_warning() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult(false, null, [], ["ghost"]) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "ghost"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + + // Unified with the first-connect front door: the typed reference is canonicalized through the + // shared reconcile. A display name that maps to no channel id is dropped (never persisted) and + // flagged on the permissions screen — not left inert in the ACL. + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("ghost", vm.Status.Value.Text, StringComparison.Ordinal); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C02", "C03"], ToStringArray(channelsRaw)); + } + + [Fact] + public async Task Edit_channel_audience_writes_channel_audiences() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + + vm.OpenSelectedChannelAudience(); + vm.MoveAudienceSelection(1); // C01 Team -> Public. + vm.ApplyAudienceSelection(); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + Assert.Equal("public", ToStringDictionary(audiencesRaw)["C01"]); + } + + [Fact] + public void Cycling_channel_audience_autosaves_without_an_explicit_save() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + + // The ←/→ audience toggle on the focused channel row (C01, Team). It sets a security-relevant + // ACL trust tier and must autosave like every other ChannelPermissions mutation — previously + // it only mutated in-memory state and was silently discarded on Esc. + var focused = vm.GetChannelRows()[vm.ChannelRowIndex]; + Assert.Equal("C01", focused.Id); + + vm.ChangeSelectedChannelAudience(1); // Team -> Public + + // No explicit Save(): the toggle persisted on its own. + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + Assert.Equal("public", ToStringDictionary(audiencesRaw)["C01"]); + } + + [Fact] + public async Task Direct_message_audience_is_saved_without_touching_channels() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginDirectMessages(); + vm.ChangeDirectMessageAudience(1); // Personal -> Team. + + vm.ApplyDirectMessages(); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowDirectMessages", out var allowDm)); + Assert.True(Assert.IsType(allowDm)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + Assert.Equal("team", ToStringDictionary(audiencesRaw)["dm"]); + } + + [Fact] + public async Task Rotate_credentials_preserves_blank_secret_and_updates_nonblank_secret() + { + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginRotateCredentials(); + vm.BotTokenInput = "xoxb-new"; + vm.AppTokenInput = string.Empty; + + vm.ApplyCredentials(); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-new", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.AppToken", out var appToken)); + Assert.Equal("xapp-test", ConfigFileHelper.DecryptIfEncrypted(_paths, appToken?.ToString())); + } + + [Theory] + [MemberData(nameof(ResetConnectionCases))] + public async Task Reset_connection_deletes_config_section_and_secrets_immediately( + ChannelType type, + string configSection, + string[] secretPaths) + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using var vm = CreateViewModel(); + + await ConfirmReset(vm, type); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.False(ConfigFileHelper.TryGetPathValue(config, configSection, out _)); + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + foreach (var secretPath in secretPaths) + Assert.False(ConfigFileHelper.TryGetPathValue(secrets, secretPath, out _)); + Assert.True(vm.IsSaved.Value); + Assert.Equal($"{type} reset saved.", vm.Status.Value.Text); + } + + [Theory] + [MemberData(nameof(ChannelTypes))] + public async Task Reset_connection_survives_reopening_channels_editor_without_outer_save( + ChannelType type) + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using (var vm = CreateViewModel()) + { + await ConfirmReset(vm, type); + } + + using var reopened = CreateViewModel(); + + Assert.False(reopened.Step.IsAdapterKnown(type)); + Assert.False(reopened.Step.IsAdapterEnabled(type)); + Assert.Null(reopened.Step.GetAdapterSummary(GetAdapterIndex(reopened, type))); + } + + [Theory] + [InlineData(ChannelType.Discord, "Discord.AllowedChannelIds", "Discord.ChannelAudiences", "987654321")] + [InlineData(ChannelType.Mattermost, "Mattermost.AllowedChannelIds", "Mattermost.ChannelAudiences", "town-square-2")] + public async Task Add_channel_management_is_generic_for_discord_and_mattermost( + ChannelType type, + string channelsPath, + string audiencesPath, + string newChannelId) + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using var vm = CreateViewModel(); + vm.OpenAdapterManagement(type); + vm.BeginAddChannel(); + vm.AddChannelInput = newChannelId; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, channelsPath, out var channelsRaw)); + Assert.Contains(newChannelId, ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, audiencesPath, out var audiencesRaw)); + Assert.Equal("team", ToStringDictionary(audiencesRaw)[newChannelId]); + } + + [Fact] + public async Task Save_resolves_slack_channel_names_to_ids_and_remaps_audiences() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("netclaw-support", "C09")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "netclaw-support"; + + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.Equal("xoxb-test", slackProbe.LastBotToken); + Assert.Contains("netclaw-support", slackProbe.LastResolvedNames!); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C02", "C03", "C09"], ToStringArray(channelsRaw)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + var audiences = ToStringDictionary(audiencesRaw); + Assert.Equal("team", audiences["C09"]); + Assert.DoesNotContain("netclaw-support", audiences.Keys); + + vm.OpenAdapterManagement(ChannelType.Slack); + var row = Assert.Single(vm.GetChannelRows(includeAddAction: false), row => row.Id == "C09"); + Assert.Equal("#netclaw-support", row.DisplayName); + } + + [Fact] + public async Task Save_resolves_discord_channel_name_to_id() + { + // The operator entered a display name; the probe resolves it to the channel id, and the id + // (not the name) is what persists — so the runtime ACL can match it. + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, null, + [new ResolvedDiscordChannel("111222333", "ops", "Stannard Labs")], + []) + }; + using var vm = CreateViewModel(discordProbe: discordProbe); + vm.Step.GetAdapterViewModel(ChannelType.Discord).ChannelIdsInput = "ops"; + + Assert.True(await vm.SaveAsync(TestContext.Current.CancellationToken)); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Discord.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["111222333"], ToStringArray(channelsRaw)); + } + + [Fact] + public async Task Save_resolves_mattermost_channel_name_to_id() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var mattermostProbe = new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult( + true, null, + [new ResolvedMattermostChannel("ttttttttttttttttttttttttab", "town-square", "Town Square")], + []) + }; + using var vm = CreateViewModel(mattermostProbe: mattermostProbe); + vm.Step.GetAdapterViewModel(ChannelType.Mattermost).ChannelIdsInput = "town-square"; + + Assert.True(await vm.SaveAsync(TestContext.Current.CancellationToken)); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Mattermost.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["ttttttttttttttttttttttttab"], ToStringArray(channelsRaw)); + } + + [Fact] + public async Task Save_blocks_when_slack_channel_name_unresolved_and_persists_nothing() + { + // The probe's API call worked (ErrorMessage null) but one name did not resolve. Per the + // fail-loud decision, an unresolvable channel is an inert allow-list entry the runtime ACL + // can never match, so the save BLOCKS and persists nothing — not even the resolved channel + // or token — rather than keeping a dead name. The operator must fix or remove it. + WriteChannelConfig(); + WriteChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + false, + null, + [new ResolvedSlackChannel("openclaw", "C99")], + ["fake-channel"]) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.ChannelNamesInput = "openclaw, fake-channel"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("#fake-channel", vm.Status.Value.Text); + Assert.Contains("Could not resolve", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_blocks_when_slack_probe_fails_and_persists_nothing() + { + // The probe itself failed (ErrorMessage set): we cannot validate, so the save + // must block and persist nothing — not even the resolved channels or token. + WriteChannelConfig(); + WriteChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + false, + "invalid_auth", + [], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.ChannelNamesInput = "openclaw"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal("Slack channel lookup failed: invalid_auth", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task SaveAsync_surfaces_dynamic_validation_exception_to_awaited_caller() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + ResolutionException = new InvalidOperationException("Slack lookup exploded") + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.ChannelNamesInput = "netclaw-support"; + + var ex = await Assert.ThrowsAsync( + () => vm.SaveAsync(TestContext.Current.CancellationToken)); + + Assert.Equal("Slack lookup exploded", ex.Message); + Assert.Equal(1, slackProbe.ResolveCallCount); + } + + [Fact] + public async Task Save_from_input_surfaces_dynamic_validation_exception_as_status_without_persistence() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var slackProbe = new FakeSlackProbe + { + ResolutionException = new InvalidOperationException("Slack lookup exploded") + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.ChannelNamesInput = "netclaw-support"; + + await vm.SaveFromInputAsync(TestContext.Current.CancellationToken); + + Assert.Equal("Channel settings save failed: Slack lookup exploded", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_blocks_when_discord_channel_id_unresolved_and_persists_nothing() + { + // The probe's API call worked (ErrorMessage null) but one id did not resolve. Per the + // fail-loud decision the save BLOCKS and persists nothing rather than keeping a dead entry. + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + false, + null, + [new ResolvedDiscordChannel("123456789", "ops", "Stannard Labs")], + ["987654321"]) + }; + using var vm = CreateViewModel(discordProbe: discordProbe); + vm.Step.GetAdapterViewModel(ChannelType.Discord).ChannelIdsInput = "123456789, 987654321"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("#987654321", vm.Status.Value.Text); + Assert.Contains("Could not resolve", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, discordProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_blocks_when_discord_probe_fails_and_persists_nothing() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + false, + "Unauthorized", + [], + []) + }; + using var vm = CreateViewModel(discordProbe: discordProbe); + vm.Step.GetAdapterViewModel(ChannelType.Discord).ChannelIdsInput = "987654321"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal("Discord channel lookup failed: Unauthorized", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(1, discordProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_uses_resolved_discord_channel_names_in_management_rows() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, + null, + [new ResolvedDiscordChannel("123456789", "netclaw", "Stannard Labs")], + []) + }; + using var vm = CreateViewModel(discordProbe: discordProbe); + + await vm.SaveAsync(TestContext.Current.CancellationToken); + vm.OpenAdapterManagement(ChannelType.Discord); + + var row = Assert.Single(vm.GetChannelRows(includeAddAction: false), row => row.Id == "123456789"); + Assert.Equal("Stannard Labs / #netclaw", row.DisplayName); + } + + [Fact] + public void Open_management_resolves_persisted_slack_channel_labels() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("general", "C01")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + + vm.OpenAdapterManagement(ChannelType.Slack); + vm.ActivateManagementMenuItem(); + + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.Equal(["C01"], slackProbe.LastResolvedNames); + var row = Assert.Single(vm.GetChannelRows(includeAddAction: false), row => row.Id == "C01"); + Assert.Equal("#general", row.DisplayName); + } + + [Fact] + public async Task SaveAsync_cancels_and_awaits_in_flight_label_refresh_before_writing() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + // Block the resolve so the background refresh is genuinely in flight when we save. + DelayBeforeResult = TimeSpan.FromMinutes(5), + NextResolutionResult = new SlackChannelResolutionResult( + true, null, [new ResolvedSlackChannel("general", "C01")], []), + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + + vm.OpenAdapterManagement(ChannelType.Slack); + vm.ActivateManagementMenuItem(); // starts the background label refresh — it blocks in the probe + + Assert.Equal(1, slackProbe.ResolveCallCount); + Assert.False(vm.PendingLabelRefresh?.IsCompleted ?? true); // background is in flight + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + // The save cancelled and awaited the blocked background refresh rather than racing its disk + // write or hanging for the 5-minute probe delay; the tracked task is unwound to null. + Assert.True(saved); + Assert.Null(vm.PendingLabelRefresh); + } + + [Fact] + public async Task ApplyResetConfirmation_cancels_and_awaits_in_flight_label_refresh_before_writing() + { + using var vm = ArrangeSlackResetWithLabelRefreshInFlight(); + + await vm.ApplyResetConfirmationAsync(TestContext.Current.CancellationToken); + + // The reset cancelled and awaited the blocked refresh rather than racing its disk write or + // rebuilding view-model state under it (and without hanging for the 5-minute probe delay); + // the tracked task is unwound to null. + Assert.Null(vm.PendingLabelRefresh); + } + + [Fact] + public async Task Reset_with_in_flight_label_refresh_completes_under_a_single_worker_synchronization_context() + { + // Regression for the macOS CI deadlock. xunit v3 runs tests under a MaxConcurrencySyncContext + // whose worker pool is sized to the core count. The old reset path bridged async work to the + // synchronous Termina key handler via .GetAwaiter().GetResult(); on a bounded context that + // blocks the only free worker while the cancelled probe's continuation is posted back to that + // same context — a sync-over-async deadlock (it passed on many-core Linux/Windows and hung on + // macOS's smaller pool). The async migration removes the block. This test pins it + // deterministically: it drives the whole reset-with-in-flight-refresh scenario on a context + // with exactly ONE worker, so a reintroduced sync-over-async bridge hangs the worker and trips + // the watchdog instead of completing. + using var context = new SingleThreadSynchronizationContext(); + var scenario = context.Run(async () => + { + // Arrange on the single-worker context so the background refresh's continuation captures + // THIS context — exactly the condition that deadlocked the old blocking reset. + using var vm = ArrangeSlackResetWithLabelRefreshInFlight(); + + // Fire-and-forget exactly like the Termina key handler, then await the serialized write. + _ = vm.ResetConfirmationFromInputAsync(); + await vm.PendingConfigWrite; + + Assert.Null(vm.PendingLabelRefresh); + }); + + var completed = await Task.WhenAny( + scenario, + Task.Delay(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken)); + Assert.True( + ReferenceEquals(completed, scenario), + "Reset deadlocked under a single-worker SynchronizationContext — a sync-over-async bridge was reintroduced."); + await scenario; // re-throw any assertion failure raised on the worker thread + } + + [Fact] + public async Task Disposing_editor_cancels_and_drains_an_in_flight_config_write() + { + WriteChannelConfig(); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + // Hold the add's label-resolve open so the config write is genuinely in flight at Dispose. + DelayBeforeResult = TimeSpan.FromMinutes(5), + NextResolutionResult = new SlackChannelResolutionResult( + true, null, [new ResolvedSlackChannel("c-09", "C09")], []), + }; + var vm = CreateViewModel(slackProbe: slackProbe); + vm.OpenAdapterManagement(ChannelType.Slack); + vm.BeginAddChannel(); + vm.AddChannelInput = "c-09"; + + // Dispatch the add fire-and-forget exactly like the key handler; it blocks on the 5-minute resolve. + var write = vm.AddChannelFromInputAsync(); + Assert.False(write.IsCompleted); // in flight, blocked on the label resolve + + // Dispose must cancel the in-flight write via the lifetime token and drain it before returning — + // not hang for the 5-minute probe, and not let the write resume on a thread-pool continuation and + // mutate disposed reactive state. Run Dispose off the xunit synchronization context so the + // in-flight write's continuations can drain on the test context while Dispose waits. + var dispose = Task.Run(vm.Dispose, TestContext.Current.CancellationToken); + var finished = await Task.WhenAny( + dispose, Task.Delay(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken)); + Assert.True(ReferenceEquals(finished, dispose), "Dispose did not drain the cancelled in-flight write promptly."); + await dispose; // surface any teardown exception + await write; // the cancelled add unwound without surfacing out + } + + [Fact] + public async Task ApplyResetConfirmation_surfaces_save_failure_without_crashing_the_loop() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + using var vm = CreateViewModel(); + + vm.OpenAdapterManagement(ChannelType.Slack); + MoveToManagementAction(vm, ChannelsManagementAction.ResetConnection); + vm.ActivateManagementMenuItem(); + vm.MoveResetConfirmation(1); + + // Force the reset's session.Save() to fail like a disk-full / permission-denied failure: + // AtomicFile cannot replace a path that is a directory. Cycle-1's race fix added the + // cancel-and-await guard here but left the write+reload unguarded. + File.Delete(_paths.NetclawConfigPath); + Directory.CreateDirectory(_paths.NetclawConfigPath); + + await vm.ApplyResetConfirmationAsync(TestContext.Current.CancellationToken); // must not throw into the Termina event loop + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + // Stayed on the confirmation screen instead of advancing as if the reset succeeded. + Assert.Equal(ChannelsConfigScreen.ResetConfirm, vm.Screen.Value); + } + + [Fact] + public void Autosave_of_a_completed_action_does_not_run_the_network_channel_probe() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var slackProbe = new FakeSlackProbe(); + using var vm = CreateViewModel(slackProbe: slackProbe); + + vm.OpenAdapterManagement(ChannelType.Slack); + vm.ActivateManagementMenuItem(); // enters Manage Channels; the background label refresh probes once + var probesBeforeAutosave = slackProbe.ResolveCallCount; + + // Removing a channel is a completed action that autosaves. With the fix the autosave + // persists immediately and does NOT block the loop on a fresh channel-access probe. + vm.RemoveSelectedChannel(); + + Assert.True(vm.IsSaved.Value); + Assert.Equal(probesBeforeAutosave, slackProbe.ResolveCallCount); + } + + [Fact] + public void Open_management_normalizes_resolved_slack_channel_name_to_id_and_persists() + { + // Bug C: a channel saved as a literal NAME (it did not resolve at first save) stays inert + // in the runtime ACL, which matches AllowedChannelIds by Slack channel ID. Once the bot can + // see the channel, re-opening management must rewrite the stored name to its canonical ID + // and persist so the ACL matches — and the audience must travel with it. + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { + "Enabled": true, + "SocketMode": true, + "AllowedChannelIds": ["C01", "netclaw-test"], + "AllowedUserIds": ["U01"], + "AllowDirectMessages": true, + "ChannelAudiences": { "C01": "team", "netclaw-test": "public", "dm": "personal" } + } + } + """); + WriteChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [new ResolvedSlackChannel("general", "C01"), new ResolvedSlackChannel("netclaw-test", "C99")], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + + vm.OpenAdapterManagement(ChannelType.Slack); + vm.ActivateManagementMenuItem(); + + // The stored name was rewritten to its ID on disk so the runtime ACL can match it. + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C99"], ToStringArray(channelsRaw)); + // The audience moved from the name to the ID; the stale name key is gone. + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.ChannelAudiences", out var audiencesRaw)); + var audiences = ToStringDictionary(audiencesRaw); + Assert.Equal("public", audiences["C99"]); + Assert.DoesNotContain("netclaw-test", audiences.Keys); + // The row now renders the resolved label like any other channel. + var row = Assert.Single(vm.GetChannelRows(includeAddAction: false), row => row.Id == "C99"); + Assert.Equal("#netclaw-test", row.DisplayName); + } + + [Fact] + public void Open_management_does_not_rewrite_already_canonical_slack_channels() + { + // Guard against spurious writes: opening management when every channel is already stored + // as its canonical ID must not rewrite the config file at all. + WriteChannelConfig(); // AllowedChannelIds: ["C01", "C02", "C03"] — all IDs. + WriteChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + true, + null, + [ + new ResolvedSlackChannel("general", "C01"), + new ResolvedSlackChannel("dev", "C02"), + new ResolvedSlackChannel("random", "C03") + ], + []) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + + vm.OpenAdapterManagement(ChannelType.Slack); + vm.ActivateManagementMenuItem(); + + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Open_management_resolves_persisted_discord_channel_labels() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var discordProbe = new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult( + true, + null, + [new ResolvedDiscordChannel("123456789", "ops", "Stannard Labs")], + []) + }; + using var vm = CreateViewModel(discordProbe: discordProbe); + + vm.OpenAdapterManagement(ChannelType.Discord); + vm.ActivateManagementMenuItem(); + + Assert.Equal(1, discordProbe.ResolveCallCount); + Assert.Equal(["123456789"], discordProbe.LastResolvedIds); + var row = Assert.Single(vm.GetChannelRows(includeAddAction: false), row => row.Id == "123456789"); + Assert.Equal("Stannard Labs / #ops", row.DisplayName); + } + + [Fact] + public async Task Save_blocks_when_mattermost_channel_id_unresolved_and_persists_nothing() + { + // The probe's API call worked (ErrorMessage null) but one id did not resolve. Per the + // fail-loud decision the save BLOCKS and persists nothing rather than keeping a dead entry. + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var mattermostProbe = new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult( + false, + null, + [new ResolvedMattermostChannel("town-square", "town-square", "Town Square")], + ["bogus"]) + }; + using var vm = CreateViewModel(mattermostProbe: mattermostProbe); + vm.Step.GetAdapterViewModel(ChannelType.Mattermost).ChannelIdsInput = "town-square, bogus"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("#bogus", vm.Status.Value.Text); + Assert.Contains("Could not resolve", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, mattermostProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_blocks_when_mattermost_probe_fails_and_persists_nothing() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var mattermostProbe = new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult( + false, + "connection refused", + [], + []) + }; + using var vm = CreateViewModel(mattermostProbe: mattermostProbe); + vm.Step.GetAdapterViewModel(ChannelType.Mattermost).ChannelIdsInput = "bogus"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal("Mattermost channel lookup failed: connection refused", vm.Status.Value.Text); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(1, mattermostProbe.ResolveCallCount); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public async Task Save_true_for_picker_enabled_adapter_persists_section_even_if_child_flag_desyncs() + { + // Regression for the confirmed data-loss: validation gates on the picker's + // Step.IsAdapterEnabled while the contribution used to gate on the sub-VM's + // SlackEnabled flag. When those two "is-enabled" sources disagree, the save + // validated + probed Slack as enabled but persisted only Slack.Enabled=false, + // dropping the live section while Save() still returned true ("saved"). + // + // The invariant under test: Save() returning true MUST imply the + // picker-enabled adapter's section (Enabled=true + AllowedChannelIds) is on + // disk. Force the desync by flipping only the sub-VM flag — the picker keeps + // these in lockstep today, so this stands in for any future code path that + // mutates one source without the other. + WriteChannelConfig(); + WriteChannelSecrets(); + using var vm = CreateViewModel(); + + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.True(vm.Step.IsAdapterEnabled(ChannelType.Slack)); + slack.SlackEnabled = false; // Desync: picker still enabled, child flag disabled. + slack.ChannelNamesInput = "C01, C02, C03"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.True(saved); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.Enabled", out var enabled)); + Assert.True(Assert.IsType(enabled)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Slack.AllowedChannelIds", out var channelsRaw)); + Assert.Equal(["C01", "C02", "C03"], ToStringArray(channelsRaw)); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var botToken)); + Assert.Equal("xoxb-test", ConfigFileHelper.DecryptIfEncrypted(_paths, botToken?.ToString())); + } + + [Fact] + public async Task Save_blocks_when_any_channel_unresolvable_and_persists_nothing() + { + // Fail-loud invariant (operator decision): the operator entered three channel NAMES where + // only one resolves. Rather than persisting the unresolvable names as inert allow-list + // entries that silently grant nothing (the prior behavior that shipped a dead allow-list and + // bit a live deployment), the save BLOCKS and persists nothing — not the valid channel, not + // the bot token — until the bad names are fixed or removed. + WriteChannelConfig(); + WriteChannelSecrets(); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + var slackProbe = new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult( + false, + null, + [new ResolvedSlackChannel("openclaw", "C77")], + ["netclaw-test", "fake-channel"]) + }; + using var vm = CreateViewModel(slackProbe: slackProbe); + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + Assert.True(vm.Step.IsAdapterEnabled(ChannelType.Slack)); + slack.ChannelNamesInput = "netclaw-test, openclaw, fake-channel"; + + var saved = await vm.SaveAsync(TestContext.Current.CancellationToken); + + Assert.False(saved); + Assert.False(vm.IsSaved.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("#netclaw-test", vm.Status.Value.Text); + Assert.Contains("#fake-channel", vm.Status.Value.Text); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + } + + private ChannelsConfigViewModel CreateViewModel( + FakeSlackProbe? slackProbe = null, + FakeDiscordProbe? discordProbe = null, + FakeMattermostProbe? mattermostProbe = null) + => new(_paths, + slackProbe ?? new FakeSlackProbe(), + discordProbe ?? new FakeDiscordProbe(), + mattermostProbe ?? new FakeMattermostProbe()); + + private void WriteFreshConfig() + => File.WriteAllText(_paths.NetclawConfigPath, """{ "configVersion": 1 }"""); + + private string[] PersistedChannels(ChannelType type) + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return ConfigFileHelper.TryGetPathValue(config, $"{type}.AllowedChannelIds", out var raw) + ? ToStringArray(raw) + : []; + } + + // A VM whose probe resolves `name` -> `id` for the given adapter. + private ChannelsConfigViewModel ViewModelResolving(ChannelType type, string name, string id) => type switch + { + ChannelType.Slack => CreateViewModel(slackProbe: new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult(true, null, [new ResolvedSlackChannel(name, id)], []) + }), + ChannelType.Discord => CreateViewModel(discordProbe: new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult(true, null, [new ResolvedDiscordChannel(id, name, "Guild")], []) + }), + ChannelType.Mattermost => CreateViewModel(mattermostProbe: new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult(true, null, [new ResolvedMattermostChannel(id, name, name)], []) + }), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + // A VM whose probe is reachable but reports `name` as not found (no such channel the bot can see). + private ChannelsConfigViewModel ViewModelUnresolved(ChannelType type, string name) => type switch + { + ChannelType.Slack => CreateViewModel(slackProbe: new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult(false, null, [], [name]) + }), + ChannelType.Discord => CreateViewModel(discordProbe: new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult(false, null, [], [name]) + }), + ChannelType.Mattermost => CreateViewModel(mattermostProbe: new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult(false, null, [], [name]) + }), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + // A VM whose probe fails outright (auth/scope/network) and so maps nothing. + private ChannelsConfigViewModel ViewModelProbeError(ChannelType type, string name, string error) => type switch + { + ChannelType.Slack => CreateViewModel(slackProbe: new FakeSlackProbe + { + NextResolutionResult = new SlackChannelResolutionResult(false, error, [], [name]) + }), + ChannelType.Discord => CreateViewModel(discordProbe: new FakeDiscordProbe + { + NextResolutionResult = new DiscordChannelResolutionResult(false, error, [], [name]) + }), + ChannelType.Mattermost => CreateViewModel(mattermostProbe: new FakeMattermostProbe + { + NextResolutionResult = new MattermostChannelResolutionResult(false, error, [], [name]) + }), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + // Stages an enabled adapter carrying `channelInput` in its channel field, then runs the background + // resolution that canonicalizes it (the path the sub-flow completion triggers, exercised directly). + private static async Task StageAndRefreshAsync(ChannelsConfigViewModel vm, ChannelType type, string channelInput) + { + vm.Step.LoadAdapterState(type, enabled: true, summary: "configured", adapter => + { + switch (adapter) + { + case SlackStepViewModel slack: + slack.SlackEnabled = true; + slack.BotToken = "xoxb-test"; + slack.ChannelNamesInput = channelInput; + break; + case DiscordStepViewModel discord: + discord.DiscordEnabled = true; + discord.BotToken = "discord-token"; + discord.ChannelIdsInput = channelInput; + break; + case MattermostStepViewModel mattermost: + mattermost.MattermostEnabled = true; + mattermost.ServerUrl = "https://mm.example.com"; + mattermost.BotToken = "mm-token"; + mattermost.ChannelIdsInput = channelInput; + break; + } + }); + + await vm.RefreshChannelLabelsAsync(type, TestContext.Current.CancellationToken); + } + + // Drives the real picker-driven entry flow for a brand-new adapter: select its + // row in the picker, toggle it on (which enters the credential/channel sub-flow), + // stage credentials + channel input on the step VM, step through the sub-flow to + // completion (autosaves), then resolve+add one channel in the permissions screen. + private static async Task EnableAdapterFromPickerWithChannel( + ChannelsConfigViewModel vm, + ChannelType type, + string botToken, + string? appToken, + string channelInput) + { + var adapterIndex = GetAdapterIndex(vm, type); + vm.Step.CursorIndex = adapterIndex; + Assert.True(vm.TryToggleSelectedAdapterFromPicker()); + Assert.True(vm.Step.IsInSubFlow); + + switch (type) + { + case ChannelType.Slack: + var slack = vm.Step.GetAdapterViewModel(ChannelType.Slack); + slack.BotToken = botToken; + slack.AppToken = appToken; + break; + case ChannelType.Discord: + vm.Step.GetAdapterViewModel(ChannelType.Discord).BotToken = botToken; + break; + } + + // Walk the sub-flow to completion; GoNext returns to the picker and opens the + // channel-permissions screen with an autosave once the sub-flow finishes. + for (var i = 0; i < 10 && vm.Step.IsInSubFlow; i++) + vm.GoNext(); + + Assert.False(vm.Step.IsInSubFlow); + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + + vm.BeginAddChannel(); + vm.AddChannelInput = channelInput; + await vm.ApplyAddChannelAsync(TestContext.Current.CancellationToken); + Assert.Equal(ChannelsConfigScreen.ChannelPermissions, vm.Screen.Value); + + // Return to the picker, mirroring "Done adding channels" before switching adapters. + vm.GoBack(); + vm.GoBack(); + Assert.Equal(ChannelsConfigScreen.Picker, vm.Screen.Value); + } + + private static async Task ConfirmReset(ChannelsConfigViewModel vm, ChannelType type) + { + vm.OpenAdapterManagement(type); + var resetIndex = vm.GetManagementMenuItems() + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Action == ChannelsManagementAction.ResetConnection) + .index; + vm.MoveManagementMenu(resetIndex); + vm.ActivateManagementMenuItem(); + vm.MoveResetConfirmation(1); + await vm.ApplyResetConfirmationAsync(TestContext.Current.CancellationToken); + } + + private static void MoveToManagementAction(ChannelsConfigViewModel vm, ChannelsManagementAction action) + { + var index = vm.GetManagementMenuItems() + .Select((item, itemIndex) => (item, itemIndex)) + .Single(entry => entry.item.Action == action) + .itemIndex; + + vm.MoveManagementMenu(index); + } + + // Arranges a Slack adapter parked on the reset-confirmation screen with a background label refresh + // genuinely in flight (a 5-minute probe delay holds it open). Shared by the reset tests that verify + // the reset cancels-and-awaits that refresh without racing its write or deadlocking. The caller owns + // disposal of the returned view-model. + private ChannelsConfigViewModel ArrangeSlackResetWithLabelRefreshInFlight() + { + WriteAllChannelConfig(); + WriteAllChannelSecrets(); + var slackProbe = new FakeSlackProbe + { + DelayBeforeResult = TimeSpan.FromMinutes(5), + NextResolutionResult = new SlackChannelResolutionResult( + true, null, [new ResolvedSlackChannel("general", "C01")], []), + }; + var vm = CreateViewModel(slackProbe: slackProbe); + + // Enter Manage Channels to start the background label refresh, then leave it in flight. + vm.OpenAdapterManagement(ChannelType.Slack); + MoveToManagementAction(vm, ChannelsManagementAction.ManageChannels); + vm.ActivateManagementMenuItem(); + Assert.False(vm.PendingLabelRefresh?.IsCompleted ?? true); // background is in flight + + vm.GoBack(); + MoveToManagementAction(vm, ChannelsManagementAction.ResetConnection); + vm.ActivateManagementMenuItem(); + vm.MoveResetConfirmation(1); + return vm; + } + + private static int GetAdapterIndex(ChannelsConfigViewModel vm, ChannelType type) + => vm.Step.Adapters + .Select((adapter, index) => (adapter.Type, index)) + .Single(entry => entry.Type == type) + .index; + + private static string[] ToStringArray(object? raw) + => Assert.IsType(raw).Select(static value => value switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Expected string array value.") + }).ToArray(); + + private static Dictionary ToStringDictionary(object? raw) + => Assert.IsType>(raw).ToDictionary( + static kv => kv.Key, + static kv => kv.Value switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Expected string dictionary value.") + }, + StringComparer.Ordinal); + + private void WriteChannelConfig() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { + "Enabled": true, + "SocketMode": true, + "AllowedChannelIds": ["C01", "C02", "C03"], + "AllowedUserIds": ["U01", "U02"], + "AllowDirectMessages": true, + "ChannelAudiences": { + "C01": "team", + "dm": "personal" + } + }, + "Discord": { + "Enabled": false, + "AllowedChannelIds": ["123"] + }, + "Mattermost": { + "Enabled": true, + "ServerUrl": "https://mattermost.example.com", + "DefaultChannelId": "town-square" + } + } + """); + } + + private void WriteChannelSecrets() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Slack": { + "BotToken": "xoxb-test", + "AppToken": "xapp-test" + }, + "Mattermost": { + "BotToken": "mattermost-token" + } + } + """); + } + + private void WriteAllChannelConfig() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { + "Enabled": true, + "SocketMode": true, + "AllowedChannelIds": ["C01"], + "ChannelAudiences": { "C01": "team" } + }, + "Discord": { + "Enabled": true, + "AllowedChannelIds": ["123456789"], + "ChannelAudiences": { "123456789": "team" } + }, + "Mattermost": { + "Enabled": true, + "ServerUrl": "https://mattermost.example.com", + "AllowedChannelIds": ["town-square"], + "ChannelAudiences": { "town-square": "team" } + } + } + """); + } + + private void WriteAllChannelSecrets() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Slack": { + "BotToken": "xoxb-test", + "AppToken": "xapp-test" + }, + "Discord": { + "BotToken": "discord-token" + }, + "Mattermost": { + "BotToken": "mattermost-token" + } + } + """); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ConfigAutosaveTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ConfigAutosaveTests.cs new file mode 100644 index 000000000..2f2c958ce --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ConfigAutosaveTests.cs @@ -0,0 +1,58 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; +using R3; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +/// +/// Coverage for the shared persistence-exception wrapper used by config leaf +/// editors. When a save callback throws, the wrapper must report failure rather +/// than letting the exception escape into the Termina render loop. +/// +public sealed class ConfigAutosaveTests +{ + [Fact] + public void Run_when_save_throws_returns_false_sets_error_status_and_redraws() + { + var status = new ReactiveProperty( + new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + var redraws = 0; + + var result = ConfigAutosave.Run( + save: () => throw new IOException("disk full"), + status, + failurePrefix: "Channel save failed", + requestRedraw: () => redraws++); + + Assert.False(result); + Assert.Equal(ConfigStatusTone.Error, status.Value.Tone); + Assert.StartsWith("Channel save failed", status.Value.Text, StringComparison.Ordinal); + Assert.Contains("disk full", status.Value.Text, StringComparison.Ordinal); + Assert.Equal(1, redraws); + } + + [Fact] + public async Task RunAsync_when_save_throws_returns_false_sets_error_status_and_redraws() + { + var status = new ReactiveProperty( + new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + var redraws = 0; + + var result = await ConfigAutosave.RunAsync( + saveAsync: _ => throw new IOException("disk full"), + status, + failurePrefix: "Channel save failed", + requestRedraw: () => redraws++, + ct: TestContext.Current.CancellationToken); + + Assert.False(result); + Assert.Equal(ConfigStatusTone.Error, status.Value.Tone); + Assert.StartsWith("Channel save failed", status.Value.Text, StringComparison.Ordinal); + Assert.Equal(1, redraws); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs new file mode 100644 index 000000000..bb8f2ad7a --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs @@ -0,0 +1,561 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ConfigEditorCoverageAuditTests : IDisposable +{ + private static readonly IReadOnlySet RoutedHandoffsOrGroups = new HashSet(StringComparer.Ordinal) + { + "/provider", + "/model", + "/security" + }; + + private static readonly IReadOnlyDictionary CoverageByEditorId = + new Dictionary(StringComparer.Ordinal) + { + ["audience-profiles"] = new( + nameof(SecurityAccessViewModelTests), + StructuralValidationCoverage.NotApplicable( + "Audience Profiles uses curated toggles and cycles; there are no typed paths, URIs, credentials, binaries, references, or reachability probes."), + DynamicValidationCoverage.NotApplicable("Audience Profiles edits local ACL/profile config without a runtime probe."), + null, + new RuntimeConsumerCoverage( + "ToolAccessPolicy and runtime tool dispatch consume Tools.AudienceProfiles.", + [ + "src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs", + "src/Netclaw.Actors.Tests/Tools/McpToolAudienceGrantsTests.cs" + ])), + ["browser-automation"] = new( + nameof(BrowserAutomationConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("binary", nameof(BrowserAutomationConfigViewModelTests), nameof(BrowserAutomationConfigViewModelTests.Save_refuses_enablement_when_prerequisites_are_missing))), + DynamicValidationCoverage.Required( + nameof(BrowserAutomationConfigViewModelTests), + nameof(BrowserAutomationConfigViewModelTests.Save_refuses_enablement_when_prerequisites_are_missing)), + null, + new RuntimeConsumerCoverage( + "Daemon MCP loading consumes McpServers.browser_playwright and McpServers.browser_chrome_devtools.", + [ + "src/Netclaw.Cli.Tests/Tui/Config/BrowserAutomationConfigViewModelTests.cs" + ])), + ["channels"] = new( + nameof(ChannelsConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("auth", nameof(ChannelsConfigViewModelTests), nameof(ChannelsConfigViewModelTests.Save_blocks_invalid_slack_token_before_probe)), + new ValidationConceptTest("uri", nameof(ChannelsConfigViewModelTests), nameof(ChannelsConfigViewModelTests.Save_blocks_invalid_mattermost_url_before_probe)), + new ValidationConceptTest("local-reference", nameof(ChannelsConfigViewModelTests), nameof(ChannelsConfigViewModelTests.Add_channel_that_does_not_resolve_is_dropped_with_a_warning))), + DynamicValidationCoverage.Required( + nameof(ChannelsConfigViewModelTests), + nameof(ChannelsConfigViewModelTests.Save_from_input_surfaces_dynamic_validation_exception_as_status_without_persistence)), + SecretCoverage.Required( + nameof(ChannelsConfigViewModelTests), + nameof(ChannelsConfigViewModelTests.Save_preserves_blank_existing_secrets_and_updates_config), + nameof(ChannelsConfigViewModelTests), + nameof(ChannelsConfigViewModelTests.Rotate_credentials_preserves_blank_secret_and_updates_nonblank_secret), + nameof(ChannelsConfigViewModelTests), + nameof(ChannelsConfigViewModelTests.Reset_connection_deletes_config_section_and_secrets_immediately)), + new RuntimeConsumerCoverage( + "Slack, Discord, and Mattermost gateway options plus ACL/routing consume channel config.", + [ + "src/Netclaw.Actors.Tests/Channels/Contracts/SlackAclContractTests.cs", + "src/Netclaw.Actors.Tests/Channels/Contracts/DiscordAclContractTests.cs", + "src/Netclaw.Actors.Tests/Channels/Contracts/MattermostAclContractTests.cs" + ])), + ["enabled-features"] = new( + nameof(SecurityAccessViewModelTests), + StructuralValidationCoverage.NotApplicable( + "Enabled Features edits boolean toggles from a fixed list without typed paths, URIs, credentials, binaries, references, or reachability probes."), + DynamicValidationCoverage.NotApplicable("Enabled Features toggles local boolean runtime flags without a config-time probe."), + null, + new RuntimeConsumerCoverage( + "Daemon service registration and tool availability consume per-feature Enabled flags.", + [ + "src/Netclaw.Actors.Tests/Tools/ToolRegistryTests.cs" + ])), + ["exposure-mode"] = new( + nameof(ExposureModeConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("local-reference", nameof(ExposureModeConfigViewModelTests), nameof(ExposureModeConfigViewModelTests.Saving_reverse_proxy_with_invalid_trusted_proxy_blocks_before_persistence))), + DynamicValidationCoverage.NotApplicable("Current Exposure Mode tests cover local merge and daemon consumer validation separately."), + null, + new RuntimeConsumerCoverage( + "DaemonConfig, exposure validation, and gateway authentication consume Daemon.ExposureMode.", + [ + "src/Netclaw.Configuration.Tests/DaemonConfigTests.cs", + "src/Netclaw.Daemon.Tests/Services/ExposureModeValidationServiceTests.cs", + "src/Netclaw.Daemon.Tests/Security/SessionHubAuthorizationTests.cs" + ])), + ["inbound-webhooks"] = new( + nameof(InboundWebhooksConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("timeout", nameof(InboundWebhooksConfigViewModelTests), nameof(InboundWebhooksConfigViewModelTests.Save_rejects_invalid_timeout_before_persistence))), + DynamicValidationCoverage.NotApplicable("Inbound Webhooks validates timeout bounds locally; `Webhooks.Enabled` is a feature toggle that needs no route (enable-first), and route authoring remains `netclaw webhooks`, so no remote probe runs from this editor."), + null, + new RuntimeConsumerCoverage( + "Daemon WebhooksConfig binding and WebhookRouteCatalog consume Webhooks.Enabled and Webhooks.ExecutionTimeoutSeconds.", + [ + "src/Netclaw.Cli.Tests/Tui/Config/InboundWebhooksConfigViewModelTests.cs", + "src/Netclaw.Cli.Tests/Doctor/InboundWebhookRoutesDoctorCheckTests.cs", + "src/Netclaw.Daemon.Tests/Webhooks/WebhookRouteCatalogTests.cs" + ])), + ["skill-sources"] = new( + nameof(SkillSourcesConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("path", nameof(SkillSourcesConfigViewModelTests), nameof(SkillSourcesConfigViewModelTests.Save_rejects_invalid_external_directory_before_persistence)), + new ValidationConceptTest("uri", nameof(SkillSourcesConfigViewModelTests), nameof(SkillSourcesConfigViewModelTests.Save_rejects_invalid_skill_feed_url_before_persistence)), + new ValidationConceptTest("auth", nameof(SkillSourcesConfigViewModelTests), nameof(SkillSourcesConfigViewModelTests.Save_rejects_multiline_skill_feed_api_key_before_persistence))), + DynamicValidationCoverage.Required( + nameof(SkillSourcesConfigViewModelTests), + nameof(SkillSourcesConfigViewModelTests.Save_blocks_unreachable_skill_feed_until_second_save_anyway)), + SecretCoverage.Required( + nameof(SkillSourcesConfigViewModelTests), + nameof(SkillSourcesConfigViewModelTests.Save_preserves_existing_feed_api_key_and_unrelated_secrets), + nameof(SkillSourcesConfigViewModelTests), + nameof(SkillSourcesConfigViewModelTests.Save_persists_external_directory_and_skill_feed_for_runtime_binding), + nameof(SkillSourcesConfigViewModelTests), + nameof(SkillSourcesConfigViewModelTests.Remove_token_explicitly_deletes_feed_api_key)), + new RuntimeConsumerCoverage( + "Daemon skill scanning and server feed sync consume ExternalSkills.Sources and SkillFeeds.Feeds.", + [ + "src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs", + "src/Netclaw.Configuration.Tests/ExternalSkillsConfigTests.cs", + "src/Netclaw.Actors.Tests/Skills/SkillScannerTests.cs" + ])), + ["search"] = new( + nameof(SearchConfigEditorViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("auth", nameof(SearchConfigEditorViewModelTests), nameof(SearchConfigEditorViewModelTests.Blank_secret_without_existing_value_is_still_structurally_invalid)), + new ValidationConceptTest("uri", nameof(SearchConfigEditorViewModelTests), nameof(SearchConfigEditorViewModelTests.Searxng_endpoint_requires_http_or_https_uri)), + new ValidationConceptTest("override-hard-block", nameof(SearchConfigEditorViewModelTests), nameof(SearchConfigEditorViewModelTests.Save_anyway_blocks_structural_errors_without_persistence))), + DynamicValidationCoverage.Required( + nameof(SearchConfigEditorViewModelTests), + nameof(SearchConfigEditorViewModelTests.Brave_probe_failure_opens_override_dialog_before_save)), + SecretCoverage.NoExplicitDeleteFlow( + nameof(SearchConfigEditorViewModelTests), + nameof(SearchConfigEditorViewModelTests.Blank_secret_preserves_existing_secret), + nameof(SearchConfigEditorViewModelTests), + nameof(SearchConfigEditorViewModelTests.Save_anyway_persists_config_and_secret_semantically), + nameof(SearchConfigEditorViewModelTests), + nameof(SearchConfigEditorViewModelTests.Switching_to_zero_config_backend_preserves_existing_brave_secret), + "Search backend changes preserve dormant Brave credentials; there is no explicit delete affordance yet."), + new RuntimeConsumerCoverage( + "Daemon search backend registration and WebSearchTool consume Search.Backend and backend-specific settings.", + [ + "src/Netclaw.Actors.Tests/Tools/WebSearchToolTests.cs" + ])), + ["security-posture"] = new( + nameof(SecurityAccessViewModelTests), + StructuralValidationCoverage.NotApplicable( + "Security Posture selects from fixed enum options and emits canonical posture defaults without typed paths, URIs, credentials, binaries, references, or reachability probes."), + DynamicValidationCoverage.NotApplicable("Security Posture writes enum/default policy config without a runtime probe."), + null, + new RuntimeConsumerCoverage( + "Security policy defaults and tool execution policy consume Security.DeploymentPosture.", + [ + "src/Netclaw.Configuration.Tests/SecurityPolicyDefaultsTests.cs", + "src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs" + ])), + ["telemetry-alerting"] = new( + nameof(TelemetryAlertingConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("uri", nameof(TelemetryAlertingConfigViewModelTests), nameof(TelemetryAlertingConfigViewModelTests.Save_rejects_invalid_telemetry_endpoint_before_persistence)), + new ValidationConceptTest("webhook-uri", nameof(TelemetryAlertingConfigViewModelTests), nameof(TelemetryAlertingConfigViewModelTests.Saving_a_webhook_with_a_non_http_url_is_rejected_before_persistence)), + new ValidationConceptTest("auth", nameof(TelemetryAlertingConfigViewModelTests), nameof(TelemetryAlertingConfigViewModelTests.Saving_a_webhook_with_a_malformed_auth_header_is_rejected_before_persistence))), + DynamicValidationCoverage.NotApplicable("Telemetry & Alerting validates local URI/header structure; remote delivery health is reported by doctor/runtime, not probed during this parked delivery-policy pass."), + SecretCoverage.NoExplicitDeleteFlow( + nameof(TelemetryAlertingConfigViewModelTests), + nameof(TelemetryAlertingConfigViewModelTests.Editing_a_webhook_updates_url_and_preserves_stored_header_when_blank), + nameof(TelemetryAlertingConfigViewModelTests), + nameof(TelemetryAlertingConfigViewModelTests.Editing_a_webhook_replaces_the_auth_header_when_a_nonblank_header_is_entered), + nameof(TelemetryAlertingConfigViewModelTests), + nameof(TelemetryAlertingConfigViewModelTests.Editing_a_webhook_updates_url_and_preserves_stored_header_when_blank), + "Outbound webhook auth headers preserve blank existing values and replace nonblank values; explicit delete is not in this config pass."), + new RuntimeConsumerCoverage( + "Daemon OpenTelemetry registration and operational notification delivery consume Telemetry and Notifications.Webhooks.", + [ + "src/Netclaw.Cli.Tests/Tui/Config/TelemetryAlertingConfigViewModelTests.cs", + "src/Netclaw.Daemon.Tests/Services/WebhookNotificationServiceTests.cs", + "src/Netclaw.Cli.Tests/Doctor/WebhookFormatDoctorCheckTests.cs" + ])), + ["workspaces"] = new( + nameof(WorkspacesConfigViewModelTests), + StructuralValidationCoverage.Required( + new ValidationConceptTest("path", nameof(WorkspacesConfigViewModelTests), nameof(WorkspacesConfigViewModelTests.Save_rejects_existing_file_before_persistence)), + new ValidationConceptTest("uri", nameof(WorkspacesConfigViewModelTests), nameof(WorkspacesConfigViewModelTests.Save_rejects_url_before_persistence))), + DynamicValidationCoverage.NotApplicable("Workspaces Directory validates a local filesystem path and creates the directory; it has no remote/runtime probe."), + null, + new RuntimeConsumerCoverage( + "NetclawPaths, project prompt assembly, and workspace-scoped filesystem roots consume Workspaces.Directory.", + [ + "src/Netclaw.Cli.Tests/Tui/Config/WorkspacesConfigViewModelTests.cs", + "src/Netclaw.Configuration.Tests/NetclawPathsTests.cs", + "src/Netclaw.Configuration.Tests/FileSystemPromptProviderAudienceTests.cs", + "src/Netclaw.Actors.Tests/Tools/PublicAudienceFileAccessPolicyTests.cs" + ])), + }; + + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ConfigEditorCoverageAuditTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Visible_config_leaf_editors_match_coverage_inventory() + { + var visibleEditorIds = DiscoverVisibleConfigLeafEditorIds(); + + Assert.Equal( + [ + "audience-profiles", + "browser-automation", + "channels", + "enabled-features", + "exposure-mode", + "inbound-webhooks", + "search", + "security-posture", + "skill-sources", + "telemetry-alerting", + "workspaces" + ], visibleEditorIds); + Assert.Equal(visibleEditorIds, CoverageByEditorId.Keys.OrderBy(static key => key).ToArray()); + } + + [Fact] + public void Visible_config_leaf_editors_declare_round_trip_coverage() + { + foreach (var editorId in DiscoverVisibleConfigLeafEditorIds()) + { + var coverage = CoverageByEditorId[editorId]; + + Assert.False(string.IsNullOrWhiteSpace(coverage.RoundTripTestClass), + $"Config editor '{editorId}' must declare a round-trip test class."); + AssertTestClassExists(coverage.RoundTripTestClass); + } + } + + [Fact] + public void Visible_config_leaf_editors_declare_structural_validation_coverage() + { + foreach (var editorId in DiscoverVisibleConfigLeafEditorIds()) + { + var coverage = CoverageByEditorId[editorId].StructuralValidation; + if (coverage.RequiredConcepts.Count == 0) + { + Assert.False(string.IsNullOrWhiteSpace(coverage.NotApplicableReason), + $"Config editor '{editorId}' must justify why no structural validation concepts apply."); + continue; + } + + Assert.Null(coverage.NotApplicableReason); + foreach (var (concept, test) in coverage.RequiredConcepts) + { + Assert.False(string.IsNullOrWhiteSpace(concept), + $"Config editor '{editorId}' has an unnamed structural validation concept."); + AssertTestMethodExists(test.TestClass, test.TestMethod); + } + } + } + + [Fact] + public void Visible_config_leaf_editors_declare_dynamic_validation_coverage() + { + foreach (var editorId in DiscoverVisibleConfigLeafEditorIds()) + { + var coverage = CoverageByEditorId[editorId].DynamicValidation; + + if (coverage.HasDynamicValidation) + { + Assert.False(string.IsNullOrWhiteSpace(coverage.FakeFailureTestClass), + $"Config editor '{editorId}' has dynamic validation and must name its fake-failure test class."); + Assert.False(string.IsNullOrWhiteSpace(coverage.FakeFailureTestMethod), + $"Config editor '{editorId}' has dynamic validation and must name its fake-failure test method."); + AssertTestMethodExists(coverage.FakeFailureTestClass!, coverage.FakeFailureTestMethod!); + continue; + } + + Assert.False(string.IsNullOrWhiteSpace(coverage.NotApplicableReason), + $"Config editor '{editorId}' must justify why it has no dynamic validation path."); + } + } + + [Fact] + public void Secret_writing_config_leaf_editors_declare_secret_lifecycle_coverage() + { + foreach (var (editorId, coverage) in CoverageByEditorId) + { + if (coverage.Secrets is not { } secretCoverage) + continue; + + AssertTestMethodExists(secretCoverage.BlankPreserveTestClass, secretCoverage.BlankPreserveTestMethod); + AssertTestMethodExists(secretCoverage.NonBlankReplaceTestClass, secretCoverage.NonBlankReplaceTestMethod); + + if (secretCoverage.SupportsExplicitDelete) + { + AssertTestMethodExists(secretCoverage.ExplicitDeleteTestClass!, secretCoverage.ExplicitDeleteTestMethod!); + continue; + } + + Assert.False(string.IsNullOrWhiteSpace(secretCoverage.NoExplicitDeleteReason), + $"Secret-writing config editor '{editorId}' must declare explicit-delete coverage or justify why no delete flow exists."); + AssertTestMethodExists(secretCoverage.NoExplicitDeleteTestClass!, secretCoverage.NoExplicitDeleteTestMethod!); + } + } + + [Fact] + public void Runtime_consumed_config_leaf_editors_name_consumers_and_contract_tests() + { + var repoRoot = FindRepoRoot(); + foreach (var editorId in DiscoverVisibleConfigLeafEditorIds()) + { + var runtime = CoverageByEditorId[editorId].RuntimeConsumer; + + Assert.False(string.IsNullOrWhiteSpace(runtime.Consumer), + $"Config editor '{editorId}' writes runtime-consumed config and must name its consumer."); + Assert.NotEmpty(runtime.ContractTestFiles); + foreach (var file in runtime.ContractTestFiles) + { + Assert.EndsWith("Tests.cs", file, StringComparison.Ordinal); + var fullPath = Path.Combine(repoRoot, file.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(fullPath), + $"Config editor '{editorId}' declares missing runtime contract test file '{file}'."); + } + } + } + + [Fact] + public void Skill_sources_page_routes_persistence_through_the_view_model() + { + var source = ReadRepoFile("src/Netclaw.Cli/Tui/Config/SkillSourcesConfigPage.cs"); + + // The page is presentational: it must never write config directly. Section-preserving + // persistence and validation live entirely on the view model, mirroring the Search editor. + Assert.DoesNotContain("SaveExternalConfig", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveSkillFeedsConfig", source, StringComparison.Ordinal); + Assert.DoesNotContain("ConfigFileHelper.WriteConfigFile", source, StringComparison.Ordinal); + + // The validated-UI commit framework is gone; the page drives plain Termina inputs and the + // view model's inline commit methods, mirroring the Search editor. + Assert.Contains("TextInputNode", source, StringComparison.Ordinal); + Assert.Contains("TryCommitCurrentAction(ConsoleKey.Enter)", source, StringComparison.Ordinal); + Assert.Contains("TryCommitCurrentAction(ConsoleKey.Spacebar)", source, StringComparison.Ordinal); + Assert.Contains("ViewModel.CommitRemoveSourceAction", source, StringComparison.Ordinal); + Assert.Contains("ViewModel.CommitAddRemoteToken", source, StringComparison.Ordinal); + + // The probe-warning override dialog is still rendered via the shared dialog views. + Assert.Contains("NetclawValidationDialogViews", source, StringComparison.Ordinal); + } + + [Fact] + public void Config_editor_pages_never_write_config_or_secrets_directly() + { + // Generalizes the Skill Sources guard to every registered config page: pages are + // presentational and must route all persistence through their view models. A page + // that calls a persistence primitive directly bypasses the view models' section- + // preserving merge and validation, so guard every leaf editor — not just one. + var pageSources = DiscoverConfigPageSourceFiles(); + + // If the glob ever stops finding pages (renamed directory, moved files), the guard + // would pass vacuously — fail loudly instead so the audit keeps real teeth. + Assert.True(pageSources.Count >= CoverageByEditorId.Count - 3, + $"Expected to discover config editor page sources but found {pageSources.Count}."); + + foreach (var (relativePath, source) in pageSources) + { + Assert.DoesNotContain("ConfigFileHelper.WriteConfigFile", source, StringComparison.Ordinal); + Assert.DoesNotContain("WriteSecretsFile", source, StringComparison.Ordinal); + + // The page must not invoke the view models' own persistence writers either. + Assert.DoesNotContain("SaveExternalConfig", source, StringComparison.Ordinal); + Assert.DoesNotContain("SaveSkillFeedsConfig", source, StringComparison.Ordinal); + + Assert.False(string.IsNullOrWhiteSpace(relativePath)); + } + } + + private static IReadOnlyList<(string RelativePath, string Source)> DiscoverConfigPageSourceFiles() + { + var repoRoot = FindRepoRoot(); + var configDir = Path.Combine(repoRoot, "src", "Netclaw.Cli", "Tui", "Config"); + return Directory.EnumerateFiles(configDir, "*Page.cs", SearchOption.TopDirectoryOnly) + .OrderBy(static path => path, StringComparer.Ordinal) + .Select(path => (Path.GetFileName(path), File.ReadAllText(path))) + .ToArray(); + } + + private string[] DiscoverVisibleConfigLeafEditorIds() + { + using var dashboard = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + var rootEditors = dashboard.Items + .Where(static item => item.Route is not null && !RoutedHandoffsOrGroups.Contains(item.Route)) + .Select(static item => RouteToEditorId(item.Route!)); + + using var security = new SecurityAccessViewModel(_paths); + var securityEditors = security.Items.Select(SecurityAccessItemToEditorId); + + return rootEditors.Concat(securityEditors).OrderBy(static id => id).ToArray(); + } + + private static string SecurityAccessItemToEditorId(SecurityAccessItem item) + { + return item.Label switch + { + "Security Posture" => "security-posture", + "Enabled Features" => "enabled-features", + "Audience Profiles" => "audience-profiles", + _ when item.Route is not null => RouteToEditorId(item.Route), + _ => throw new InvalidOperationException($"Security & Access item '{item.Label}' must be audited as a leaf editor.") + }; + } + + private static string RouteToEditorId(string route) => route.TrimStart('/'); + + private static void AssertTestClassExists(string testClassName) + { + var type = FindTestType(testClassName); + Assert.True(type is not null, $"Declared test class '{testClassName}' was not found."); + } + + private static void AssertTestMethodExists(string testClassName, string testMethodName) + { + var type = FindTestType(testClassName); + Assert.True(type is not null, $"Declared test class '{testClassName}' was not found."); + Assert.Contains(type!.GetMethods(), method => string.Equals(method.Name, testMethodName, StringComparison.Ordinal)); + } + + private static Type? FindTestType(string testClassName) + => typeof(ConfigEditorCoverageAuditTests).Assembly + .GetTypes() + .FirstOrDefault(type => string.Equals(type.Name, testClassName, StringComparison.Ordinal)); + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "IMPLEMENTATION_PLAN.md"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate repository root from test output directory."); + } + + private static string ReadRepoFile(string repoRelativePath) + { + var repoRoot = FindRepoRoot(); + return File.ReadAllText(Path.Combine(repoRoot, repoRelativePath.Replace('/', Path.DirectorySeparatorChar))); + } + + private sealed record ConfigEditorCoverage( + string RoundTripTestClass, + StructuralValidationCoverage StructuralValidation, + DynamicValidationCoverage DynamicValidation, + SecretCoverage? Secrets, + RuntimeConsumerCoverage RuntimeConsumer); + + private sealed record StructuralValidationCoverage( + IReadOnlyDictionary RequiredConcepts, + string? NotApplicableReason) + { + public static StructuralValidationCoverage Required(params ValidationConceptTest[] tests) + => new( + tests.ToDictionary(static test => test.Concept, static test => test.ValidationTest, StringComparer.Ordinal), + null); + + public static StructuralValidationCoverage NotApplicable(string reason) + => new(new Dictionary(StringComparer.Ordinal), reason); + } + + private sealed record ValidationConceptTest(string Concept, string TestClass, string TestMethod) + { + public ValidationTest ValidationTest { get; } = new(TestClass, TestMethod); + } + + private sealed record ValidationTest(string TestClass, string TestMethod); + + private sealed record DynamicValidationCoverage( + bool HasDynamicValidation, + string? FakeFailureTestClass, + string? FakeFailureTestMethod, + string? NotApplicableReason) + { + public static DynamicValidationCoverage Required(string fakeFailureTestClass, string fakeFailureTestMethod) + => new(true, fakeFailureTestClass, fakeFailureTestMethod, null); + + public static DynamicValidationCoverage NotApplicable(string reason) + => new(false, null, null, reason); + } + + private sealed record SecretCoverage( + string BlankPreserveTestClass, + string BlankPreserveTestMethod, + string NonBlankReplaceTestClass, + string NonBlankReplaceTestMethod, + bool SupportsExplicitDelete, + string? ExplicitDeleteTestClass, + string? ExplicitDeleteTestMethod, + string? NoExplicitDeleteTestClass, + string? NoExplicitDeleteTestMethod, + string? NoExplicitDeleteReason) + { + public static SecretCoverage Required( + string blankPreserveTestClass, + string blankPreserveTestMethod, + string nonBlankReplaceTestClass, + string nonBlankReplaceTestMethod, + string explicitDeleteTestClass, + string explicitDeleteTestMethod) + => new( + blankPreserveTestClass, + blankPreserveTestMethod, + nonBlankReplaceTestClass, + nonBlankReplaceTestMethod, + true, + explicitDeleteTestClass, + explicitDeleteTestMethod, + null, + null, + null); + + public static SecretCoverage NoExplicitDeleteFlow( + string blankPreserveTestClass, + string blankPreserveTestMethod, + string nonBlankReplaceTestClass, + string nonBlankReplaceTestMethod, + string noExplicitDeleteTestClass, + string noExplicitDeleteTestMethod, + string noExplicitDeleteReason) + => new( + blankPreserveTestClass, + blankPreserveTestMethod, + nonBlankReplaceTestClass, + nonBlankReplaceTestMethod, + false, + null, + null, + noExplicitDeleteTestClass, + noExplicitDeleteTestMethod, + noExplicitDeleteReason); + } + + private sealed record RuntimeConsumerCoverage(string Consumer, IReadOnlyList ContractTestFiles); +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs new file mode 100644 index 000000000..c0b5ab478 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigPageTests.cs @@ -0,0 +1,116 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tests.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ExposureModeConfigPageTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ExposureModeConfigPageTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "TrustedProxies": ["10.0.0.0/24"] + } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task ModeSelection_RendersActiveCheckboxForSavedExposureMode() + { + var (terminal, app, _) = CreateHeadlessApp(out var input); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.True(terminal.Contains("[x] active exposure mode"), + $"Expected active exposure-mode legend in terminal output. Screen:\n{terminal}"); + Assert.True(terminal.Contains("[x] Reverse Proxy"), + $"Expected saved reverse-proxy mode checkbox in terminal output. Screen:\n{terminal}"); + } + + [Fact] + public async Task ReverseProxySetup_AcceptsPastedHostAndTrustedProxyInput() + { + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local" + } + } + """); + var (_, app, vm) = CreateHeadlessApp(out var input); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.A, false, false, true); + input.EnqueueKey(ConsoleKey.Backspace); + input.EnqueuePaste("10.0.0.10"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueuePaste("10.0.0.0/24"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal("10.0.0.10", vm.Step.Host); + Assert.Equal(["10.0.0.0/24"], vm.Step.TrustedProxies); + } + + private (VirtualTerminal Terminal, TerminaApplication App, ExposureModeConfigViewModel Vm) + CreateHeadlessApp(out VirtualInputSource input) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + ExposureModeConfigViewModel? capturedVm = null; + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/exposure", builder => + { + builder.RegisterRoute( + "/exposure", + _ => new ExposureModeConfigPage(), + _ => + { + capturedVm = new ExposureModeConfigViewModel(_paths); + return capturedVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService(); + + return (terminal, app, capturedVm!); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs new file mode 100644 index 000000000..36097098d --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/ExposureModeConfigViewModelTests.cs @@ -0,0 +1,448 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Buffers.Text; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tests.Tui.Wizard; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class ExposureModeConfigViewModelTests : WizardStepTestBase +{ + [Fact] + public void Constructor_with_malformed_config_does_not_throw_and_surfaces_error() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, "{ not valid json "); + + // Must not throw from the constructor (which would make the Exposure page inaccessible); it + // degrades to no existing config and surfaces the read error. + using var vm = new ExposureModeConfigViewModel(Context.Paths); + + Assert.Contains("Could not read", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_prefills_existing_exposure_mode() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "TrustedProxies": ["10.0.0.0/24"] + } + } + """); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + + Assert.Equal(ExposureMode.ReverseProxy, vm.Step.SelectedMode); + Assert.Equal("10.0.0.5", vm.Step.Host); + Assert.Equal(["10.0.0.0/24"], vm.Step.TrustedProxies); + } + + [Fact] + public void Saving_tunnel_mode_preserves_unrelated_daemon_fields() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local", + "Host": "127.0.0.1", + "Port": 5299, + "DisableSelfUpdate": true + } + } + """); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + vm.GoNext(); + vm.GoNext(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("tailscale-serve", mode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.Port", out var port)); + Assert.Equal(5299L, port); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.DisableSelfUpdate", out var disableSelfUpdate)); + Assert.Equal(true, disableSelfUpdate); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Daemon.Host", out _)); + Assert.True(vm.IsSaved.Value); + } + + [Fact] + public void Saving_first_non_local_mode_auto_pairs_current_client() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local" + } + } + """); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + AdvanceTunnelModeToSave(vm); + + Assert.True(vm.IsSaved.Value); + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("tailscale-serve", mode); + + var rawToken = ReadLocalDeviceToken(); + var devices = ReadPairedDevices(); + var device = Assert.Single(devices); + Assert.True(device.IsBootstrapDevice); + Assert.True(PairedDevice.VerifyToken(rawToken, device)); + } + + [Fact] + public void Saving_non_local_with_orphaned_local_token_pairs_current_client() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local" + } + } + """); + // A real DeviceToken is always a base64url token; orphaned = present in secrets with no + // matching device in the (absent) registry. + var (orphanedToken, _) = CreatePairedDevice("orphan"); + File.WriteAllText(Context.Paths.SecretsPath, JsonSerializer.Serialize(new Dictionary + { + ["configVersion"] = 1, + ["DeviceToken"] = orphanedToken + })); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + AdvanceTunnelModeToSave(vm); + + // Auto-pair instead of blocking: keep the operator's existing token and mint a device that + // accepts it so the configuring client is not locked out of chat. + Assert.True(vm.IsSaved.Value); + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("tailscale-serve", mode); + Assert.Equal(orphanedToken, ReadLocalDeviceToken()); + var device = Assert.Single(ReadPairedDevices()); + Assert.True(device.IsBootstrapDevice); + Assert.True(PairedDevice.VerifyToken(orphanedToken, device)); + } + + [Fact] + public void Pairing_writes_devices_registry_atomically_with_owner_only_permissions() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { "ExposureMode": "local" } + } + """); + File.WriteAllText(Context.Paths.DevicesPath, "[]"); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + AdvanceTunnelModeToSave(vm); + + Assert.True(vm.IsSaved.Value); + Assert.Single(ReadPairedDevices()); + // The atomic write leaves no temp sibling behind, and devices.json stays owner-only. + var dir = Path.GetDirectoryName(Context.Paths.DevicesPath)!; + Assert.Empty(Directory.GetFiles(dir, "*.tmp-*")); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(Context.Paths.DevicesPath)); + } + + [Fact] + public void Saving_non_local_with_empty_devices_file_pairs_current_client() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local" + } + } + """); + File.WriteAllText(Context.Paths.DevicesPath, "[]"); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + AdvanceTunnelModeToSave(vm); + + // No token and an empty registry: mint a fresh token+device for the configuring client. + Assert.True(vm.IsSaved.Value); + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("tailscale-serve", mode); + var rawToken = ReadLocalDeviceToken(); + Assert.False(string.IsNullOrWhiteSpace(rawToken)); + var device = Assert.Single(ReadPairedDevices()); + Assert.True(device.IsBootstrapDevice); + Assert.True(PairedDevice.VerifyToken(rawToken, device)); + } + + [Fact] + public void Saving_non_local_with_mismatched_local_token_pairs_current_client() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "local" + } + } + """); + var (_, registeredDevice) = CreatePairedDevice("daemon-bootstrap"); + var (mismatchedToken, _) = CreatePairedDevice("other-device"); + WritePairedDevice(registeredDevice); + File.WriteAllText(Context.Paths.SecretsPath, JsonSerializer.Serialize(new Dictionary + { + ["configVersion"] = 1, + ["DeviceToken"] = mismatchedToken + })); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + AdvanceTunnelModeToSave(vm); + + // The local token matches no registered device: mint an additional device that accepts it + // without removing the pre-existing one, so the configuring client retains access. + Assert.True(vm.IsSaved.Value); + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("tailscale-serve", mode); + Assert.Equal(mismatchedToken, ReadLocalDeviceToken()); + var devices = ReadPairedDevices(); + Assert.Equal(2, devices.Count); + Assert.Contains(devices, d => PairedDevice.VerifyToken(mismatchedToken, d)); + Assert.Contains(devices, d => d.Name == registeredDevice.Name); + } + + [Fact] + public void Saving_reverse_proxy_writes_mode_specific_fields() + { + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.ReverseProxy; + vm.Step.Host = "10.0.0.5"; + vm.Step.TrustedProxies = ["10.0.0.0/24"]; + + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var mode)); + Assert.Equal("reverse-proxy", mode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.Host", out var host)); + Assert.Equal("10.0.0.5", host); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.TrustedProxies", out var proxies)); + Assert.Equal(["10.0.0.0/24"], Assert.IsType(proxies).Select(static item => item.ToString() ?? string.Empty).ToArray()); + } + + [Fact] + public void Saving_reverse_proxy_with_loopback_host_blocks_before_persistence() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + var configBefore = File.ReadAllText(Context.Paths.NetclawConfigPath); + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.ReverseProxy; + vm.Step.Host = "127.0.0.1"; + vm.Step.TrustedProxies = ["10.0.0.0/24"]; + + AdvanceReverseProxyToSave(vm); + + Assert.False(vm.IsSaved.Value); + Assert.Contains("loopback", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + Assert.Equal(configBefore, File.ReadAllText(Context.Paths.NetclawConfigPath)); + } + + [Fact] + public void Saving_reverse_proxy_with_invalid_trusted_proxy_blocks_before_persistence() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + var configBefore = File.ReadAllText(Context.Paths.NetclawConfigPath); + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.ReverseProxy; + vm.Step.Host = "10.0.0.5"; + vm.Step.TrustedProxies = ["not-a-proxy"]; + + AdvanceReverseProxyToSave(vm); + + Assert.False(vm.IsSaved.Value); + Assert.Contains("not-a-proxy", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + Assert.Equal(configBefore, File.ReadAllText(Context.Paths.NetclawConfigPath)); + } + + [Fact] + public void Saving_when_registry_write_fails_surfaces_error_without_crashing_or_claiming_success() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { "ExposureMode": "local" } + } + """); + // Force the auto-pair devices-registry access to throw the way a disk-full / permission + // failure would: ReadPairedDevices/WritePairedDevices cannot read or atomically replace a + // path that is a directory, raising IOException/UnauthorizedAccessException at the real + // call site. Before the guard, that exception escaped GoNext into the Termina event loop. + Directory.CreateDirectory(Context.Paths.DevicesPath); + + using var vm = new ExposureModeConfigViewModel(Context.Paths); + vm.Step.SelectedMode = ExposureMode.TailscaleServe; + + // Must not throw: the write failure has to be caught and surfaced, not crash the loop. + AdvanceTunnelModeToSave(vm); + + Assert.False(vm.IsSaved.Value); + Assert.Contains("Failed to save exposure mode", vm.Context.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Saving_local_mode_preserves_reverse_proxy_values_for_reactivation() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "Port": 5299, + "DisableSelfUpdate": true, + "TrustedProxies": ["10.0.0.0/24"] + } + } + """); + + using (var vm = new ExposureModeConfigViewModel(Context.Paths)) + { + vm.Step.SelectedMode = ExposureMode.Local; + vm.GoNext(); + } + + var localConfig = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.ExposureMode", out var localMode)); + Assert.Equal("local", localMode); + Assert.True(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.Port", out var port)); + Assert.Equal(5299L, port); + Assert.True(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.DisableSelfUpdate", out var disableSelfUpdate)); + Assert.Equal(true, disableSelfUpdate); + Assert.False(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.Host", out _)); + Assert.False(ConfigFileHelper.TryGetPathValue(localConfig, "Daemon.TrustedProxies", out _)); + + using (var vm = new ExposureModeConfigViewModel(Context.Paths)) + { + Assert.Equal(ExposureMode.Local, vm.Step.SelectedMode); + Assert.Equal("10.0.0.5", vm.Step.Host); + Assert.Equal(["10.0.0.0/24"], vm.Step.TrustedProxies); + + vm.Step.SelectedMode = ExposureMode.ReverseProxy; + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + } + + var reverseProxyConfig = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(reverseProxyConfig, "Daemon.ExposureMode", out var restoredMode)); + Assert.Equal("reverse-proxy", restoredMode); + Assert.True(ConfigFileHelper.TryGetPathValue(reverseProxyConfig, "Daemon.Host", out var restoredHost)); + Assert.Equal("10.0.0.5", restoredHost); + Assert.True(ConfigFileHelper.TryGetPathValue(reverseProxyConfig, "Daemon.TrustedProxies", out var restoredProxies)); + Assert.Equal(["10.0.0.0/24"], Assert.IsType(restoredProxies).Select(static item => item.ToString() ?? string.Empty).ToArray()); + } + + [Fact] + public void Escape_from_saved_state_returns_to_mode_selection_before_parent_route() + { + using var vm = new ExposureModeConfigViewModel(Context.Paths); + + vm.GoNext(); + Assert.True(vm.IsSaved.Value); + + vm.GoBack(); + + Assert.False(vm.IsSaved.Value); + Assert.Equal(0, vm.Step.CurrentSubStep); + } + + private static void AdvanceReverseProxyToSave(ExposureModeConfigViewModel vm) + { + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + vm.GoNext(); + } + + private static void AdvanceTunnelModeToSave(ExposureModeConfigViewModel vm) + { + vm.GoNext(); + vm.GoNext(); + } + + private string ReadLocalDeviceToken() + { + var secrets = ConfigFileHelper.LoadJsonDict(Context.Paths.SecretsPath); + Assert.True(secrets.TryGetValue("DeviceToken", out var rawValue)); + var rawToken = rawValue is JsonElement element ? element.GetString() : rawValue?.ToString(); + return ConfigFileHelper.DecryptIfEncrypted(Context.Paths, rawToken); + } + + private List ReadPairedDevices() + => JsonSerializer.Deserialize>(File.ReadAllText(Context.Paths.DevicesPath)) ?? []; + + private void WritePairedDevice(PairedDevice device) + => File.WriteAllText(Context.Paths.DevicesPath, JsonSerializer.Serialize(new[] { device })); + + private static (string RawToken, PairedDevice Device) CreatePairedDevice(string name) + { + var tokenBytes = RandomNumberGenerator.GetBytes(32); + var rawToken = Base64Url.EncodeToString(tokenBytes); + var saltBytes = RandomNumberGenerator.GetBytes(16); + var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); + var tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); + + return (rawToken, new PairedDevice + { + Name = name, + IsBootstrapDevice = true, + TokenHash = tokenHash, + Salt = saltHex, + CreatedAt = DateTimeOffset.UnixEpoch, + LastUsedAt = DateTimeOffset.UnixEpoch, + }); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/InboundWebhooksConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/InboundWebhooksConfigViewModelTests.cs new file mode 100644 index 000000000..9a0d228d0 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/InboundWebhooksConfigViewModelTests.cs @@ -0,0 +1,143 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class InboundWebhooksConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public InboundWebhooksConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Inbound_webhooks_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Inbound Webhooks")); + + Assert.Equal("/inbound-webhooks", route); + } + + [Fact] + public void Save_persists_global_enablement_and_timeout_for_runtime_binding() + { + WriteValidRoute("github-issues"); + using var vm = new InboundWebhooksConfigViewModel(_paths); + + vm.ToggleEnabled(); + vm.SelectedRow.Value = 1; + vm.AppendTimeoutText("120"); + + Assert.True(vm.Save()); + + var bound = BindWebhooksConfig(); + Assert.True(bound.Enabled); + Assert.Equal(120, bound.ExecutionTimeoutSeconds); + } + + [Fact] + public void Save_surfaces_write_failure_without_crashing_the_loop() + { + using var vm = new InboundWebhooksConfigViewModel(_paths); + + // Force the config write to fail like a disk-full / permission-denied failure: AtomicFile + // cannot replace a path that is a directory. The Enter-key Save() previously bypassed the + // ConfigAutosave.Run guard (only the autosave path was wrapped), letting this escape into + // the Termina event loop. + File.Delete(_paths.NetclawConfigPath); + Directory.CreateDirectory(_paths.NetclawConfigPath); + + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Save_rejects_invalid_timeout_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new InboundWebhooksConfigViewModel(_paths); + vm.SelectedRow.Value = 1; + + vm.AppendTimeoutText("0"); + + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("between 1 and 3600", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Enabling_with_no_routes_persists_and_warns() + { + using var vm = new InboundWebhooksConfigViewModel(_paths); + + // Enable-first is the intended setup order: the toggle persists and the editor + // advises adding routes rather than blocking. With zero routes the gateway is + // inert (every request 404s), so this is a valid intermediate state. + Assert.True(vm.ToggleEnabled()); + Assert.True(vm.Enabled.Value); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("netclaw webhooks set", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Webhooks.Enabled", out var enabled)); + Assert.True(enabled is bool flag && flag); + // The editor still never fabricates routes. + Assert.Empty(Directory.EnumerateFiles(_paths.WebhooksDirectory)); + } + + [Fact] + public void Disabled_save_does_not_create_dummy_routes() + { + using var vm = new InboundWebhooksConfigViewModel(_paths); + + Assert.True(vm.Save()); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Webhooks.Enabled", out var enabled)); + Assert.Equal(false, enabled); + Assert.Empty(Directory.EnumerateFiles(_paths.WebhooksDirectory)); + } + + private void WriteValidRoute(string name) + { + var store = new WebhookRouteStore(_paths); + store.Save(name, new WebhookRouteConfig + { + Prompt = "triage this webhook", + Verification = new WebhookVerificationConfig + { + Kind = WebhookVerifierKind.Hmac, + Secret = new SensitiveString("secret") + } + }); + } + + private WebhooksConfig BindWebhooksConfig() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(_paths.NetclawConfigPath) + .Build(); + return configuration.GetSection("Webhooks").Get()!; + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs new file mode 100644 index 000000000..620d88a6e --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorPageTests.cs @@ -0,0 +1,141 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using System.Net; +using System.Net.Http; +using System.Text; +using Netclaw.Cli.Tests.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SearchConfigEditorPageTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SearchConfigEditorPageTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Search": { + "Backend": "duckduckgo" + } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task ProviderSelection_RendersActiveCheckboxAndConfiguredLegend() + { + var (terminal, app, _) = CreateHeadlessApp(out var input); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.True(terminal.Contains("[x] active backend"), + $"Expected active-backend legend in terminal output. Screen:\n{terminal}"); + Assert.True(terminal.Contains("[x] DuckDuckGo"), + $"Expected active backend checkbox in terminal output. Screen:\n{terminal}"); + Assert.True(terminal.Contains("backend has saved setup"), + $"Expected configured-backend legend in terminal output. Screen:\n{terminal}"); + } + + [Fact] + public async Task SavedScreen_EscapeReturnsToProviderSelection() + { + var (terminal, app, vm) = CreateHeadlessApp(out var input); + + vm.SaveWithoutProbeOverride(); + + input.EnqueueKey(ConsoleKey.Escape); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + Assert.True(terminal.Contains("Choose the backend Netclaw uses for web search."), + $"Expected provider selection screen after Esc from saved state. Screen:\n{terminal}"); + } + + [Fact] + public async Task BraveEntry_AcceptsTypedAndPastedApiKeyInput() + { + var (_, app, vm) = CreateHeadlessApp(out var input, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"web\":{\"results\":[]}}", Encoding.UTF8, "application/json"), + })); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("BSA-"); + input.EnqueuePaste("pasted-key"); + input.EnqueueKey(ConsoleKey.LeftArrow); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal("BSA-pasted-key", vm.FieldValues["Search.BraveApiKey"].Value); + } + + private (VirtualTerminal Terminal, TerminaApplication App, SearchConfigEditorViewModel Vm) + CreateHeadlessApp(out VirtualInputSource input, IHttpClientFactory? httpClientFactory = null) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + SearchConfigEditorViewModel? capturedVm = null; + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/search", builder => + { + builder.RegisterRoute( + "/search", + _ => new SearchConfigEditorPage(), + _ => + { + capturedVm = new SearchConfigEditorViewModel(_paths, httpClientFactory); + return capturedVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService(); + + return (terminal, app, capturedVm!); + } + + private sealed class StubHttpClientFactory(Func handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) + => new(new StubHttpMessageHandler(handler)); + } + + private sealed class StubHttpMessageHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(handler(request)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs new file mode 100644 index 000000000..77bb0c97d --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchConfigEditorViewModelTests.cs @@ -0,0 +1,510 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http; +using System.Text; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SearchConfigEditorViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SearchConfigEditorViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Search": { + "Backend": "duckduckgo" + } + } + """); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Search_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Search")); + + Assert.Equal("/search", route); + } + + [Fact] + public void Fields_project_search_enabled_out_of_editor() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + Assert.DoesNotContain(vm.Fields, static field => field.Path == "Search.Enabled"); + Assert.Contains(vm.Fields, static field => field.Path == "Search.Backend"); + } + + [Fact] + public void Starts_on_provider_selection_screen() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + Assert.Equal("duckduckgo", vm.CurrentBackendValue); + Assert.Null(vm.CurrentProviderField); + } + + [Fact] + public void Selecting_brave_moves_to_entry_state() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("brave"); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Equal("brave", vm.CurrentBackendValue); + Assert.Equal("Search.BraveApiKey", vm.CurrentProviderField?.Path); + } + + [Fact] + public void Selecting_duckduckgo_enters_zero_config_workflow_state() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("duckduckgo"); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Null(vm.CurrentProviderField); + } + + [Fact] + public void Selecting_zero_config_provider_keeps_workflow_clean_when_effective_value_is_unchanged() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("brave"); + vm.BeginBackendSelection(); + vm.SelectBackendForEditing("duckduckgo"); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.False(vm.IsDirty); + Assert.Equal("duckduckgo", vm.CurrentBackendValue); + } + + [Fact] + public void Navigate_back_resets_preserved_editor_to_provider_selection() + { + using var vm = new SearchConfigEditorViewModel(_paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.SaveWithoutProbeOverride(); + vm.NavigateBack(); + + Assert.Equal("/config", route); + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + } + + [Fact] + public async Task Brave_probe_failure_opens_override_dialog_before_save() + { + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var secretsExistedBefore = File.Exists(_paths.SecretsPath); + using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.Unauthorized))); + + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "bad-key"); + + await vm.SubmitCurrentConfigurationAsync(TestContext.Current.CancellationToken); + + Assert.Equal(SearchConfigEditorDialog.ProbeWarning, vm.ActiveDialog.Value); + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Equal(string.Empty, vm.Status.Value.Text); + Assert.Contains("authentication failed", vm.LastProbeResult?.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(secretsExistedBefore, File.Exists(_paths.SecretsPath)); + } + + [Fact] + public async Task SubmitCurrentConfigurationAsync_surfaces_persistence_exception_to_awaited_caller() + { + using var vm = CreateBraveEditorWithSuccessfulProbe(); + ReplaceConfigFileWithDirectory(); + + // The awaited path surfaces the persistence failure to the caller (unlike the from-input path, + // which catches it into a status). The exact type depends on the OS/write mechanism — the + // atomic write's File.Move onto a directory throws IOException, a denied open throws + // UnauthorizedAccessException — so accept either persistence-IO exception rather than pinning + // one, while still rejecting an unexpected exception. + var ex = await Assert.ThrowsAnyAsync( + () => vm.SubmitCurrentConfigurationAsync(TestContext.Current.CancellationToken)); + Assert.True(ex is IOException or UnauthorizedAccessException, + $"Expected a persistence IO exception, got {ex.GetType().Name}: {ex.Message}"); + } + + [Fact] + public async Task Submit_from_input_surfaces_persistence_exception_as_status() + { + using var vm = CreateBraveEditorWithSuccessfulProbe(); + ReplaceConfigFileWithDirectory(); + + await vm.SubmitCurrentConfigurationFromInputAsync(TestContext.Current.CancellationToken); + + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("Search settings save failed", vm.Status.Value.Text, StringComparison.Ordinal); + } + + [Fact] + public void NavigateBack_with_malformed_config_surfaces_error_without_crashing() + { + using var vm = new SearchConfigEditorViewModel(_paths); + vm.SelectBackendForEditing("brave"); + + // Corrupt the config so the nav-back reload (_mapper.Load -> deserialize) throws JsonException + // — previously unguarded in ReloadPersistedDraft. + File.WriteAllText(_paths.NetclawConfigPath, "{ not valid json "); + + vm.NavigateBack(); // must not throw into the Termina event loop + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public async Task NavigateBack_during_validation_abandons_the_stale_probe_result() + { + var gate = new TaskCompletionSource(); + using var vm = new SearchConfigEditorViewModel(_paths, new GatedHttpClientFactory(gate.Task)); + vm.SelectBackendForEditing("searxng"); + vm.SetFieldValue("Search.SearXngEndpoint", "https://search.test.local"); + + // Start validation; the probe blocks in the gated handler, so it is genuinely in flight. + var validation = vm.SubmitCurrentConfigurationFromInputAsync(TestContext.Current.CancellationToken); + + // Navigate away while the probe is in flight: the owned CTS is cancelled, the probe is + // abandoned, and its stale result must not overwrite the navigated-to screen. + vm.NavigateBack(); + await validation; + gate.SetResult(); + + Assert.Equal(SearchConfigEditorScreen.ProviderSelection, vm.CurrentScreen.Value); + } + + [Fact] + public async Task Re_entrant_submit_while_a_probe_is_in_flight_is_ignored() + { + var gate = new TaskCompletionSource(); + using var vm = new SearchConfigEditorViewModel(_paths, new GatedHttpClientFactory(gate.Task)); + vm.SelectBackendForEditing("searxng"); + vm.SetFieldValue("Search.SearXngEndpoint", "https://search.test.local"); + + // First submit starts a probe that blocks in the gated handler. + var first = vm.SubmitCurrentConfigurationFromInputAsync(TestContext.Current.CancellationToken); + Assert.False(first.IsCompleted); + + // A second Enter while the first is still validating must NOT launch an overlapping probe + disk + // write (two would race the same config file). The guard returns the same in-flight task. + var second = vm.SubmitCurrentConfigurationFromInputAsync(TestContext.Current.CancellationToken); + Assert.Same(first, second); + + gate.SetResult(); + await first; + } + + [Fact] + public void Save_anyway_persists_config_and_secret_semantically() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", "BSA-live-key"); + vm.SaveWithoutProbeOverride(); + + var config = File.ReadAllText(_paths.NetclawConfigPath); + var secrets = File.ReadAllText(_paths.SecretsPath); + + Assert.Contains("\"Backend\": \"brave\"", config, StringComparison.Ordinal); + Assert.DoesNotContain("BraveApiKey", config, StringComparison.Ordinal); + Assert.Contains("BraveApiKey", secrets, StringComparison.Ordinal); + Assert.Contains("ENC:", secrets, StringComparison.Ordinal); + Assert.DoesNotContain("BSA-live-key", secrets, StringComparison.Ordinal); + Assert.DoesNotContain("***REDACTED***", secrets, StringComparison.Ordinal); + } + + [Fact] + public void Search_contribution_wraps_brave_api_key_as_sensitive_secret() + { + var mapper = new SearchEditorPersistenceMapper(); + var model = new SearchEditorModel { Backend = SearchBackend.Brave }; + model.Brave.ApiKeyDraft = "BSA-live-key"; + + var contribution = mapper.BuildContribution(model); + var secretAction = Assert.Single(contribution.SecretActionsOrEmpty); + + Assert.Equal("Search.BraveApiKey", secretAction.Path); + Assert.Equal(SectionSecretActionKind.Set, secretAction.Action); + Assert.NotNull(secretAction.Value); + Assert.Equal("BSA-live-key", secretAction.Value.Value); + Assert.Contains(contribution.FieldActionsOrEmpty, + action => action.Path == "Search.Backend" && Equals(action.Value, "brave")); + } + + [Fact] + public void Search_contribution_preserves_blank_existing_brave_secret() + { + var mapper = new SearchEditorPersistenceMapper(); + var model = new SearchEditorModel { Backend = SearchBackend.Brave }; + model.Brave.HasPersistedApiKey = true; + + var contribution = mapper.BuildContribution(model); + var secretAction = Assert.Single(contribution.SecretActionsOrEmpty); + + Assert.Equal("Search.BraveApiKey", secretAction.Path); + Assert.Equal(SectionSecretActionKind.Preserve, secretAction.Action); + Assert.Null(secretAction.Value); + } + + [Fact] + public void Blank_secret_preserves_existing_secret() + { + var protector = SecretsProtection.CreateProtector(_paths); + var encrypted = protector.Protect("stored-secret"); + File.WriteAllText(_paths.SecretsPath, + "{\n" + + " \"configVersion\": 1,\n" + + " \"Search\": {\n" + + $" \"BraveApiKey\": \"{encrypted}\"\n" + + " }\n" + + "}\n"); + + using var vm = new SearchConfigEditorViewModel(_paths); + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", ""); + vm.SaveWithoutProbeOverride(); + + var secrets = File.ReadAllText(_paths.SecretsPath); + Assert.Contains(encrypted, secrets, StringComparison.Ordinal); + } + + [Fact] + public void Blank_secret_without_existing_value_is_still_structurally_invalid() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SetFieldValue("Search.BraveApiKey", ""); + + var issues = vm.ValidationSummary.Value.IssuesFor("Search.BraveApiKey"); + Assert.Contains(issues, static issue => issue.Message.Contains("API key", StringComparison.OrdinalIgnoreCase)); + Assert.False(vm.HasPersistedSecret("Search.BraveApiKey")); + } + + [Fact] + public void Switching_to_zero_config_backend_preserves_existing_brave_secret() + { + var protector = SecretsProtection.CreateProtector(_paths); + var encrypted = protector.Protect("stored-secret"); + File.WriteAllText(_paths.SecretsPath, + "{\n" + + " \"configVersion\": 1,\n" + + " \"Search\": {\n" + + $" \"BraveApiKey\": \"{encrypted}\"\n" + + " }\n" + + "}\n"); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1, \"Search\": { \"Backend\": \"brave\" } }"); + + using var vm = new SearchConfigEditorViewModel(_paths); + vm.SelectBackendForEditing("duckduckgo"); + vm.SaveWithoutProbeOverride(); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveKey)); + Assert.Equal("stored-secret", ConfigFileHelper.DecryptIfEncrypted(_paths, braveKey?.ToString())); + } + + [Fact] + public void Switching_to_duckduckgo_preserves_inactive_searxng_endpoint() + { + File.WriteAllText(_paths.NetclawConfigPath, """ + { + "configVersion": 1, + "Search": { + "Backend": "searxng", + "SearXngEndpoint": "https://search.example.com" + } + } + """); + + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SelectBackendForEditing("duckduckgo"); + vm.SaveWithoutProbeOverride(); + + var reloaded = new SearchConfigEditorViewModel(_paths); + var config = File.ReadAllText(_paths.NetclawConfigPath); + + Assert.Contains("\"Backend\": \"duckduckgo\"", config, StringComparison.Ordinal); + Assert.Contains("\"SearXngEndpoint\": \"https://search.example.com\"", config, StringComparison.Ordinal); + Assert.Equal("https://search.example.com", reloaded.FieldValues["Search.SearXngEndpoint"].Value); + } + + [Fact] + public async Task Successful_probe_allows_save_without_dialog() + { + using var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"web\":{\"results\":[]}}", Encoding.UTF8, "application/json"), + })); + + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "good-key"); + + await vm.SubmitCurrentConfigurationAsync(TestContext.Current.CancellationToken); + + Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + Assert.Equal(SearchConfigEditorScreen.Saved, vm.CurrentScreen.Value); + Assert.Contains("Saved Search settings", vm.Status.Value.Text, StringComparison.Ordinal); + } + + [Fact] + public void Missing_required_backend_specific_fields_raise_structural_errors() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "searxng"); + vm.SetFieldValue("Search.SearXngEndpoint", ""); + + var issues = vm.ValidationSummary.Value.IssuesFor("Search.SearXngEndpoint"); + Assert.Contains(issues, static issue => issue.Message.Contains("requires an endpoint", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Searxng_endpoint_requires_http_or_https_uri() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "searxng"); + var result = vm.CommitField("Search.SearXngEndpoint", "ftp://search.example.com"); + + Assert.False(result.Success); + Assert.Contains(result.Issues, + static issue => issue.Message.Contains("http:// or https://", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("ftp://search.example.com", vm.FieldValues["Search.SearXngEndpoint"].Value); + } + + [Fact] + public void Save_anyway_blocks_structural_errors_without_persistence() + { + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SetFieldValue("Search.Backend", "brave"); + vm.SaveWithoutProbeOverride(); + + Assert.Equal(SearchConfigEditorDialog.None, vm.ActiveDialog.Value); + Assert.Equal(SearchConfigEditorScreen.Entry, vm.CurrentScreen.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("API key", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(configBefore, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.False(File.Exists(_paths.SecretsPath)); + } + + [Fact] + public void Preserved_state_supports_in_memory_draft_edits() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SelectBackendForEditing("searxng"); + vm.StageFieldValue("Search.SearXngEndpoint", "https://search.example.com"); + vm.CommitFieldValue("Search.SearXngEndpoint"); + vm.OnDeactivating(); + vm.OnActivated(); + + Assert.Equal("searxng", vm.FieldValues["Search.Backend"].Value); + Assert.Equal("https://search.example.com", vm.FieldValues["Search.SearXngEndpoint"].Value); + } + + [Fact] + public void Invalid_endpoint_submission_keeps_typed_draft_without_mutating_accepted_value() + { + using var vm = new SearchConfigEditorViewModel(_paths); + + vm.SelectBackendForEditing("searxng"); + var field = Assert.IsType(vm.CurrentProviderField); + + var result = vm.CommitField(field.Path, "search.local"); + + Assert.False(result.Success); + Assert.Equal("search.local", vm.FieldValues[field.Path].Value); + Assert.Equal("(not configured)", vm.GetDisplayValue(field)); + } + + private sealed class StubHttpClientFactory(Func handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) + => new(new StubHttpMessageHandler(handler)); + } + + private SearchConfigEditorViewModel CreateBraveEditorWithSuccessfulProbe() + { + var vm = new SearchConfigEditorViewModel(_paths, new StubHttpClientFactory(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"web\":{\"results\":[]}}", Encoding.UTF8, "application/json"), + })); + vm.SelectBackendForEditing("brave"); + vm.StageFieldValue("Search.BraveApiKey", "good-key"); + return vm; + } + + private void ReplaceConfigFileWithDirectory() + { + File.Delete(_paths.NetclawConfigPath); + Directory.CreateDirectory(_paths.NetclawConfigPath); + } + + private sealed class StubHttpMessageHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(handler(request)); + } + + // Blocks the probe in an awaited send until the gate completes (or the request is cancelled), so a + // test can navigate away while the probe is genuinely in flight. + private sealed class GatedHttpClientFactory(Task gate) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new GatedHttpMessageHandler(gate)); + } + + private sealed class GatedHttpMessageHandler(Task gate) : HttpMessageHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }; + } + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs new file mode 100644 index 000000000..4149d772b --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SearchSectionSpecTests.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Cli.Tui.Config; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SearchSectionSpecTests +{ + [Fact] + public void Fields_are_projected_from_runtime_config_metadata_keys() + { + var spec = new SearchSectionSpec(); + + Assert.Contains(spec.Fields, field => field.Path == "Search.Backend"); + + var brave = Assert.Single(spec.Fields, field => field.Path == "Search.BraveApiKey"); + Assert.Equal(ConfigFieldStorage.SecretsFile, brave.Storage); + Assert.Equal(ConfigFieldWidget.PasswordInput, brave.Widget); + Assert.True(brave.PreserveBlankSecret); + + var searXng = Assert.Single(spec.Fields, field => field.Path == "Search.SearXngEndpoint"); + Assert.Equal(ConfigFieldStorage.ConfigFile, searXng.Storage); + Assert.Equal(ConfigFieldWidget.TextInput, searXng.Widget); + } + + [Fact] + public void Provider_field_follows_selected_backend() + { + var spec = new SearchSectionSpec(); + var model = new SearchEditorModel { Backend = SearchBackend.Brave }; + + Assert.Equal("Search.BraveApiKey", spec.GetProviderField(model)?.Path); + + model.Backend = SearchBackend.SearXng; + Assert.Equal("Search.SearXngEndpoint", spec.GetProviderField(model)?.Path); + + model.Backend = SearchBackend.DuckDuckGo; + Assert.Null(spec.GetProviderField(model)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs new file mode 100644 index 000000000..79cacda1e --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessNavigationTests.cs @@ -0,0 +1,127 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Daemon; +using Netclaw.Cli.Mcp; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SecurityAccessNavigationTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SecurityAccessNavigationTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task McpGrants_Escape_ReturnsToSecurityUsingTerminaHistory() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + var app = CreateHeadlessApp(out var input, out var securityVm, out var getMcpVm); + securityVm.SelectedAudienceIndex.Value = 1; + securityVm.OpenSelectedAudienceProfile(); + securityVm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.McpPermissions; + + securityVm.ActivateSelectedAudienceProfileRow(); + input.EnqueueKey(ConsoleKey.Escape); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var mcpVm = Assert.IsType(getMcpVm()); + Assert.Equal("/security", app.CurrentPath); + Assert.Equal(TrustAudience.Team, mcpVm.SelectedAudience); + } + + private TerminaApplication CreateHeadlessApp( + out VirtualInputSource input, + out SecurityAccessViewModel securityVm, + out Func getMcpVm) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + + var navigationState = new McpToolPermissionsNavigationState(); + var tuiNavigation = new TuiNavigation(); + SecurityAccessViewModel? capturedSecurityVm = null; + McpToolPermissionsViewModel? capturedMcpVm = null; + + var configuration = new ConfigurationBuilder().Build(); + var daemonApi = new DaemonApi(new FailingHttpClientFactory(), configuration, _paths); + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddSingleton(navigationState); + services.AddSingleton(tuiNavigation); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/security", builder => + { + builder.RegisterRoute( + "/security", + _ => new SecurityAccessPage(), + _ => + { + capturedSecurityVm = new SecurityAccessViewModel(_paths, navigationState); + return capturedSecurityVm; + }); + builder.RegisterRoute( + "/mcp-tools", + _ => new McpToolPermissionsPage(), + _ => + { + capturedMcpVm = new McpToolPermissionsViewModel(_paths, daemonApi, navigationState, tuiNavigation); + return capturedMcpVm; + }); + }); + + var sp = services.BuildServiceProvider(); + var app = sp.GetRequiredService(); + tuiNavigation.Attach(app); + + securityVm = capturedSecurityVm!; + getMcpVm = () => capturedMcpVm; + return app; + } + + private sealed class FailingHttpHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => throw new HttpRequestException("Test: no daemon available"); + } + + private sealed class FailingHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(new FailingHttpHandler()); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs new file mode 100644 index 000000000..7f9954f11 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -0,0 +1,439 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Cli.Json; +using Netclaw.Cli.Mcp; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tests.Tui.Wizard; +using Netclaw.Configuration; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SecurityAccessViewModelTests : WizardStepTestBase +{ + [Fact] + public void Security_access_lists_expected_leaf_entries() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + + var labels = vm.Items.Select(static item => item.Label).ToArray(); + + Assert.Equal( + [ + "Security Posture", + "Enabled Features", + "Audience Profiles", + "Exposure Mode" + ], labels); + } + + [Fact] + public void Unparseable_posture_fails_loud_and_closed_not_permissive() + { + // A stored posture the editor cannot parse (renamed enum member, stale value, hand-edited + // typo) must NOT be silently treated as the permissive Personal default. It fails closed to + // Public (matching the daemon's TrustContextPolicy fallback) and surfaces the corruption. + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Galaxy-Brain" } + } + """); + using var vm = new SecurityAccessViewModel(Context.Paths); + + Assert.NotEqual(DeploymentPosture.Personal, vm.CurrentPosture); // no permissive assumption + Assert.Equal(DeploymentPosture.Public, vm.CurrentPosture); // fail closed + Assert.NotNull(vm.PostureConfigWarning); + Assert.Contains("Galaxy-Brain", vm.PostureConfigWarning!, StringComparison.Ordinal); + + var postureSummary = vm.Items.Single(static item => item.Label == "Security Posture").Summary; + Assert.Contains("Unknown", postureSummary, StringComparison.Ordinal); + } + + [Fact] + public void Malformed_exposure_and_audience_config_render_a_status_without_crashing() + { + // A hand-edited/migrated config with an unsupported ExposureMode or a malformed + // Tools.AudienceProfiles blob must not throw on the render path (Items is read every frame) + // or on the audience-profile load path — it degrades to a visible status instead. + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { "ExposureMode": "WormHole" }, + "Tools": { "AudienceProfiles": "not-an-object" } + } + """); + using var vm = new SecurityAccessViewModel(Context.Paths); + + var items = vm.Items; // render path — must not throw + Assert.Contains("WormHole", items.Single(static i => i.Label == "Exposure Mode").Summary, StringComparison.Ordinal); + Assert.Contains("Unreadable", items.Single(static i => i.Label == "Audience Profiles").Summary, StringComparison.Ordinal); + + // Audience-profile load path (used by mutation handlers + override-status reads) must not throw. + var status = vm.SelectedAudienceOverrideStatus; + Assert.False(string.IsNullOrEmpty(status)); + } + + [Fact] + public void Exposure_mode_routes_to_exposure_editor() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.Activate(vm.Items.Single(static item => item.Label == "Exposure Mode")); + + Assert.Equal("/exposure-mode", route); + } + + [Fact] + public void Enabled_features_opens_inline_global_toggle_editor() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.Activate(vm.Items.Single(static item => item.Label == "Enabled Features")); + + Assert.Equal(SecurityAccessEditorMode.Features, vm.Mode.Value); + Assert.Null(route); + } + + [Fact] + public void Security_posture_saves_posture_and_shell_defaults() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.OpenPostureEditor(); + vm.SelectedPostureIndex.Value = 1; + + vm.ApplySelectedPosture(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var posture)); + Assert.Equal("Team", posture); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Security.ShellExecutionMode", out var securityShellMode)); + Assert.Equal("Off", securityShellMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.ShellMode", out var toolsShellMode)); + Assert.Equal("Off", toolsShellMode); + Assert.Equal(SecurityAccessEditorMode.Features, vm.Mode.Value); + } + + [Fact] + public void Audience_profiles_opens_inline_audience_list() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? route = null; + vm.RouteRequested = value => route = value; + + vm.Activate(vm.Items.Single(static item => item.Label == "Audience Profiles")); + + Assert.Equal(SecurityAccessEditorMode.AudienceList, vm.Mode.Value); + Assert.Null(route); + } + + [Fact] + public void Audience_profile_toggle_updates_selected_profile_only() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.WebAccess; + + vm.ActivateSelectedAudienceProfileRow(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.AllowedTools", out var teamTools)); + var teamAllowedTools = Assert.IsAssignableFrom(teamTools); + Assert.DoesNotContain(teamAllowedTools, static tool => tool?.ToString() == "web_search"); + Assert.DoesNotContain(teamAllowedTools, static tool => tool?.ToString() == "web_fetch"); + + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Public.AllowedTools", out _)); + Assert.Equal("Customized", vm.AudienceOverrideMarker(TrustAudience.Team)); + Assert.Equal("", vm.AudienceOverrideMarker(TrustAudience.Public)); + Assert.Equal("Customized overrides", vm.SelectedAudienceOverrideStatus); + } + + [Fact] + public void Audience_profile_toggle_from_all_mode_materializes_profile_managed_allowlist() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Personal" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 0; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.WebAccess; + + vm.ActivateSelectedAudienceProfileRow(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Personal.ToolsMode", out var mode)); + Assert.Equal("Allowlist", mode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Personal.AllowedTools", out var tools)); + var allowedTools = Assert.IsAssignableFrom(tools).Select(static tool => tool?.ToString() ?? string.Empty).ToArray(); + var expected = ToolAudienceProfileToolCatalog.ProfileManagedTools + .Except(ToolAudienceProfileToolCatalog.WebTools) + .ToArray(); + + Assert.Equal(expected, allowedTools); + Assert.Contains(ToolAudienceProfileToolCatalog.ShellExecute, allowedTools); + Assert.Contains(ToolAudienceProfileToolCatalog.SetWebhook, allowedTools); + Assert.DoesNotContain(ToolAudienceProfileToolCatalog.WebSearch, allowedTools); + Assert.DoesNotContain(ToolAudienceProfileToolCatalog.WebFetch, allowedTools); + } + + [Fact] + public void Audience_profiles_summary_reports_overrides_not_defaults() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + + var audienceProfiles = vm.Items.Single(static item => item.Label == "Audience Profiles"); + Assert.Equal("No overrides", audienceProfiles.Summary); + Assert.Equal("", vm.AudienceOverrideMarker(TrustAudience.Team)); + Assert.False(vm.IsSystemDefaultAudience(TrustAudience.Personal)); + Assert.True(vm.IsSystemDefaultAudience(TrustAudience.Team)); + Assert.False(vm.IsSystemDefaultAudience(TrustAudience.Public)); + + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.WebAccess; + vm.ActivateSelectedAudienceProfileRow(); + + audienceProfiles = vm.Items.Single(static item => item.Label == "Audience Profiles"); + Assert.Equal("Customized", audienceProfiles.Summary); + Assert.Equal("Customized", vm.AudienceOverrideMarker(TrustAudience.Team)); + } + + [Fact] + public void Audience_profile_file_scope_cycle_keeps_team_scope_restricted() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.FileAccess; + + vm.ActivateSelectedAudienceProfileRow(); + vm.ActivateSelectedAudienceProfileRow(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.ReadFiles.Mode", out var readMode)); + Assert.Equal("Roots", readMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.WriteFiles.Mode", out var writeMode)); + Assert.Equal("Roots", writeMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles.Team.AttachFiles.Mode", out var attachMode)); + Assert.Equal("Roots", attachMode); + } + + [Fact] + public void Audience_profile_mcp_grants_routes_to_permissions_for_selected_audience() + { + var navigationState = new McpToolPermissionsNavigationState(); + using var vm = new SecurityAccessViewModel(Context.Paths, navigationState); + string? route = null; + vm.RouteRequested = value => route = value; + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.McpPermissions; + + vm.ActivateSelectedAudienceProfileRow(); + + Assert.Equal("/mcp-tools", route); + Assert.Equal(TrustAudience.Team, navigationState.ConsumeInitialAudience()); + } + + [Fact] + public void Reset_to_posture_default_clears_hidden_mcp_and_approval_settings() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" }, + "Tools": { + "AudienceProfiles": { + "Team": { + "ToolsMode": "Allowlist", + "AllowedTools": ["file_read", "file_list", "attach_file"], + "McpServersMode": "All", + "AllowedMcpServers": ["memorizer"], + "McpServerToolGrants": { + "memorizer": ["search_memories", "get"] + }, + "ApprovalPolicy": { + "DefaultMode": "Deny", + "ToolOverrides": { + "shell_execute": "Approval" + } + } + } + } + } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedAudienceIndex.Value = 1; + vm.OpenSelectedAudienceProfile(); + vm.SelectedAudienceRowIndex.Value = (int)AudienceProfileRowKind.ResetToDefault; + + vm.ActivateSelectedAudienceProfileRow(); + + var root = JsonSerializer.Deserialize( + File.ReadAllText(Context.Paths.NetclawConfigPath), + JsonDefaults.ConfigRead); + Assert.NotNull(root); + var team = root.Tools.AudienceProfiles.Team; + Assert.Contains(ToolAudienceProfileToolCatalog.WebSearch, team.AllowedTools); + Assert.Equal(ToolProfileMode.Allowlist, team.McpServersMode); + Assert.Null(team.McpServerToolGrants); + Assert.Null(team.ApprovalPolicy); + Assert.Empty(team.AllowedMcpServers); + } + + [Fact] + public void Enabled_features_summary_treats_missing_flags_as_enabled() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + + var features = vm.Items.Single(static item => item.Label == "Enabled Features"); + Assert.Equal("6/6 enabled", features.Summary); + } + + [Fact] + public void Toggle_selected_feature_persists_global_flag_and_preserves_siblings() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" }, + "Memory": { "Enabled": true }, + "Search": { + "Enabled": false, + "Backend": "searxng", + "SearXngEndpoint": "https://search.example.com" + }, + "SkillSync": { "Enabled": true }, + "Scheduling": { "Enabled": false }, + "SubAgents": { "Enabled": true }, + "Webhooks": { "Enabled": false } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedFeatureIndex.Value = 1; + + vm.ToggleSelectedFeature(); + + var config = ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Search.Enabled", out var searchEnabled)); + Assert.Equal(true, searchEnabled); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Search.Backend", out var backend)); + Assert.Equal("searxng", backend); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Search.SearXngEndpoint", out var endpoint)); + Assert.Equal("https://search.example.com", endpoint); + } + + [Fact] + public void Toggle_selected_feature_surfaces_save_failure_and_rolls_back_without_crashing() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" }, + "Search": { "Enabled": false } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.SelectedFeatureIndex.Value = 1; + var before = vm.IsFeatureEnabled(1); + + // Force the ConfigEditorSession write to fail the way a disk-full / permission-denied failure + // would: AtomicFile cannot replace a path that is a directory. LoadJsonDict treats the + // directory as "missing" (File.Exists is false), so only the Save() write throws — matching + // the real bug where the toggle's session.Save() was unguarded. + ReplaceConfigFileWithDirectory(); + + // Must not throw into the Termina event loop. + vm.ToggleSelectedFeature(); + + Assert.Contains("Failed to save", vm.StatusMessage.Value, StringComparison.OrdinalIgnoreCase); + // The in-memory flip rolled back: a toggle that never reached disk must not stick. + Assert.Equal(before, vm.IsFeatureEnabled(1)); + } + + [Fact] + public void Exposure_summary_reads_existing_daemon_mode() + { + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { "ExposureMode": "cloudflare-tunnel" } + } + """); + + using var vm = new SecurityAccessViewModel(Context.Paths); + + var exposure = vm.Items.Single(static item => item.Label == "Exposure Mode"); + Assert.Equal("Cloudflare Tunnel", exposure.Summary); + } + + private void ReplaceConfigFileWithDirectory() + { + if (File.Exists(Context.Paths.NetclawConfigPath)) + File.Delete(Context.Paths.NetclawConfigPath); + Directory.CreateDirectory(Context.Paths.NetclawConfigPath); + } + + private sealed class SecurityAccessConfigRoot + { + public ToolConfig Tools { get; set; } = new(); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs new file mode 100644 index 000000000..9e3649921 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/SkillSourcesConfigViewModelTests.cs @@ -0,0 +1,763 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class SkillSourcesConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public SkillSourcesConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Skill_sources_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Skill Sources")); + + Assert.Equal("/skill-sources", route); + } + + [Fact] + public async Task Save_persists_external_directory_and_skill_feed_for_runtime_binding() + { + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true, requiresAuth: true)); + + AddLocalFolder(vm, externalDir, "team-skills"); + await AddRemoteServer(vm, "https://skills.example.test", "secret-token", "custom-feed"); + + var external = Bind("ExternalSkills"); + var resolved = external.ResolveEnabledSources(); + Assert.Contains(resolved, source => source.Name == "team-skills" && source.Paths.Contains(externalDir)); + + var feed = SingleFeedSection(); + Assert.Equal("custom-feed", feed["Name"]); + Assert.Equal("https://skills.example.test", feed["Url"]); + var storedApiKey = feed["ApiKey"]; + Assert.NotNull(storedApiKey); + Assert.StartsWith("ENC:", storedApiKey!, StringComparison.Ordinal); + Assert.Equal("secret-token", Decrypt(storedApiKey!)); + Assert.DoesNotContain("secret-token", File.ReadAllText(_paths.NetclawConfigPath), StringComparison.Ordinal); + } + + // The picker can't produce these inputs, but CommitAddLocalPath still validates them: a URL is + // not a local path, and a bare name resolves to a well-formed but non-existent directory under + // the temp dir. Both must surface an error and persist nothing. + [Theory] + [InlineData("https://example.test/skills", "local filesystem path")] + [InlineData("missing-skills", "must already exist")] + public void Save_rejects_invalid_external_directory_before_persistence(string draftInput, string expectedError) + { + var target = draftInput.Contains("://", StringComparison.Ordinal) + ? draftInput + : Path.Combine(_dir.Path, draftInput); + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + BeginAddLocalFolder(vm); + vm.CommitAddLocalPath(target); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains(expectedError, vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Adding_a_source_surfaces_config_write_failure_without_crashing() + { + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + // Force the config write to fail like a disk-full / permission-denied failure: AtomicFile + // cannot replace a path that is a directory. LoadJsonDict treats it as missing, so the save's + // read returns a skeleton and only the write throws — exercising the TryEditConfig guard that + // now brackets the save read+write as one unit. + File.Delete(_paths.NetclawConfigPath); + Directory.CreateDirectory(_paths.NetclawConfigPath); + + // Drive the full add-local-folder flow (path -> symlinks -> name); the final commit persists + // via SaveExternalConfig. (We inline rather than call AddLocalFolder, which asserts the + // success-path screen transition that does not happen when the save fails.) + BeginAddLocalFolder(vm); + vm.CommitAddLocalPath(externalDir); + vm.ActivateSelected(); + ReplaceDraft(vm, "team-skills"); + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public async Task Adding_a_remote_source_surfaces_keyring_failure_without_crashing() + { + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true, requiresAuth: true)); + + // Drive the remote-add flow up to (but not through) the final commit, which encrypts the token. + BeginAddRemoteServer(vm); + vm.AppendText("https://skills.example.test"); + vm.ActivateSelected(); + await vm.PendingProbe!; + vm.ActivateSelected(); // RequiresAuth -> token field + vm.AppendText("secret-token"); + vm.ActivateSelected(); + await vm.PendingProbe!; + vm.ActivateSelected(); // success -> name review + ReplaceDraft(vm, "custom-feed"); + + // Make the DataProtection keys directory unusable (a file, not a directory) so the commit's + // ProtectApiKeyForConfig().Protect() throws the way an unavailable / rotated key ring would. + if (Directory.Exists(_paths.KeysDirectory)) + Directory.Delete(_paths.KeysDirectory, recursive: true); + File.WriteAllText(_paths.KeysDirectory, "not a directory"); + + vm.ActivateSelected(); // commit -> key-ring failure must surface, not crash the loop + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Malformed_config_does_not_crash_construction_or_a_source_mutation() + { + File.WriteAllText(_paths.NetclawConfigPath, "{ this is not valid json "); + + // Construction (ReloadSources) must not throw on a malformed config — it degrades to an empty + // source list with an error Status instead of leaving the page permanently inaccessible. + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + Assert.Empty(vm.Sources); + + // A mutation's pre-save read (LoadExternalConfig, now guarded by TryLoadExternalConfig) must + // likewise surface an error rather than throwing into the Termina event loop. + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + BeginAddLocalFolder(vm); + vm.CommitAddLocalPath(externalDir); + vm.ActivateSelected(); + ReplaceDraft(vm, "team-skills"); + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Save_external_directory_does_not_decrypt_unedited_feed_api_key() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"ENC:not-valid-for-this-keyring\",\"Enabled\":true}]}}"); + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + AddLocalFolder(vm, externalDir, "team-skills"); + + Assert.Equal(externalDir, Bind("ExternalSkills").ResolveEnabledSources().Single().Paths.Single()); + Assert.Equal("ENC:not-valid-for-this-keyring", SingleFeedSection()["ApiKey"]); + } + + [Fact] + public void Save_rejects_invalid_skill_feed_url_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + BeginAddRemoteServer(vm); + vm.AppendText("file:///tmp/skills"); + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("HTTP or HTTPS", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Save_rejects_multiline_skill_feed_api_key_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true, requiresAuth: true)); + + // Two-phase add-remote review: the URL probe (off-loop) reports RequiresAuth; phase 2 reveals + // the token field. A multiline token is then rejected by structural validation before any + // re-probe or persistence. + BeginAddRemoteServer(vm); + vm.AppendText("https://skills.example.test"); + vm.ActivateSelected(); // phase 1: no-auth probe -> 401 + await vm.PendingProbe!; + vm.ActivateSelected(); // phase 2: RequiresAuth -> reveal token field + Assert.Equal(SkillSourcesScreen.AddRemoteToken, vm.Screen.Value); + vm.AppendText("token\nnext"); + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("single-line", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Save_blocks_unreachable_skill_feed_until_second_save_anyway() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(false)); + + // Two-phase: the first Enter on AddRemoteUrl kicks off the off-loop probe (phase 1). Once it + // completes (unreachable, non-auth) the status warns "save anyway" and the screen stays on + // AddRemoteUrl — nothing persisted. A second Enter (phase 2) acts on that result and advances + // to the name review. This preserves the original intent: an unreachable open server can still + // be added via a deliberate second Enter. + BeginAddRemoteServer(vm); + vm.AppendText("https://skills.example.test"); + vm.ActivateSelected(); // phase 1: kick off probe + await vm.PendingProbe!; + + Assert.Equal(SkillSourcesScreen.AddRemoteUrl, vm.Screen.Value); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("save anyway", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + + vm.ActivateSelected(); // phase 2: save anyway -> name review + Assert.Equal(SkillSourcesScreen.AddRemoteName, vm.Screen.Value); + ReplaceDraft(vm, "custom-feed"); + vm.ActivateSelected(); + + var feed = SingleFeedSection(); + Assert.Equal("https://skills.example.test", feed["Url"]); + Assert.Null(feed["ApiKey"]); + } + + [Fact] + public void Save_preserves_existing_feed_api_key_and_unrelated_secrets() + { + var protector = SecretsProtection.CreateProtector(_paths); + var encryptedApiKey = protector.Protect("old-token"); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"SkillFeeds\":{{\"Feeds\":[{{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"{encryptedApiKey}\",\"Enabled\":true}}]}}}}"); + File.WriteAllText(_paths.SecretsPath, "{\"Providers\":{\"openrouter\":{\"ApiKey\":\"ENC:provider\"}}}"); + var beforeSecrets = File.ReadAllText(_paths.SecretsPath); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + OpenRemoteDetail(vm, "custom-feed"); + MoveToDetailAction(vm, SkillSourceDetailAction.ChangeLocation); + vm.ActivateSelected(); + ReplaceDraft(vm, "https://new.example.test"); + vm.ActivateSelected(); + + var feed = SingleFeedSection(); + Assert.Equal("https://new.example.test", feed["Url"]); + Assert.Equal(encryptedApiKey, feed["ApiKey"]); + Assert.Equal("old-token", protector.Unprotect(feed["ApiKey"]!)); + Assert.Equal(beforeSecrets, File.ReadAllText(_paths.SecretsPath)); + } + + [Fact] + public void Location_detail_row_opens_local_path_editor() + { + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + AddLocalFolder(vm, externalDir, "team-skills"); + MoveToDetailAction(vm, SkillSourceDetailAction.Location); + vm.ActivateSelected(); + + Assert.Equal(SkillSourcesScreen.ChangeLocation, vm.Screen.Value); + Assert.Equal(externalDir, vm.Draft.Value); + } + + [Fact] + public async Task Location_detail_row_opens_remote_url_editor() + { + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true, requiresAuth: true)); + + await AddRemoteServer(vm, "https://skills.example.test", "secret-token", "custom-feed"); + MoveToDetailAction(vm, SkillSourceDetailAction.Location); + vm.ActivateSelected(); + + Assert.Equal(SkillSourcesScreen.ChangeLocation, vm.Screen.Value); + Assert.Equal("https://skills.example.test", vm.Draft.Value); + } + + [Fact] + public void Local_source_status_warns_when_runtime_scan_reports_issues() + { + var externalDir = Path.Combine(_dir.Path, "team-skills"); + var invalidSkillDir = Path.Combine(externalDir, "broken-skill"); + Directory.CreateDirectory(invalidSkillDir); + File.WriteAllText(Path.Combine(invalidSkillDir, "SKILL.md"), "not frontmatter"); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"ExternalSkills\":{{\"Sources\":[{{\"Name\":\"team-skills\",\"Path\":\"{externalDir.Replace("\\", "\\\\", StringComparison.Ordinal)}\",\"Enabled\":true,\"AllowSymlinks\":false}}]}}}}"); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + var source = Assert.Single(vm.Sources); + Assert.Equal(ConfigStatusTone.Warning, source.StatusTone); + Assert.Contains("scan warning", source.StatusText, StringComparison.OrdinalIgnoreCase); + + OpenLocalDetail(vm, "team-skills"); + MoveToDetailAction(vm, SkillSourceDetailAction.Rescan); + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("scan warning", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Remove_token_explicitly_deletes_feed_api_key() + { + var protector = SecretsProtection.CreateProtector(_paths); + var encryptedApiKey = protector.Protect("old-token"); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"SkillFeeds\":{{\"Feeds\":[{{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"{encryptedApiKey}\",\"Enabled\":true}}]}}}}"); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + OpenRemoteDetail(vm, "custom-feed"); + MoveToDetailAction(vm, SkillSourceDetailAction.RemoveToken); + vm.ActivateSelected(); + + Assert.Null(SingleFeedSection()["ApiKey"]); + Assert.Equal(ConfigStatusTone.Success, vm.Status.Value.Tone); + Assert.Contains("token removed", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Rotate_token_persists_new_encrypted_token_and_invalidates_old() + { + var protector = SecretsProtection.CreateProtector(_paths); + var oldEncrypted = protector.Protect("old-token"); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"SkillFeeds\":{{\"Feeds\":[{{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"{oldEncrypted}\",\"Enabled\":true}}]}}}}"); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + BeginRotateToken(vm, "custom-feed"); + ReplaceDraft(vm, "new-token"); + vm.ActivateSelected(); + + var rotated = SingleFeedSection()["ApiKey"]; + Assert.NotNull(rotated); + Assert.StartsWith("ENC:", rotated!, StringComparison.Ordinal); + Assert.NotEqual(oldEncrypted, rotated); + Assert.Equal("new-token", protector.Unprotect(rotated!)); + Assert.NotEqual("old-token", protector.Unprotect(rotated!)); + Assert.DoesNotContain("new-token", File.ReadAllText(_paths.NetclawConfigPath), StringComparison.Ordinal); + } + + [Fact] + public void Rotate_token_rejects_blank_and_leaves_existing_token_untouched() + { + var protector = SecretsProtection.CreateProtector(_paths); + var oldEncrypted = protector.Protect("old-token"); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"SkillFeeds\":{{\"Feeds\":[{{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"{oldEncrypted}\",\"Enabled\":true}}]}}}}"); + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + BeginRotateToken(vm, "custom-feed"); + ReplaceDraft(vm, " "); + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("New bearer token is required", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + Assert.Equal(oldEncrypted, SingleFeedSection()["ApiKey"]); + } + + [Fact] + public async Task Rotate_token_persists_immediately_then_warns_when_feed_unreachable() + { + // Persist-now, validate-async: rotating a token no longer blocks on a reachability gate + // (a blocking probe froze the loop). The new token is persisted on the first Enter and an + // off-loop warn-probe surfaces a non-blocking Warning when the feed is unreachable — the + // rotation is NOT reverted. + var protector = SecretsProtection.CreateProtector(_paths); + var oldEncrypted = protector.Protect("old-token"); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"SkillFeeds\":{{\"Feeds\":[{{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"{oldEncrypted}\",\"Enabled\":true}}]}}}}"); + var probe = new CountingSkillFeedProbe(success: false); + using var vm = new SkillSourcesConfigViewModel(_paths, probe); + + BeginRotateToken(vm, "custom-feed"); + ReplaceDraft(vm, "new-token"); + vm.ActivateSelected(); // persists now, then kicks off the off-loop warn-probe + await vm.PendingProbe!; + + // The rotation is persisted (not reverted), and the warn-probe flagged the unreachable feed. + var rotated = SingleFeedSection()["ApiKey"]; + Assert.NotNull(rotated); + Assert.Equal("new-token", protector.Unprotect(rotated!)); + Assert.NotEqual(oldEncrypted, rotated); + Assert.Equal(1, probe.ProbeCount); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("unreachable", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AddLocalPath_is_a_directory_picker_so_typing_does_not_change_the_draft() + { + // The add-local-folder step is an interactive directory picker now, not a text field: + // keystrokes route to the picker, so AppendText must be inert and IsTextEntryActive false. + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + BeginAddLocalFolder(vm); + + vm.AppendText("/tmp/should-be-ignored"); + + Assert.False(vm.IsTextEntryActive); + Assert.Equal(string.Empty, vm.Draft.Value); + Assert.Equal(SkillSourcesScreen.AddLocalPath, vm.Screen.Value); + } + + [Fact] + public void CommitAddLocalPath_from_picker_advances_to_symlinks() + { + // CommitAddLocalPath is the picker's SelectionConfirmed target: a chosen (existing) + // directory validates and advances to the symlink-security step. + var folder = Path.Combine(_dir.Path, "picked-skill-folder"); + Directory.CreateDirectory(folder); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + BeginAddLocalFolder(vm); + + vm.CommitAddLocalPath(folder); + + Assert.Equal(SkillSourcesScreen.AddLocalSymlinks, vm.Screen.Value); + } + + [Fact] + public void CreateAndSelectFolder_creates_a_new_folder_and_advances() + { + // The inline "new folder" affordance: create a subdir under the picker's location, then + // commit it (it now exists, so it advances to the symlink-security step). + var parent = Path.Combine(_dir.Path, "parent"); + Directory.CreateDirectory(parent); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + BeginAddLocalFolder(vm); + + vm.CreateAndSelectFolder(parent, "fresh-skills"); + + Assert.True(Directory.Exists(Path.Combine(parent, "fresh-skills"))); + Assert.Equal(SkillSourcesScreen.AddLocalSymlinks, vm.Screen.Value); + } + + [Fact] + public void CreateAndSelectFolder_rejects_an_invalid_name_and_stays_on_the_picker() + { + var parent = Path.Combine(_dir.Path, "parent"); + Directory.CreateDirectory(parent); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + BeginAddLocalFolder(vm); + + vm.CreateAndSelectFolder(parent, "bad/name"); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(SkillSourcesScreen.AddLocalPath, vm.Screen.Value); + } + + [Fact] + public async Task Probe_runs_off_the_loop_and_does_not_block_input() + { + // Regression for the deep-review finding: the reachability probe used to run synchronously + // on the single-threaded TUI loop (HttpClient.Send), freezing input for up to 10s. It now + // runs off-loop. Gate the fake so the probe is still in flight when the triggering call + // returns: PendingProbe must be non-null and NOT complete (the call did NOT block), and the + // status shows the in-progress "Testing…" message. + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://feed.example.test\",\"Enabled\":true}]}}"); + var gate = new TaskCompletionSource(); + var probe = new FakeSkillFeedProbe(success: true) { Gate = gate }; + using var vm = new SkillSourcesConfigViewModel(_paths, probe); + + OpenRemoteDetail(vm, "custom-feed"); + MoveToDetailAction(vm, SkillSourceDetailAction.TestConnection); + vm.ActivateSelected(); // kicks off the probe and returns WITHOUT blocking + + Assert.NotNull(vm.PendingProbe); + Assert.False(vm.PendingProbe!.IsCompleted); // proof the call did not block the loop + Assert.Equal(ConfigStatusTone.Neutral, vm.Status.Value.Tone); + Assert.Contains("Testing skill server", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + + gate.SetResult(); + await vm.PendingProbe!; + + Assert.Equal(ConfigStatusTone.Success, vm.Status.Value.Tone); + Assert.Contains("reachable", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Probe_is_cancelled_on_dispose() + { + // Disposing the VM while a probe is in flight cancels it: the gated continuation must NOT + // apply its result (status stays on "Testing…") and awaiting the task must not throw. + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://feed.example.test\",\"Enabled\":true}]}}"); + var gate = new TaskCompletionSource(); + var probe = new FakeSkillFeedProbe(success: true) { Gate = gate }; + var vm = new SkillSourcesConfigViewModel(_paths, probe); + + OpenRemoteDetail(vm, "custom-feed"); + MoveToDetailAction(vm, SkillSourceDetailAction.TestConnection); + vm.ActivateSelected(); // probe in flight (gated) + var pending = vm.PendingProbe; + Assert.NotNull(pending); + // Snapshot the status before Dispose (Dispose disposes the R3 ReactiveProperty). + var statusBeforeDispose = vm.Status.Value; + + vm.Dispose(); // cancels the in-flight probe + gate.SetResult(); // release the gate; cancellation should win + await pending!; // completes without throwing (cancellation swallowed) + + // The continuation dropped the result quietly — the status was never advanced past "Testing…". + Assert.Equal(ConfigStatusTone.Neutral, statusBeforeDispose.Tone); + Assert.Contains("Testing skill server", statusBeforeDispose.Text, StringComparison.OrdinalIgnoreCase); + } + + // A migrated/hand-edited config may store a bearer token unencrypted. The editor must NOT silently + // accept and use it: a plaintext token is flagged (so the operator can rotate/re-encrypt it), + // while an ENC:-protected token is not flagged. + [Theory] + [InlineData("raw-plaintext-token", true)] + [InlineData("ENC:protected-blob", false)] + public void Feed_token_stored_as_plaintext_is_flagged_not_silently_accepted(string storedApiKey, bool expectedPlaintext) + { + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"SkillFeeds\":{{\"Feeds\":[{{\"Name\":\"feed-x\",\"Url\":\"https://feed.example.test\",\"ApiKey\":\"{storedApiKey}\",\"Enabled\":true}}]}}}}"); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + var feed = vm.Sources.Single(s => s.Kind == SkillSourceKind.RemoteSkillServer); + Assert.Equal(expectedPlaintext, feed.ApiKeyIsPlaintext); + + OpenRemoteDetail(vm, "feed-x"); + var authRow = vm.DetailRows.Single(r => r.Action == SkillSourceDetailAction.Authentication); + Assert.Equal(expectedPlaintext ? ConfigStatusTone.Warning : ConfigStatusTone.Neutral, authRow.Tone); + if (expectedPlaintext) + Assert.Contains("PLAINTEXT", authRow.Label, StringComparison.Ordinal); + } + + [Fact] + public void Config_write_io_failure_surfaces_an_error_and_persists_nothing() + { + // Inject a real disk-write failure (config dir made read-only) and confirm the save surfaces + // an error status, does not advance to the detail screen, and persists nothing — instead of + // throwing IOException into the Termina event loop. chmod-based injection is Unix-only. + if (OperatingSystem.IsWindows()) + return; + + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + using var vm = new SkillSourcesConfigViewModel(_paths, new FakeSkillFeedProbe(true)); + + BeginAddLocalFolder(vm); + vm.CommitAddLocalPath(externalDir); + vm.ActivateSelected(); // symlinks → name screen (no write yet) + ReplaceDraft(vm, "team-skills"); + + var configDir = Path.GetDirectoryName(_paths.NetclawConfigPath)!; + var originalMode = File.GetUnixFileMode(configDir); + var before = File.ReadAllText(_paths.NetclawConfigPath); + File.SetUnixFileMode(configDir, UnixFileMode.UserRead | UnixFileMode.UserExecute); // no write + try + { + vm.ActivateSelected(); // CommitAddLocalName → SaveNewLocalSource → write fails + } + finally + { + File.SetUnixFileMode(configDir, originalMode); + } + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("Could not save", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(SkillSourcesScreen.SourceDetail, vm.Screen.Value); // did not falsely advance + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); // nothing persisted + } + + private static void BeginRotateToken(SkillSourcesConfigViewModel vm, string name) + { + OpenRemoteDetail(vm, name); + MoveToDetailAction(vm, SkillSourceDetailAction.RotateToken); + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.AddRemoteToken, vm.Screen.Value); + } + + private static void BeginAddLocalFolder(SkillSourcesConfigViewModel vm) + { + EnsureInventory(vm); + MoveToInventoryAction(vm, SkillSourcesInventoryAction.AddLocalFolder); + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.AddLocalPath, vm.Screen.Value); + } + + private static void AddLocalFolder(SkillSourcesConfigViewModel vm, string path, string name) + { + BeginAddLocalFolder(vm); + // AddLocalPath is a directory picker; CommitAddLocalPath is what its SelectionConfirmed + // calls with the chosen path (replaces the former type-the-path-then-Enter flow). + vm.CommitAddLocalPath(path); + Assert.Equal(SkillSourcesScreen.AddLocalSymlinks, vm.Screen.Value); + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.AddLocalName, vm.Screen.Value); + ReplaceDraft(vm, name); + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + } + + private static void BeginAddRemoteServer(SkillSourcesConfigViewModel vm) + { + EnsureInventory(vm); + MoveToInventoryAction(vm, SkillSourcesInventoryAction.AddSkillServer); + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.AddRemoteUrl, vm.Screen.Value); + } + + // Drives the probe-driven add flow for an auth-gated server. The reachability probe now runs + // OFF the loop, so each probe is two-phase: the first ActivateSelected kicks it off (await + // PendingProbe), the second ActivateSelected acts on the completed result (reveal token / advance + // to name). The vm must be constructed with a requiresAuth FakeSkillFeedProbe. + private static async Task AddRemoteServer(SkillSourcesConfigViewModel vm, string url, string token, string name) + { + BeginAddRemoteServer(vm); + vm.AppendText(url); + vm.ActivateSelected(); // phase 1: no-auth probe -> 401 + await vm.PendingProbe!; + vm.ActivateSelected(); // phase 2: RequiresAuth -> reveal token field + Assert.Equal(SkillSourcesScreen.AddRemoteToken, vm.Screen.Value); + vm.AppendText(token); + vm.ActivateSelected(); // phase 1: re-probe with token -> success + await vm.PendingProbe!; + vm.ActivateSelected(); // phase 2: success -> advance to name review + Assert.Equal(SkillSourcesScreen.AddRemoteName, vm.Screen.Value); + ReplaceDraft(vm, name); + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + } + + private static void OpenRemoteDetail(SkillSourcesConfigViewModel vm, string name) + { + var index = vm.InventoryRows + .Select((row, idx) => (row, idx)) + .Single(entry => entry.row.SourceKind == SkillSourceKind.RemoteSkillServer && entry.row.SourceName == name) + .idx; + vm.SelectedRow.Value = index; + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + } + + private static void OpenLocalDetail(SkillSourcesConfigViewModel vm, string name) + { + var index = vm.InventoryRows + .Select((row, idx) => (row, idx)) + .Single(entry => entry.row.SourceKind == SkillSourceKind.LocalFolder && entry.row.SourceName == name) + .idx; + vm.SelectedRow.Value = index; + vm.ActivateSelected(); + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + } + + private static void MoveToInventoryAction(SkillSourcesConfigViewModel vm, SkillSourcesInventoryAction action) + { + vm.SelectedRow.Value = vm.InventoryRows + .Select((row, idx) => (row, idx)) + .Single(entry => entry.row.Action == action) + .idx; + } + + private static void EnsureInventory(SkillSourcesConfigViewModel vm) + { + while (vm.Screen.Value != SkillSourcesScreen.Inventory) + vm.GoBack(); + } + + private static void MoveToDetailAction(SkillSourcesConfigViewModel vm, SkillSourceDetailAction action) + { + vm.SelectedRow.Value = vm.DetailRows + .Select((row, idx) => (row, idx)) + .Single(entry => entry.row.Action == action) + .idx; + } + + private static void ReplaceDraft(SkillSourcesConfigViewModel vm, string value) + { + while (vm.Draft.Value.Length > 0) + vm.Backspace(); + vm.AppendText(value); + } + + private T Bind(string sectionName) where T : new() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(_paths.NetclawConfigPath) + .Build(); + return configuration.GetSection(sectionName).Get() ?? new T(); + } + + private IConfigurationSection SingleFeedSection() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(_paths.NetclawConfigPath) + .Build(); + return Assert.Single(configuration.GetSection("SkillFeeds:Feeds").GetChildren()); + } + + private string Decrypt(string encrypted) + => SecretsProtection.CreateProtector(_paths).Unprotect(encrypted); + + private sealed class FakeSkillFeedProbe(bool success, bool requiresAuth = false, bool failWithToken = false) + : ISkillFeedReachabilityProbe + { + // When set, ProbeAsync blocks on this gate before returning so tests can stage an in-flight + // probe (proving the call returned without blocking the loop, and that it is cancellable). + public TaskCompletionSource? Gate { get; set; } + + public async Task ProbeAsync(string baseUrl, string? apiKey, int timeoutSeconds, CancellationToken ct = default) + { + if (Gate is not null) + await Gate.Task.WaitAsync(ct); + + // Simulate an auth-gated server: 401 (RequiresAuth) until a bearer token is + // supplied. Drives the probe-driven token disclosure path. With a token the + // re-probe either succeeds (default) or, when failWithToken is set, fails with a + // non-auth error so the token-step override dialog appears. + if (requiresAuth) + { + if (string.IsNullOrEmpty(apiKey)) + return new SkillFeedReachabilityResult(false, "auth required", RequiresAuth: true); + + return failWithToken + ? new SkillFeedReachabilityResult(false, "unreachable") + : new SkillFeedReachabilityResult(true, "reachable"); + } + + return success + ? new SkillFeedReachabilityResult(true, "reachable") + : new SkillFeedReachabilityResult(false, "unreachable"); + } + } + + private sealed class CountingSkillFeedProbe(bool success) : ISkillFeedReachabilityProbe + { + public int ProbeCount { get; private set; } + + public Task ProbeAsync(string baseUrl, string? apiKey, int timeoutSeconds, CancellationToken ct = default) + { + ProbeCount++; + return Task.FromResult(success + ? new SkillFeedReachabilityResult(true, "reachable") + : new SkillFeedReachabilityResult(false, "unreachable")); + } + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/Task1ConfigAreaPageTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/Task1ConfigAreaPageTests.cs new file mode 100644 index 000000000..d0993d9b7 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/Task1ConfigAreaPageTests.cs @@ -0,0 +1,843 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina; +using Termina.Hosting; +using Termina.Input; +using Termina.Layout; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class Task1ConfigAreaPageTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public Task1ConfigAreaPageTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public async Task Workspaces_page_choosing_a_directory_in_the_picker_saves_it() + { + // The page is the directory picker (no Tab, no typed form): Space chooses the highlighted + // directory, which saves it as the workspaces directory. + var target = Path.Combine(_dir.Path, "chosen-workspaces"); + Directory.CreateDirectory(target); + var start = _paths.WorkspacesDirectory; + var fileSystem = new StubFileSystemProvider( + existingDirectories: [start, target], + entries: new Dictionary> + { + [start] = [StubFileSystemProvider.Dir(target)], + }); + var app = CreateWorkspacesApp(out var input, out var vm, fileSystem); + + input.EnqueueKey(ConsoleKey.Spacebar); // choose the highlighted directory -> save. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.True(vm.IsSaved.Value); + Assert.Equal(target, vm.CurrentDirectory.Value); + } + + [Fact] + public async Task Inbound_webhooks_page_accepts_typed_timeout_input() + { + var app = CreateInboundWebhooksApp(out var input, out var vm); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueString("45"); + input.EnqueueKey(ConsoleKey.Backspace); + input.EnqueuePaste("0"); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal("40", vm.TimeoutDraft.Value); + } + + [Fact] + public async Task Skill_sources_local_path_screen_renders_directory_picker() + { + var app = CreateSkillSourcesApp(out var input, out _, out var terminal, + fileSystem: SkillFolderPickerFs(out _)); + + input.EnqueueKey(ConsoleKey.Enter); // Inventory -> Add local folder -> directory picker. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var screen = terminal.ToString(); + Assert.Contains("Add a local skill folder.", screen, StringComparison.Ordinal); + Assert.Contains("[Ctrl+N] new folder", screen, StringComparison.Ordinal); + } + + [Fact] + public async Task Skill_sources_choosing_existing_directory_advances_without_persisting_incomplete_flow() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, out _, + fileSystem: SkillFolderPickerFs(out _)); + + input.EnqueueKey(ConsoleKey.Enter); // -> directory picker (the folder is highlighted). + input.EnqueueKey(ConsoleKey.Spacebar); // choose the folder -> AddLocalSymlinks. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddLocalSymlinks, vm.Screen.Value); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_local_name_enter_persists_source_to_external_skills() + { + var app = CreateSkillSourcesApp(out var input, out var vm, out _, + fileSystem: SkillFolderPickerFs(out var externalDir)); + + input.EnqueueKey(ConsoleKey.Enter); // -> directory picker. + input.EnqueueKey(ConsoleKey.Spacebar); // choose the folder -> AddLocalSymlinks. + input.EnqueueKey(ConsoleKey.Enter); // symlinks default (No) -> AddLocalName. + input.EnqueueKey(ConsoleKey.Enter); // default name (folder basename) -> persist -> SourceDetail. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var root = doc.RootElement; + Assert.False(root.TryGetProperty("SkillFeeds", out _)); + var source = Assert.Single(root.GetProperty("ExternalSkills").GetProperty("Sources").EnumerateArray()); + Assert.Equal("team-skills", source.GetProperty("Name").GetString()); + Assert.Equal(externalDir, source.GetProperty("Path").GetString()); + Assert.True(source.GetProperty("Enabled").GetBoolean()); + Assert.False(source.GetProperty("AllowSymlinks").GetBoolean()); + } + + [Fact] + public async Task Skill_sources_remote_url_screen_explains_skill_server_project() + { + var app = CreateSkillSourcesApp(out var input, out _, out var terminal); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var screen = terminal.ToString(); + Assert.True(screen.Contains("Server URL", StringComparison.Ordinal), + $"Expected server URL input label in terminal output. Screen:\n{terminal}"); + Assert.DoesNotContain("Type here...|", screen, StringComparison.Ordinal); + Assert.True(screen.Contains("https://github.com/netclaw-dev/skill-server", StringComparison.Ordinal), + $"Expected skill-server project callout in terminal output. Screen:\n{terminal}"); + } + + [Fact] + public async Task Skill_sources_remote_inventory_row_uses_readable_metadata_lines() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"skillserver-testlab-petabridge-net\",\"Url\":\"https://skillserver.testlab.petabridge.net\",\"Enabled\":true,\"TimeoutSeconds\":30}]}} "); + var app = CreateSkillSourcesApp(out var input, out _, out var terminal); + + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + var screen = terminal.ToString(); + Assert.Contains("Skillserver Testlab Petabridge", screen, StringComparison.Ordinal); + Assert.Contains("skillserver.testlab.petabridge.net | No auth", screen, StringComparison.Ordinal); + Assert.DoesNotContain("server https://skillserver", screen, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Skill_sources_remote_url_enter_rejects_invalid_url_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("file:///tmp/skills"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteUrl, vm.Screen.Value); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("HTTP or HTTPS", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_url_enter_accepts_valid_url_without_persisting_incomplete_flow() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + // Default probe reports success. The reachability probe now runs off-loop in two phases: + // the first Enter on AddRemoteUrl kicks it off (completes inline for the synchronous fake); + // the second Enter acts on the success result and advances to the name/review step (open + // servers never see the bearer-token field). + var app = CreateSkillSourcesApp(out var input, out var vm); + + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString("https://"); + input.EnqueuePaste("skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // phase 1: kick off probe + input.EnqueueKey(ConsoleKey.Enter); // phase 2: success -> AddRemoteName + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteName, vm.Screen.Value); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_url_unreachable_probe_fingerprints_without_dialog_before_persistence() + { + // Repurposed from a URL-step override-dialog test: the URL step no longer raises the + // override dialog. An unreachable (non-auth) probe now fingerprints the URL and surfaces + // a "save anyway" warning status while staying on AddRemoteUrl — no dialog, no persistence. + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, out var terminal, new FakeSkillFeedProbe(false, "probe failed")); + + // BeginRemoteUrlEntry runs the first URL commit, which fires the no-auth probe once. + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteUrl, vm.Screen.Value); + Assert.Null(vm.ActiveValidationDialog.Value); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("save anyway", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + var screen = terminal.ToString(); + Assert.DoesNotContain("Skill Server Validation Warning", screen, StringComparison.Ordinal); + Assert.True(screen.Contains("probe failed", StringComparison.OrdinalIgnoreCase), + $"Expected probe failure in warning status. Screen:\n{terminal}"); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_url_unreachable_open_server_second_enter_saves_anyway_reviews_name() + { + // For an OPEN (non-auth) server that probes unreachable, the first Enter on AddRemoteUrl + // fingerprints the URL and warns "save anyway" (no dialog). A second Enter on the same URL + // matches the fingerprint, skips the probe, and advances to AddRemoteName — nothing + // persisted until the name commits. + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, new FakeSkillFeedProbe(false, "probe failed")); + + // First URL commit (inside BeginRemoteUrlEntry) warns; a second Enter saves anyway. + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteName, vm.Screen.Value); + Assert.Null(vm.ActiveValidationDialog.Value); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_url_unreachable_probe_preserves_url_draft_for_editing() + { + // Repurposed from a URL-step "back to edit" dialog test: the URL step no longer raises a + // dialog, so there is no "back to edit" action. The equivalent guarantee under the + // fingerprint model is that the typed URL is preserved on AddRemoteUrl so the user can + // edit it after an unreachable probe instead of retyping. + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, new FakeSkillFeedProbe(false, "probe failed")); + + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteUrl, vm.Screen.Value); + Assert.Null(vm.ActiveValidationDialog.Value); + Assert.Equal("https://skills.example.test", vm.Draft.Value); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_token_commit_advances_to_name_without_blocking_on_reachability() + { + // Persist-now, validate-async: committing a bearer token no longer runs a blocking probe + // (which froze the loop) and no longer raises an override dialog. The token screen is reached + // via a 401 on the off-loop no-token URL probe (two-phase); a structurally-valid token then + // advances straight to the name review. Reachability is validated later (Test action / review), + // so an unreachable token does NOT block here. Nothing is persisted until the name commits. + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, + new FakeSkillFeedProbe(message: "probe failed", requiresAuth: true, failWithToken: true)); + + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // URL phase 2: RequiresAuth -> reveal token field + input.EnqueueString("secret-token"); + input.EnqueueKey(ConsoleKey.Enter); // token commit -> advance to name (no block, no dialog) + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteName, vm.Screen.Value); + Assert.Null(vm.ActiveValidationDialog.Value); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_url_success_probe_reviews_name_without_persisting_incomplete_flow() + { + // Repurposed from a URL-step "save anyway" dialog test: there is no URL-step override. + // A reachable open server advances straight to the name/review screen with the suggested + // name prefilled, still without persisting until the name commits. This preserves the + // AddRemoteName render and suggested-name coverage the old dialog test asserted. + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, out var terminal, new FakeSkillFeedProbe(true, "reachable")); + + // Two-phase off-loop probe: BeginRemoteUrlEntry's Enter kicks it off (phase 1, completes + // inline for the synchronous fake); a second Enter advances to the name review (phase 2). + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // phase 2: success -> AddRemoteName + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteName, vm.Screen.Value); + Assert.Null(vm.ActiveValidationDialog.Value); + var screen = terminal.ToString(); + Assert.True(screen.Contains("Review remote skill server source", StringComparison.Ordinal), + $"Expected remote source name confirmation screen. Screen:\n{terminal}"); + Assert.True(screen.Contains("skills-example-test", StringComparison.Ordinal), + $"Expected suggested source name in terminal output. Screen:\n{terminal}"); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_url_requiring_auth_reveals_token_entry() + { + // Repurposed from the auth-choice "pick Bearer" test: there is no auth-choice screen. + // The bearer-token field is revealed only when the no-token probe reports RequiresAuth + // (HTTP 401/403), with a warning prompting the user to enter a token. + var before = File.ReadAllText(_paths.NetclawConfigPath); + var app = CreateSkillSourcesApp(out var input, out var vm, + new FakeSkillFeedProbe(message: "auth required", requiresAuth: true)); + + // Two-phase off-loop probe: the first Enter kicks off the no-auth probe (phase 1, 401); + // the second Enter acts on the RequiresAuth result and reveals the bearer-token field. + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // phase 2: RequiresAuth -> reveal token field + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.AddRemoteToken, vm.Screen.Value); + Assert.Equal(ConfigStatusTone.Warning, vm.Status.Value.Tone); + Assert.Contains("bearer token to continue", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public async Task Skill_sources_remote_unreachable_token_feed_can_still_be_added_and_persists() + { + // Preserves the original intent (an unreachable auth feed can still be added) under the new + // persist-now/validate-async model: the token re-probe no longer blocks with an override + // dialog. The token screen is reached via the off-loop 401 URL probe (two-phase); a valid + // token advances to the name review even though the feed is unreachable (failWithToken), and + // committing the name persists the encrypted token. + var app = CreateSkillSourcesApp(out var input, out var vm, + new FakeSkillFeedProbe(message: "probe failed", requiresAuth: true, failWithToken: true)); + + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // URL phase 2: RequiresAuth -> reveal token field + input.EnqueueString("secret-token"); + input.EnqueueKey(ConsoleKey.Enter); // token commit -> advance to name (no block, no dialog) + input.EnqueueKey(ConsoleKey.Enter); // name -> persist + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + Assert.Null(vm.ActiveValidationDialog.Value); + var contents = File.ReadAllText(_paths.NetclawConfigPath); + Assert.DoesNotContain("secret-token", contents, StringComparison.Ordinal); + using var doc = JsonDocument.Parse(contents); + var feed = Assert.Single(doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds").EnumerateArray()); + Assert.Equal("https://skills.example.test", feed.GetProperty("Url").GetString()); + Assert.StartsWith("ENC:", feed.GetProperty("ApiKey").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Skill_sources_remote_bearer_name_enter_persists_encrypted_token_to_skill_feeds() + { + // requiresAuth probe: URL probe 401s and reveals the token field; the token re-probe + // succeeds, advances to name, and the entered token is persisted encrypted. Each off-loop + // probe is two-phase (kick off, then act on the inline-completed result on the next Enter). + var app = CreateSkillSourcesApp(out var input, out var vm, + new FakeSkillFeedProbe(message: "auth required", requiresAuth: true)); + + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // URL phase 2: RequiresAuth -> reveal token field + input.EnqueueString("secret-token"); + input.EnqueueKey(ConsoleKey.Enter); // token phase 1: re-probe with token + input.EnqueueKey(ConsoleKey.Enter); // token phase 2: success -> AddRemoteName + input.EnqueueKey(ConsoleKey.Enter); // name -> persist + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + var contents = File.ReadAllText(_paths.NetclawConfigPath); + Assert.DoesNotContain("secret-token", contents, StringComparison.Ordinal); + using var doc = JsonDocument.Parse(contents); + var feed = Assert.Single(doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds").EnumerateArray()); + Assert.Equal("https://skills.example.test", feed.GetProperty("Url").GetString()); + Assert.StartsWith("ENC:", feed.GetProperty("ApiKey").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Skill_sources_remote_name_enter_persists_no_auth_source_to_skill_feeds() + { + // Default probe reports success. The off-loop probe is two-phase: the URL Enter kicks it + // off, a second Enter advances to the name screen, and a third Enter on the name persists + // an open feed with no ApiKey. + var app = CreateSkillSourcesApp(out var input, out var vm); + + BeginRemoteUrlEntry(input, "https://skills.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // URL phase 2: success -> AddRemoteName + input.EnqueueKey(ConsoleKey.Enter); // name -> persist + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var root = doc.RootElement; + Assert.False(root.TryGetProperty("ExternalSkills", out _)); + var feeds = root.GetProperty("SkillFeeds").GetProperty("Feeds"); + var feed = Assert.Single(feeds.EnumerateArray()); + Assert.Equal("https://skills.example.test", feed.GetProperty("Url").GetString()); + Assert.True(feed.GetProperty("Enabled").GetBoolean()); + Assert.Equal(30, feed.GetProperty("TimeoutSeconds").GetInt32()); + Assert.False(feed.TryGetProperty("ApiKey", out _)); + } + + [Fact] + public async Task Skill_sources_remote_name_enter_after_save_anyway_persists_source_to_skill_feeds() + { + // OPEN-URL save-anyway path: the no-auth probe reports unreachable, so the first Enter on + // AddRemoteUrl fingerprints the URL and warns "save anyway". A second Enter on the same URL + // skips the probe and advances to AddRemoteName, and Enter on the name persists the feed + // with no token (open server, null ApiKey). + var app = CreateSkillSourcesApp(out var input, out var vm, new FakeSkillFeedProbe(false, "probe failed")); + + // URL + Enter (inside BeginRemoteUrlEntry) warns; a second Enter saves anyway -> AddRemoteName. + BeginRemoteUrlEntry(input, "https://example.invalid"); + input.EnqueueKey(ConsoleKey.Enter); + // Now on AddRemoteName -> Enter persists. + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + Assert.Equal(ConfigStatusTone.Success, vm.Status.Value.Tone); + Assert.Contains("Added skill server", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var feeds = doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds"); + var feed = Assert.Single(feeds.EnumerateArray()); + Assert.Equal("https://example.invalid", feed.GetProperty("Url").GetString()); + Assert.False(feed.TryGetProperty("ApiKey", out _)); + } + + [Fact] + public async Task Skill_sources_remote_change_url_persists_immediately_even_when_unreachable() + { + // Persist-now, validate-async: changing a remote feed URL no longer blocks on a "save anyway" + // override (the probe ran synchronously and froze the loop). The new URL is persisted on the + // first Enter and an off-loop warn-probe surfaces a non-blocking warning when unreachable. + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"Enabled\":true,\"TimeoutSeconds\":30}]}}"); + var app = CreateSkillSourcesApp(out var input, out var vm, new FakeSkillFeedProbe(false, "probe failed")); + + input.EnqueueKey(ConsoleKey.Enter); // open the feed's detail + input.EnqueueKey(ConsoleKey.DownArrow); // move to the Change Location action + input.EnqueueKey(ConsoleKey.Enter); // open the URL editor + EnqueueBackspaces(input, "https://old.example.test".Length); + input.EnqueueString("https://new.example.test"); + input.EnqueueKey(ConsoleKey.Enter); // persists now (unreachable warn-probe is off-loop) + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.SourceDetail, vm.Screen.Value); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var feed = Assert.Single(doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds").EnumerateArray()); + Assert.Equal("https://new.example.test", feed.GetProperty("Url").GetString()); + } + + [Fact] + public async Task Skill_sources_inventory_space_toggles_source_enabled() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"Enabled\":true,\"TimeoutSeconds\":30}]}}"); + var app = CreateSkillSourcesApp(out var input, out _); + + input.EnqueueKey(ConsoleKey.Spacebar); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var feed = Assert.Single(doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds").EnumerateArray()); + Assert.False(feed.GetProperty("Enabled").GetBoolean()); + } + + [Fact] + public async Task Skill_sources_local_detail_space_toggles_symlink_policy() + { + var externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + File.WriteAllText(_paths.NetclawConfigPath, + $"{{\"configVersion\":1,\"ExternalSkills\":{{\"Sources\":[{{\"Name\":\"team-skills\",\"Path\":\"{externalDir.Replace("\\", "\\\\", StringComparison.Ordinal)}\",\"Enabled\":true,\"AllowSymlinks\":false}}]}}}}"); + var app = CreateSkillSourcesApp(out var input, out _); + + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Spacebar); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var source = Assert.Single(doc.RootElement.GetProperty("ExternalSkills").GetProperty("Sources").EnumerateArray()); + Assert.True(source.GetProperty("AllowSymlinks").GetBoolean()); + } + + [Fact] + public async Task Skill_sources_remote_detail_enter_cycles_timeout() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"Enabled\":true,\"TimeoutSeconds\":30}]}}"); + var app = CreateSkillSourcesApp(out var input, out _); + + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var feed = Assert.Single(doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds").EnumerateArray()); + Assert.Equal(60, feed.GetProperty("TimeoutSeconds").GetInt32()); + } + + [Fact] + public async Task Skill_sources_remote_detail_enter_removes_token() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"ApiKey\":\"plain-token\",\"Enabled\":true,\"TimeoutSeconds\":30}]}}"); + var app = CreateSkillSourcesApp(out var input, out _); + + input.EnqueueKey(ConsoleKey.Enter); + for (var i = 0; i < 8; i++) + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + var feed = Assert.Single(doc.RootElement.GetProperty("SkillFeeds").GetProperty("Feeds").EnumerateArray()); + Assert.False(feed.TryGetProperty("ApiKey", out _)); + } + + [Fact] + public async Task Skill_sources_remove_confirm_enter_removes_source() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"SkillFeeds\":{\"Feeds\":[{\"Name\":\"custom-feed\",\"Url\":\"https://old.example.test\",\"Enabled\":true,\"TimeoutSeconds\":30}]}}"); + var app = CreateSkillSourcesApp(out var input, out var vm); + + input.EnqueueKey(ConsoleKey.Delete); + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal(SkillSourcesScreen.Inventory, vm.Screen.Value); + using var doc = JsonDocument.Parse(File.ReadAllText(_paths.NetclawConfigPath)); + Assert.False(doc.RootElement.TryGetProperty("SkillFeeds", out _)); + } + + [Fact] + public async Task Telemetry_alerting_page_accepts_typed_and_pasted_values() + { + var app = CreateTelemetryAlertingApp(out var input, out var vm); + + // Edit and save the OTLP endpoint on row 1, then open the "+ Add webhook" + // row, type a URL into the form, and save it. + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueString("http://"); + input.EnqueuePaste("127.0.0.1:4318"); + input.EnqueueKey(ConsoleKey.Enter); // save OTLP endpoint. + input.EnqueueKey(ConsoleKey.DownArrow); // -> + Add webhook row (no webhooks yet). + input.EnqueueKey(ConsoleKey.Enter); // open the add form. + input.EnqueueKey(ConsoleKey.DownArrow); // Name -> URL field. + input.EnqueueString("https://"); + input.EnqueuePaste("alerts.example.test/hook"); + input.EnqueueKey(ConsoleKey.Enter); // save webhook. + input.EnqueueKey(ConsoleKey.Q, false, false, true); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await app.RunAsync(cts.Token); + + Assert.Equal("http://127.0.0.1:4318", vm.OtlpEndpointDraft.Value); + Assert.Equal(TelemetryConfigScreen.List, vm.Screen.Value); + var webhook = Assert.Single(vm.Webhooks.Value); + Assert.Equal("https://alerts.example.test/hook", webhook.Url); + } + + private TerminaApplication CreateWorkspacesApp( + out VirtualInputSource input, + out WorkspacesConfigViewModel vm, + IFileSystemProvider? fileSystem = null) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + var capturedVm = new WorkspacesConfigViewModel(_paths, fileSystem); + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/workspaces", builder => + { + builder.RegisterRoute( + "/workspaces", + _ => new WorkspacesConfigPage(), + _ => capturedVm); + }); + + var sp = services.BuildServiceProvider(); + vm = capturedVm!; + return sp.GetRequiredService(); + } + + private TerminaApplication CreateInboundWebhooksApp(out VirtualInputSource input, out InboundWebhooksConfigViewModel vm) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + var capturedVm = new InboundWebhooksConfigViewModel(_paths); + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/inbound-webhooks", builder => + { + builder.RegisterRoute( + "/inbound-webhooks", + _ => new InboundWebhooksConfigPage(), + _ => capturedVm); + }); + + var sp = services.BuildServiceProvider(); + vm = capturedVm!; + return sp.GetRequiredService(); + } + + private TerminaApplication CreateSkillSourcesApp(out VirtualInputSource input, out SkillSourcesConfigViewModel vm) + => CreateSkillSourcesApp(out input, out vm, out _); + + private TerminaApplication CreateSkillSourcesApp( + out VirtualInputSource input, + out SkillSourcesConfigViewModel vm, + ISkillFeedReachabilityProbe probe) + => CreateSkillSourcesApp(out input, out vm, out _, probe); + + private TerminaApplication CreateSkillSourcesApp( + out VirtualInputSource input, + out SkillSourcesConfigViewModel vm, + out VirtualTerminal terminal, + ISkillFeedReachabilityProbe? probe = null, + IFileSystemProvider? fileSystem = null) + { + terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + var capturedVm = new SkillSourcesConfigViewModel(_paths, probe ?? new FakeSkillFeedProbe(), fileSystem); + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/skill-sources", builder => + { + builder.RegisterRoute( + "/skill-sources", + _ => new SkillSourcesConfigPage(), + _ => capturedVm); + }); + + var sp = services.BuildServiceProvider(); + vm = capturedVm!; + return sp.GetRequiredService(); + } + + // A fake filesystem whose home directory contains exactly one real temp folder, so the + // "add local folder" directory picker highlights it and Space chooses it deterministically. + private StubFileSystemProvider SkillFolderPickerFs(out string externalDir) + { + externalDir = Path.Combine(_dir.Path, "team-skills"); + Directory.CreateDirectory(externalDir); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return new StubFileSystemProvider( + existingDirectories: [home, externalDir], + entries: new Dictionary> + { + [home] = [StubFileSystemProvider.Dir(externalDir)], + }); + } + + private TerminaApplication CreateTelemetryAlertingApp(out VirtualInputSource input, out TelemetryAlertingConfigViewModel vm) + { + var terminal = new VirtualTerminal(120, 40); + var virtualInput = new VirtualInputSource(); + input = virtualInput; + var capturedVm = new TelemetryAlertingConfigViewModel(_paths); + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddTerminaVirtualInput(virtualInput); + services.AddTermina("/telemetry-alerting", builder => + { + builder.RegisterRoute( + "/telemetry-alerting", + _ => new TelemetryAlertingConfigPage(), + _ => capturedVm); + }); + + var sp = services.BuildServiceProvider(); + vm = capturedVm!; + return sp.GetRequiredService(); + } + + private sealed class FakeSkillFeedProbe : ISkillFeedReachabilityProbe + { + private readonly bool _success; + private readonly string _message; + private readonly bool _requiresAuth; + private readonly bool _failWithToken; + + public FakeSkillFeedProbe( + bool success = true, + string message = "reachable", + bool requiresAuth = false, + bool failWithToken = false) + { + _success = success; + _message = message; + _requiresAuth = requiresAuth; + _failWithToken = failWithToken; + } + + // Returns a synchronously-completed Task so RunProbeAsync runs inline on the loop thread + // (a completed-task await never suspends): the probe result is applied before the event + // loop pulls the next scripted key, keeping these full-loop tests deterministic. + public Task ProbeAsync(string baseUrl, string? apiKey, int timeoutSeconds, CancellationToken ct = default) + { + // Simulate an auth-gated server: the no-token probe returns 401 (RequiresAuth), + // which reveals the bearer-token field. This is the only way to reach the + // AddRemoteToken screen now. With a token supplied the re-probe either succeeds + // (default) or, when failWithToken is set, fails with a non-auth error so the + // token-step override dialog appears. + if (_requiresAuth) + { + if (string.IsNullOrEmpty(apiKey)) + return Task.FromResult(new SkillFeedReachabilityResult(false, _message, RequiresAuth: true)); + + return Task.FromResult(_failWithToken + ? new SkillFeedReachabilityResult(false, _message) + : new SkillFeedReachabilityResult(true, _message)); + } + + return Task.FromResult(new SkillFeedReachabilityResult(_success, _message)); + } + } + + private static void BeginRemoteUrlEntry(VirtualInputSource input, string url) + { + input.EnqueueKey(ConsoleKey.DownArrow); + input.EnqueueKey(ConsoleKey.Enter); + input.EnqueueString(url); + input.EnqueueKey(ConsoleKey.Enter); + } + + private static void EnqueueBackspaces(VirtualInputSource input, int count) + { + for (var i = 0; i < count; i++) + input.EnqueueKey(ConsoleKey.Backspace); + } + + private static int CountOccurrences(string value, string pattern, StringComparison comparison) + { + var count = 0; + var index = 0; + while ((index = value.IndexOf(pattern, index, comparison)) >= 0) + { + count++; + index += pattern.Length; + } + + return count; + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/TelemetryAlertingConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/TelemetryAlertingConfigViewModelTests.cs new file mode 100644 index 000000000..cf362473c --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/TelemetryAlertingConfigViewModelTests.cs @@ -0,0 +1,316 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Daemon.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class TelemetryAlertingConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public TelemetryAlertingConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Telemetry_alerting_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Telemetry & Alerting")); + + Assert.Equal("/telemetry-alerting", route); + } + + [Fact] + public void Constructor_with_malformed_config_does_not_throw_and_surfaces_error() + { + File.WriteAllText(_paths.NetclawConfigPath, "{ not valid json "); + + // Must not throw from the constructor (which would make the page permanently inaccessible). + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Save_persists_telemetry_otlp_endpoint_for_runtime_binding() + { + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.ToggleTelemetry(); + vm.SelectedRow.Value = 1; + vm.AppendText("http://127.0.0.1:4318"); + + Assert.True(vm.Save()); + + var telemetry = Bind("Telemetry"); + Assert.True(telemetry.Enabled); + Assert.Equal("http://127.0.0.1:4318", telemetry.Otlp.Endpoint); + } + + [Fact] + public void Save_rejects_invalid_telemetry_endpoint_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + vm.SelectedRow.Value = 1; + vm.OtlpEndpointDraft.Value = "not-a-url"; + + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("absolute HTTP or HTTPS URI", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Save_surfaces_write_failure_without_crashing_the_loop() + { + using var vm = new TelemetryAlertingConfigViewModel(_paths); + vm.ToggleTelemetry(); + vm.SelectedRow.Value = 1; + vm.AppendText("http://127.0.0.1:4318"); + + // Force the config write to fail like a disk-full / permission-denied failure would: AtomicFile + // cannot replace a path that is a directory. LoadJsonDict treats it as missing, so only the + // WriteConfigFile throws — which was previously unguarded on this direct Save() path. + File.Delete(_paths.NetclawConfigPath); + Directory.CreateDirectory(_paths.NetclawConfigPath); + + // Must not throw into the Termina event loop: the write is now wrapped in ConfigAutosave.Run. + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Saving_a_webhook_preserves_an_in_progress_otlp_endpoint_draft() + { + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + // Type an OTLP endpoint but never save it — it stays an unsaved draft. + vm.SelectedRow.Value = 1; + vm.AppendText("http://unsaved.example.test:4318"); + + // Save a webhook (a different section). This used to ReloadState unconditionally, discarding + // the dirty OTLP draft and force-flipping IsSaved=true. + vm.BeginAddWebhook(); + vm.WebhookNameDraft.Value = "ops"; + vm.WebhookUrlDraft.Value = "https://alerts.example.test/hook"; + vm.ActivateSelected(); + + Assert.Single(Bind("Notifications").Webhooks); + // The in-progress OTLP draft survives, and IsSaved reflects that it is still unsaved. + Assert.Equal("http://unsaved.example.test:4318", vm.OtlpEndpointDraft.Value); + Assert.False(vm.IsSaved.Value); + } + + [Fact] + public void Adding_a_webhook_persists_name_url_and_detected_slack_format() + { + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookNameDraft.Value = "pagerduty"; + vm.WebhookUrlDraft.Value = "https://hooks.slack.com/services/T000/B000/SECRET"; + vm.ActivateSelected(); + + var webhook = Assert.Single(Bind("Notifications").Webhooks); + Assert.Equal("pagerduty", webhook.Name); + Assert.Equal("https://hooks.slack.com/services/T000/B000/SECRET", webhook.Url); + Assert.Equal(WebhookFormat.Slack, webhook.Format); + Assert.Equal(TelemetryConfigScreen.List, vm.Screen.Value); + } + + [Fact] + public void Adding_a_generic_url_defaults_format_and_name() + { + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookUrlDraft.Value = "https://alerts.example.test/hook"; + vm.ActivateSelected(); + + var webhook = Assert.Single(Bind("Notifications").Webhooks); + Assert.Equal("generic-webhook", webhook.Name); + Assert.Equal(WebhookFormat.Generic, webhook.Format); + } + + [Fact] + public void Multiple_webhooks_round_trip_through_the_list_editor() + { + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookNameDraft.Value = "ops"; + vm.WebhookUrlDraft.Value = "https://alerts.example.test/ops"; + vm.ActivateSelected(); + + vm.BeginAddWebhook(); + vm.WebhookNameDraft.Value = "slack"; + vm.WebhookUrlDraft.Value = "https://hooks.slack.com/services/T/B/C"; + vm.ActivateSelected(); + + var webhooks = Bind("Notifications").Webhooks; + Assert.Equal(2, webhooks.Count); + Assert.Contains(webhooks, w => w.Name == "ops" && w.Format == WebhookFormat.Generic); + Assert.Contains(webhooks, w => w.Name == "slack" && w.Format == WebhookFormat.Slack); + + // A fresh VM sees both entries in its list rows (reentrancy). + using var reopened = new TelemetryAlertingConfigViewModel(_paths); + Assert.Equal(2, reopened.WebhookCount); + } + + [Fact] + public void Editing_a_webhook_updates_url_and_preserves_stored_header_when_blank() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"Notifications\":{\"DeduplicationWindowSeconds\":120,\"MaxRetries\":4,\"TimeoutSeconds\":12,\"Webhooks\":[{\"Name\":\"ops-alerts\",\"Url\":\"https://old.example.test/hook\",\"Headers\":{\"Authorization\":\"Bearer old\"},\"Format\":\"Generic\"}]}}"); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginEditWebhook(0); + Assert.True(vm.EditingHasPersistedAuthHeader.Value); + vm.WebhookUrlDraft.Value = "https://new.example.test/hook"; + vm.ActivateSelected(); + + var notifications = Bind("Notifications"); + var webhook = Assert.Single(notifications.Webhooks); + Assert.Equal("https://new.example.test/hook", webhook.Url); + Assert.Equal("Bearer old", webhook.Headers?["Authorization"]); + // Delivery policy is preserved untouched. + Assert.Equal(120, notifications.DeduplicationWindowSeconds); + Assert.Equal(4, notifications.MaxRetries); + Assert.Equal(12, notifications.TimeoutSeconds); + } + + [Fact] + public void Editing_a_webhook_replaces_the_auth_header_when_a_nonblank_header_is_entered() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"Notifications\":{\"Webhooks\":[{\"Name\":\"ops-alerts\",\"Url\":\"https://alerts.example.test/hook\",\"Headers\":{\"Authorization\":\"Bearer old\"},\"Format\":\"Generic\"}]}}"); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginEditWebhook(0); + vm.WebhookAuthHeaderDraft.Value = "Authorization: Bearer new"; + vm.ActivateSelected(); + + var webhook = Assert.Single(Bind("Notifications").Webhooks); + Assert.Equal("Bearer new", webhook.Headers?["Authorization"]); + } + + [Fact] + public void Editing_a_webhook_clears_the_auth_header_with_the_dash_gesture() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"Notifications\":{\"Webhooks\":[{\"Name\":\"ops-alerts\",\"Url\":\"https://alerts.example.test/hook\",\"Headers\":{\"Authorization\":\"Bearer old\"},\"Format\":\"Generic\"}]}}"); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginEditWebhook(0); + Assert.True(vm.EditingHasPersistedAuthHeader.Value); + // A blank field would preserve the header; "-" explicitly removes it. + vm.WebhookAuthHeaderDraft.Value = "-"; + vm.ActivateSelected(); + + var webhook = Assert.Single(Bind("Notifications").Webhooks); + Assert.Null(webhook.Headers); + } + + [Fact] + public void Saving_a_webhook_without_a_url_is_rejected_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookNameDraft.Value = "no-url"; + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("URL is required", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(TelemetryConfigScreen.WebhookForm, vm.Screen.Value); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Saving_a_webhook_with_a_non_http_url_is_rejected_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookUrlDraft.Value = "ftp://alerts.example.test/hook"; + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("absolute HTTP or HTTPS URI", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Saving_a_webhook_with_a_malformed_auth_header_is_rejected_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookUrlDraft.Value = "https://alerts.example.test/hook"; + vm.WebhookAuthHeaderDraft.Value = "Bearer token-without-header-name"; + vm.ActivateSelected(); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("Header-Name", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Removing_a_webhook_drops_only_the_selected_entry() + { + File.WriteAllText(_paths.NetclawConfigPath, + "{\"configVersion\":1,\"Notifications\":{\"Webhooks\":[{\"Name\":\"ops\",\"Url\":\"https://a.test/h\",\"Format\":\"Generic\"},{\"Name\":\"slack\",\"Url\":\"https://hooks.slack.com/x\",\"Format\":\"Slack\"}]}}"); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + // Row 2 == first webhook (OtlpRowCount == 2). + vm.SelectedRow.Value = TelemetryAlertingConfigViewModel.OtlpRowCount; + vm.RemoveSelectedWebhook(); + + var webhook = Assert.Single(Bind("Notifications").Webhooks); + Assert.Equal("slack", webhook.Name); + } + + [Fact] + public void Webhook_edits_preserve_unrelated_secrets_file() + { + File.WriteAllText(_paths.SecretsPath, "{\"Slack\":{\"BotToken\":\"ENC:slack\"}}"); + var beforeSecrets = File.ReadAllText(_paths.SecretsPath); + using var vm = new TelemetryAlertingConfigViewModel(_paths); + + vm.BeginAddWebhook(); + vm.WebhookUrlDraft.Value = "https://alerts.example.test/hook"; + vm.ActivateSelected(); + + Assert.Equal(beforeSecrets, File.ReadAllText(_paths.SecretsPath)); + } + + private T Bind(string sectionName) where T : new() + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(_paths.NetclawConfigPath) + .Build(); + return configuration.GetSection(sectionName).Get() ?? new T(); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Config/WorkspacesConfigViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/WorkspacesConfigViewModelTests.cs new file mode 100644 index 000000000..1d75f1d24 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Config/WorkspacesConfigViewModelTests.cs @@ -0,0 +1,218 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Config; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Config; + +public sealed class WorkspacesConfigViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public WorkspacesConfigViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Workspaces_dashboard_entry_routes_to_real_editor() + { + using var vm = new Netclaw.Cli.Tui.ConfigDashboardViewModel(new Netclaw.Cli.Tui.ConfigDashboardNavigationState()); + string? route = null; + vm.RouteRequested = r => route = r; + + vm.Activate(vm.Items.Single(static item => item.Label == "Workspaces Directory")); + + Assert.Equal("/workspaces", route); + } + + [Fact] + public void Save_persists_workspaces_directory_and_preserves_identity_files() + { + Directory.CreateDirectory(_paths.IdentityDirectory); + File.WriteAllText(_paths.SoulPath, "original soul"); + File.WriteAllText(_paths.ToolingPath, "original tooling"); + var customWorkspaces = Path.Combine(_dir.Path, "custom-workspaces"); + using var vm = new WorkspacesConfigViewModel(_paths); + + vm.AppendText(customWorkspaces); + + Assert.True(vm.Save()); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Workspaces.Directory", out var value)); + Assert.Equal(customWorkspaces, value); + Assert.True(Directory.Exists(customWorkspaces)); + Assert.Equal("original soul", File.ReadAllText(_paths.SoulPath)); + Assert.Equal("original tooling", File.ReadAllText(_paths.ToolingPath)); + } + + [Fact] + public void Constructor_with_malformed_config_does_not_throw_and_surfaces_error() + { + File.WriteAllText(_paths.NetclawConfigPath, "{ not valid json "); + + // Must not throw from the constructor (which would make the page permanently inaccessible). + using var vm = new WorkspacesConfigViewModel(_paths); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Save_rejects_url_before_persistence() + { + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new WorkspacesConfigViewModel(_paths); + + vm.AppendText("https://example.com/workspaces"); + + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Contains("local filesystem path", vm.Status.Value.Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Save_surfaces_malformed_config_read_failure_without_crashing() + { + var target = Path.Combine(_dir.Path, "workspaces"); + Directory.CreateDirectory(target); + using var vm = new WorkspacesConfigViewModel(_paths); + vm.AppendText(target); + + // Corrupt netclaw.json so the save-time LoadJsonDict read (which sat between the two + // try/catch blocks, outside the guard) throws JsonException rather than an IOException. + File.WriteAllText(_paths.NetclawConfigPath, "{ this is not valid json "); + + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + } + + [Fact] + public void Save_rejects_existing_file_before_persistence() + { + var filePath = Path.Combine(_dir.Path, "not-a-directory"); + File.WriteAllText(filePath, "file"); + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new WorkspacesConfigViewModel(_paths); + + vm.AppendText(filePath); + + Assert.False(vm.Save()); + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void Saved_directory_is_consumed_by_paths_and_prompt_workspace_context() + { + var customWorkspaces = Path.Combine(_dir.Path, "workspace-root"); + var projectDir = Path.Combine(customWorkspaces, "project-a"); + Directory.CreateDirectory(projectDir); + File.WriteAllText(Path.Combine(projectDir, "AGENTS.md"), "project-specific instructions"); + using var vm = new WorkspacesConfigViewModel(_paths); + vm.AppendText(customWorkspaces); + + Assert.True(vm.Save()); + var runtimePaths = new NetclawPaths(_paths.BasePath, ReadConfiguredWorkspacesDirectory()); + var promptProvider = new FileSystemPromptProvider(runtimePaths); + + Assert.Equal(customWorkspaces, runtimePaths.WorkspacesDirectory); + Assert.Contains("project-specific instructions", promptProvider.GetSystemPrompt(TrustAudience.Team, projectDir)); + } + + [Fact] + public void ApplyPickedDirectory_persists_the_chosen_directory() + { + // A directory chosen in the picker is itself the confirmation: it stages + saves at once. + var picked = Path.Combine(_dir.Path, "picked-workspaces"); + Directory.CreateDirectory(picked); + using var vm = new WorkspacesConfigViewModel(_paths); + + vm.ApplyPickedDirectory(picked); + + Assert.True(vm.IsSaved.Value); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Workspaces.Directory", out var value)); + Assert.Equal(picked, value); + } + + [Fact] + public void CreateAndSelectFolder_creates_persists_and_selects_a_new_subdirectory() + { + // The inline "new folder" affordance: create a subdir under where the picker is, then + // select it (the directory must exist + be persisted afterward). + var parent = Path.Combine(_dir.Path, "parent"); + Directory.CreateDirectory(parent); + using var vm = new WorkspacesConfigViewModel(_paths); + + vm.CreateAndSelectFolder(parent, "fresh-workspace"); + + var created = Path.Combine(parent, "fresh-workspace"); + Assert.True(Directory.Exists(created)); + Assert.True(vm.IsSaved.Value); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Workspaces.Directory", out var value)); + Assert.Equal(created, value); + } + + [Fact] + public void CreateAndSelectFolder_rejects_an_invalid_name_without_persisting() + { + var parent = Path.Combine(_dir.Path, "parent"); + Directory.CreateDirectory(parent); + var before = File.ReadAllText(_paths.NetclawConfigPath); + using var vm = new WorkspacesConfigViewModel(_paths); + + vm.CreateAndSelectFolder(parent, "bad/name"); + + Assert.Equal(ConfigStatusTone.Error, vm.Status.Value.Tone); + Assert.Equal(before, File.ReadAllText(_paths.NetclawConfigPath)); + } + + [Fact] + public void BrowseStartPath_prefers_the_existing_current_directory() + { + var fileSystem = new StubFileSystemProvider(existingDirectories: [_paths.WorkspacesDirectory]); + using var vm = new WorkspacesConfigViewModel(_paths, fileSystem); + + Assert.Equal(_paths.WorkspacesDirectory, vm.BrowseStartPath); + } + + [Fact] + public void BrowseStartPath_falls_back_to_the_parent_when_current_is_missing_but_parent_exists() + { + var parent = Path.GetDirectoryName(_paths.WorkspacesDirectory)!; + var fileSystem = new StubFileSystemProvider(existingDirectories: [parent]); + using var vm = new WorkspacesConfigViewModel(_paths, fileSystem); + + Assert.Equal(parent, vm.BrowseStartPath); + } + + [Fact] + public void BrowseStartPath_falls_back_to_home_when_neither_current_nor_parent_exist() + { + var fileSystem = new StubFileSystemProvider(existingDirectories: []); + using var vm = new WorkspacesConfigViewModel(_paths, fileSystem); + + Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), vm.BrowseStartPath); + } + + private string ReadConfiguredWorkspacesDirectory() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Workspaces.Directory", out var value)); + return Assert.IsType(value); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs new file mode 100644 index 000000000..da33a3d20 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ConfigDashboardViewModelTests.cs @@ -0,0 +1,235 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class ConfigDashboardViewModelTests +{ + [Fact] + public void Root_dashboard_contains_expected_domain_entries() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + + var labels = vm.Items.Select(static item => item.Label).ToList(); + + Assert.Equal( + [ + "Inference Providers", + "Models", + "Channels", + "Inbound Webhooks", + "Skill Sources", + "Search", + "Browser Automation", + "Telemetry & Alerting", + "Security & Access", + "Workspaces Directory", + "Run Full Doctor", + "Quit", + ], labels); + } + + [Fact] + public void Inference_providers_routes_to_provider_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Inference Providers")); + + Assert.Equal("/provider", navigatedRoute); + } + + [Fact] + public void Models_routes_to_model_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Models")); + + Assert.Equal("/model", navigatedRoute); + } + + [Fact] + public void Security_access_routes_to_security_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Security & Access")); + + Assert.Equal("/security", navigatedRoute); + } + + [Fact] + public void Channels_routes_to_channels_page() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(static item => item.Label == "Channels")); + + Assert.Equal("/channels", navigatedRoute); + } + + [Theory] + [InlineData("Inbound Webhooks", "/inbound-webhooks")] + [InlineData("Skill Sources", "/skill-sources")] + [InlineData("Browser Automation", "/browser-automation")] + [InlineData("Telemetry & Alerting", "/telemetry-alerting")] + [InlineData("Workspaces Directory", "/workspaces")] + public void Task1_config_areas_route_to_dedicated_pages(string label, string expectedRoute) + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + string? navigatedRoute = null; + vm.RouteRequested = route => navigatedRoute = route; + + vm.Activate(vm.Items.Single(item => item.Label == label)); + + Assert.Equal(expectedRoute, navigatedRoute); + } + + [Fact] + public void Run_full_doctor_sets_pending_action_and_shuts_down() + { + var navigationState = new ConfigDashboardNavigationState(); + using var vm = new ConfigDashboardViewModel(navigationState); + + vm.Activate(vm.Items.Single(static item => item.Label == "Run Full Doctor")); + + Assert.Equal(ConfigDashboardAction.RunDoctor, navigationState.PendingAction); + Assert.True(vm.ShutdownRequestedForTest); + } + + [Fact] + public void Status_summary_is_empty_without_a_config_reader() + { + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState()); + + foreach (var item in vm.Items) + Assert.Equal(string.Empty, vm.StatusFor(item)); + } + + [Fact] + public void Terminal_rows_never_carry_a_status_summary() + { + using var dir = new DisposableTempDir(); + var paths = new NetclawPaths(dir.Path); + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState(), paths); + + Assert.Equal(string.Empty, vm.StatusFor(vm.Items.Single(i => i.Label == "Run Full Doctor"))); + Assert.Equal(string.Empty, vm.StatusFor(vm.Items.Single(i => i.Label == "Quit"))); + } + + [Fact] + public void Malformed_sections_render_a_config_error_indicator_without_crashing() + { + using var dir = new DisposableTempDir(); + var paths = new NetclawPaths(dir.Path); + paths.EnsureDirectoriesExist(); + // Sources/Webhooks have the wrong JSON shape (a number / string instead of an array), as a + // hand-edited or migrated config might. Summarize runs in the dashboard layout render and + // must degrade to a visible indicator rather than throwing JsonException into the render loop. + File.WriteAllText(paths.NetclawConfigPath, + "{\"configVersion\":1,\"ExternalSkills\":{\"Sources\":42},\"Notifications\":{\"Webhooks\":\"nope\"}}"); + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState(), paths); + + Assert.Contains("config error", vm.StatusFor(vm.Items.Single(i => i.Label == "Skill Sources")), StringComparison.Ordinal); + Assert.Contains("config error", vm.StatusFor(vm.Items.Single(i => i.Label == "Telemetry & Alerting")), StringComparison.Ordinal); + } + + [Fact] + public void Status_summaries_reflect_an_empty_default_config() + { + using var dir = new DisposableTempDir(); + var paths = new NetclawPaths(dir.Path); + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState(), paths); + + Assert.Equal("0 configured", Summary(vm, "Inference Providers")); + Assert.Equal("– not set", Summary(vm, "Models")); + Assert.Equal("– none configured", Summary(vm, "Channels")); + Assert.Equal("– disabled", Summary(vm, "Inbound Webhooks")); + Assert.Equal("0 dirs · 0 feeds", Summary(vm, "Skill Sources")); + Assert.Equal("– not set", Summary(vm, "Search")); + Assert.Equal("– disabled", Summary(vm, "Browser Automation")); + Assert.Equal("OTLP off · 0 webhooks", Summary(vm, "Telemetry & Alerting")); + // Features default to enabled when absent, so a bare config reports 6/6. + Assert.Equal("Personal · 6/6 enabled", Summary(vm, "Security & Access")); + Assert.Equal(paths.WorkspacesDirectory, Summary(vm, "Workspaces Directory")); + } + + [Fact] + public void Status_summaries_reflect_a_populated_config() + { + using var dir = new DisposableTempDir(); + var paths = new NetclawPaths(dir.Path); + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Providers": { "anthropic": { "Type": "anthropic" }, "openai": { "Type": "openai" } }, + "Models": { "Main": { "Provider": "anthropic", "ModelId": "claude-opus-4" } }, + "Slack": { "Enabled": true, "AllowedChannelIds": ["C01", "C02"] }, + "Discord": { "Enabled": true, "AllowedChannelIds": ["123"] }, + "Webhooks": { "Enabled": true }, + "ExternalSkills": { "Sources": [ { "Name": "claude-code" } ] }, + "SkillFeeds": { "Feeds": [ { "Name": "corp", "Url": "https://skills.corp.com" } ] }, + "Search": { "Backend": "brave" }, + "McpServers": { "browser_playwright": { "Command": "npx", "Args": ["@playwright/mcp@latest"] } }, + "Telemetry": { "Enabled": true }, + "Notifications": { "Webhooks": [ { "Url": "https://hooks.slack.com/x" } ] }, + "Security": { "DeploymentPosture": "Team", "Memory": { "Enabled": false } }, + "Memory": { "Enabled": false } + } + """); + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState(), paths); + + Assert.Equal("2 configured", Summary(vm, "Inference Providers")); + Assert.Equal("claude-opus-4", Summary(vm, "Models")); + Assert.Equal("Slack · Discord · 3 channels", Summary(vm, "Channels")); + Assert.Equal("enabled", Summary(vm, "Inbound Webhooks")); + Assert.Equal("1 dir · 1 feed", Summary(vm, "Skill Sources")); + Assert.Equal("✓ Brave", Summary(vm, "Search")); + Assert.Equal("enabled", Summary(vm, "Browser Automation")); + Assert.Equal("OTLP on · 1 webhook", Summary(vm, "Telemetry & Alerting")); + // Memory.Enabled=false drops the count to 5/6. + Assert.Equal("Team · 5/6 enabled", Summary(vm, "Security & Access")); + } + + [Fact] + public void Status_summaries_are_recomputed_on_each_read_for_autosave_reentrancy() + { + using var dir = new DisposableTempDir(); + var paths = new NetclawPaths(dir.Path); + paths.EnsureDirectoriesExist(); + File.WriteAllText(paths.NetclawConfigPath, "{ \"configVersion\": 1, \"Search\": { \"Backend\": \"duckduckgo\" } }"); + using var vm = new ConfigDashboardViewModel(new ConfigDashboardNavigationState(), paths); + + Assert.Equal("✓ DuckDuckGo", Summary(vm, "Search")); + + // Simulate a sub-editor autosave changing the backend, then returning. + File.WriteAllText(paths.NetclawConfigPath, "{ \"configVersion\": 1, \"Search\": { \"Backend\": \"brave\" } }"); + + Assert.Equal("✓ Brave", Summary(vm, "Search")); + } + + private static string Summary(ConfigDashboardViewModel vm, string label) + => vm.StatusFor(vm.Items.Single(item => item.Label == label)); +} diff --git a/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs index 48b04a368..ee11d57c8 100644 --- a/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs +++ b/src/Netclaw.Cli.Tests/Tui/FakeDiscordProbe.cs @@ -16,7 +16,9 @@ public sealed class FakeDiscordProbe : IDiscordProbe public string? LastBotToken { get; private set; } - public DiscordChannelResolutionResult NextResolutionResult { get; set; } = new(true, null, [], []); + // When null (default), ResolveChannelIdsAsync echoes every input as a resolved channel (mimics + // "all valid ids/names resolve"). Set it to stage a specific resolved/unresolved outcome. + public DiscordChannelResolutionResult? NextResolutionResult { get; set; } public int ResolveCallCount { get; private set; } @@ -24,6 +26,13 @@ public sealed class FakeDiscordProbe : IDiscordProbe public TimeSpan? DelayBeforeResult { get; set; } + /// + /// Optional gate. When set, blocks (observing the + /// cancellation token) until the gate is completed — used to stage an in-flight channel-name + /// prefetch for race/cancellation tests. Null (default) returns immediately. + /// + public TaskCompletionSource? ResolveGate { get; set; } + public async Task ProbeAsync(string botToken, CancellationToken ct = default) { ProbeCallCount++; @@ -37,9 +46,16 @@ public async Task ResolveChannelIdsAsync( string botToken, IReadOnlyList channelIds, CancellationToken ct = default) { ResolveCallCount++; + LastBotToken = botToken; LastResolvedIds = channelIds; + if (ResolveGate is not null) + await ResolveGate.Task.WaitAsync(ct); if (DelayBeforeResult.HasValue) await Task.Delay(DelayBeforeResult.Value, ct); - return NextResolutionResult; + return NextResolutionResult ?? new DiscordChannelResolutionResult( + true, + null, + [.. channelIds.Select(id => new ResolvedDiscordChannel(id, id, "Test Guild"))], + []); } } diff --git a/src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs new file mode 100644 index 000000000..f673283ba --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/FakeMattermostProbe.cs @@ -0,0 +1,56 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Mattermost; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class FakeMattermostProbe : IMattermostProbe +{ + public MattermostProbeResult NextProbeResult { get; set; } = new( + true, null, "testbot"); + + public int ProbeCallCount { get; private set; } + + public string? LastServerUrl { get; private set; } + + public string? LastBotToken { get; private set; } + + // When null (default), ResolveChannelIdsAsync echoes every input as a resolved channel (mimics + // "all valid ids/names resolve"). Set it to stage a specific resolved/unresolved outcome. + public MattermostChannelResolutionResult? NextResolutionResult { get; set; } + + public int ResolveCallCount { get; private set; } + + public IReadOnlyList? LastResolvedIds { get; private set; } + + public TimeSpan? DelayBeforeResult { get; set; } + + public async Task ProbeAsync(string serverUrl, string botToken, CancellationToken ct = default) + { + ProbeCallCount++; + LastServerUrl = serverUrl; + LastBotToken = botToken; + if (DelayBeforeResult.HasValue) + await Task.Delay(DelayBeforeResult.Value, ct); + return NextProbeResult; + } + + public async Task ResolveChannelIdsAsync( + string serverUrl, string botToken, IReadOnlyList channelIds, CancellationToken ct = default) + { + ResolveCallCount++; + LastServerUrl = serverUrl; + LastBotToken = botToken; + LastResolvedIds = channelIds; + if (DelayBeforeResult.HasValue) + await Task.Delay(DelayBeforeResult.Value, ct); + return NextResolutionResult ?? new MattermostChannelResolutionResult( + true, + null, + [.. channelIds.Select(id => new ResolvedMattermostChannel(id, id, id))], + []); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/FakeProviderProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeProviderProbe.cs index f6bb86709..cf1776ed8 100644 --- a/src/Netclaw.Cli.Tests/Tui/FakeProviderProbe.cs +++ b/src/Netclaw.Cli.Tests/Tui/FakeProviderProbe.cs @@ -56,7 +56,14 @@ public sealed class FakeProviderProbe : IProviderProbe /// public string? LastApiKey { get; private set; } - public Task ProbeAsync( + /// + /// Optional gate. When set, + /// blocks (observing the cancellation token) until the gate is completed — used to stage + /// in-flight probes for cancellation/concurrency tests. Null (default) returns immediately. + /// + public TaskCompletionSource? Gate { get; set; } + + public async Task ProbeAsync( string providerType, string? endpoint, string? apiKey, CancellationToken ct = default) { @@ -65,14 +72,15 @@ public Task ProbeAsync( LastApiKey = apiKey; ProbedTypes.Add(providerType); + if (Gate is not null) + await Gate.Task.WaitAsync(ct); + if (ExceptionToThrow is not null) - return Task.FromException(ExceptionToThrow); + throw ExceptionToThrow; - var result = TypeResults.TryGetValue(providerType, out var typeResult) + return TypeResults.TryGetValue(providerType, out var typeResult) ? typeResult : NextResult; - - return Task.FromResult(result); } public Task ProbeAsync( diff --git a/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs b/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs index f85b111fe..73087bb88 100644 --- a/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs +++ b/src/Netclaw.Cli.Tests/Tui/FakeSlackProbe.cs @@ -51,6 +51,16 @@ public sealed class FakeSlackProbe : ISlackProbe /// public TimeSpan? DelayBeforeResult { get; set; } + public Exception? ResolutionException { get; set; } + + /// + /// When set, answers per-request: each requested name found + /// in this map resolves to its id, the rest come back unresolved. Lets a test exercise multi-channel + /// (CSV) input where each reference resolves distinctly. is used + /// when this is null. + /// + public IReadOnlyDictionary? ResolveByName { get; set; } + public async Task ProbeAsync(string botToken, CancellationToken ct = default) { ProbeCallCount++; @@ -64,9 +74,27 @@ public async Task ResolveChannelNamesAsync( string botToken, IReadOnlyList channelNames, CancellationToken ct = default) { ResolveCallCount++; + LastBotToken = botToken; LastResolvedNames = channelNames; + if (ResolutionException is not null) + throw ResolutionException; + if (DelayBeforeResult.HasValue) await Task.Delay(DelayBeforeResult.Value, ct); - return NextResolutionResult; + + if (ResolveByName is null) + return NextResolutionResult; + + var resolved = new List(); + var unresolved = new List(); + foreach (var name in channelNames) + { + if (ResolveByName.TryGetValue(name, out var id)) + resolved.Add(new ResolvedSlackChannel(name, id)); + else + unresolved.Add(name); + } + + return new SlackChannelResolutionResult(unresolved.Count == 0, null, resolved, unresolved); } } diff --git a/src/Netclaw.Cli.Tests/Tui/IdentityRedoViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/IdentityRedoViewModelTests.cs new file mode 100644 index 000000000..cf4094cae --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/IdentityRedoViewModelTests.cs @@ -0,0 +1,118 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Reflection; +using System.Text.Json; +using Netclaw.Cli.Tui; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Termina.Reactive; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +/// +/// Behavioral coverage for the "redo identity setup" flow. The defining invariant +/// (simplify-netclaw-init) is that this flow rewrites the identity files WITHOUT +/// calling WriteConfig, so a redo must never clobber an existing +/// netclaw.json — security posture and configured providers must survive. +/// +public sealed class IdentityRedoViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public IdentityRedoViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Redo_rewrites_identity_files_without_clobbering_config() + { + // A non-default config: a hardened posture plus a configured provider entry. + // The redo must leave both untouched while (re)writing SOUL.md / TOOLING.md. + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Security": { "DeploymentPosture": "Team" }, + "Providers": { "openrouter": { "BaseUrl": "https://openrouter.ai/api/v1" } }, + "Identity": { "AgentName": "Existing", "UserTimezone": "UTC" } + } + """); + var configBefore = File.ReadAllText(_paths.NetclawConfigPath); + var securityBefore = ReadSection(configBefore, "Security"); + var providersBefore = ReadSection(configBefore, "Providers"); + + // Identity files do not exist before a redo run. + Assert.False(File.Exists(_paths.SoulPath)); + Assert.False(File.Exists(_paths.ToolingPath)); + + using var vm = new IdentityRedoViewModel(_paths); + DriveToSaved(vm); + + Assert.True(vm.IsSaved.Value); + + // The identity files were (re)written by the redo flow. + Assert.True(File.Exists(_paths.SoulPath), "SOUL.md must be written by the redo flow."); + Assert.True(File.Exists(_paths.ToolingPath), "TOOLING.md must be written by the redo flow."); + Assert.NotEqual(0, new FileInfo(_paths.SoulPath).Length); + Assert.NotEqual(0, new FileInfo(_paths.ToolingPath).Length); + + // netclaw.json is byte-for-byte untouched: redo never calls WriteConfig. + var configAfter = File.ReadAllText(_paths.NetclawConfigPath); + Assert.Equal(configBefore, configAfter); + Assert.Equal(securityBefore, ReadSection(configAfter, "Security")); + Assert.Equal(providersBefore, ReadSection(configAfter, "Providers")); + } + + [Fact] + public void GoBack_at_first_identity_field_routes_to_existing_install_menu() + { + File.WriteAllText(_paths.NetclawConfigPath, "{ \"configVersion\": 1 }"); + using var vm = new IdentityRedoViewModel(_paths); + + string? route = null; + SetNavigate(vm, r => route = r); + + // Esc / GoBack at the very first identity sub-step exits the redo flow + // back to the existing-install menu rather than swallowing the keystroke. + vm.GoBack(); + + Assert.Equal(InitExistingInstallViewModel.MenuRoute, route); + } + + // Drives the single-step identity flow forward until the redo reports IsSaved. + // The orchestrator advances through the identity sub-steps; one extra GoNext past + // the last sub-step finalizes (writes identity files, sets IsSaved). Guard the loop + // so a flow that never completes fails loudly instead of hanging. + private static void DriveToSaved(IdentityRedoViewModel vm) + { + for (var i = 0; i < 32 && !vm.IsSaved.Value; i++) + vm.GoNext(); + } + + private static string ReadSection(string json, string section) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.GetProperty(section).GetRawText(); + } + + // The Navigate delegate is a protected, framework-wired member on ReactiveViewModel + // (set via the internal WireUp during page binding, which tests cannot reach). + // Inject it directly so we can observe the route the redo flow requests on exit. + private static void SetNavigate(ReactiveViewModel vm, Action navigate) + { + var property = typeof(ReactiveViewModel).GetProperty( + "Navigate", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(property); + property!.SetValue(vm, navigate); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/InitExistingInstallViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/InitExistingInstallViewModelTests.cs new file mode 100644 index 000000000..cecd46e7b --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/InitExistingInstallViewModelTests.cs @@ -0,0 +1,157 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +using Phase = InitExistingInstallViewModel.Phase; +using ResetScopeKind = InitExistingInstallViewModel.ResetScopeKind; + +/// +/// Behavioral coverage for the existing-install menu and its double-confirmed +/// start-over flow (simplify-netclaw-init §3–4). Drives the ViewModel phase machine +/// directly; the Termina rendering is exercised separately by the smoke tapes. +/// +public sealed class InitExistingInstallViewModelTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + private readonly InitNavigationState _nav = new(); + + public InitExistingInstallViewModelTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + private InitExistingInstallViewModel Create() => new(_paths, _nav); + + private static void Select(InitExistingInstallViewModel vm, int index) + { + vm.SelectedIndex.Value = index; + vm.ActivateSelected(); + } + + [Fact] + public void OpenConfigEditor_SetsPendingHandoffAction() + { + var vm = Create(); + + Select(vm, 1); // "Open configuration editor" + + Assert.Equal(InitFollowUpAction.OpenConfigEditor, _nav.PendingAction); + } + + [Fact] + public void StartOver_EntersResetScopeAtTop() + { + var vm = Create(); + + Select(vm, 2); // "Start over from scratch" + + Assert.Equal(Phase.ResetScope, vm.CurrentPhase.Value); + Assert.Equal(0, vm.SelectedIndex.Value); + } + + [Fact] + public void DestructiveReset_RequiresTwoConfirmationsBeforeDeleting() + { + var vm = Create(); + + Select(vm, 2); // Start over → ResetScope + Select(vm, 1); // Full reset → ResetConfirm1 + + Assert.Equal(Phase.ResetConfirm1, vm.CurrentPhase.Value); + Assert.Equal(ResetScopeKind.Full, vm.Scope); + // Each confirmation defaults to Cancel so a stray Enter never deletes. + Assert.Equal(0, vm.SelectedIndex.Value); + + Select(vm, 1); // "Yes" on the FIRST confirmation → only advances to confirm 2 + + Assert.Equal(Phase.ResetConfirm2, vm.CurrentPhase.Value); + Assert.True(Directory.Exists(_paths.ConfigDirectory), + "Config must still exist after only one confirmation."); + } + + [Fact] + public void FullReset_AfterBothConfirmations_DeletesEverything() + { + File.WriteAllText(_paths.NetclawConfigPath, "{}"); + File.WriteAllText(_paths.SqliteDbPath, "db"); + + var vm = Create(); + Select(vm, 2); // Start over + Select(vm, 1); // Full reset → confirm 1 + Select(vm, 1); // Yes → confirm 2 + Select(vm, 1); // Yes → perform + + Assert.False(Directory.Exists(_paths.BasePath)); + } + + [Fact] + public void SetupOnlyReset_DeletesConfigButKeepsMemoryAndSessions() + { + // Setup-only reset removes everything the bootstrap wizard writes + // (config + secrets + identity + soul) while leaving operator data + // (memory db, sessions) intact. Seed both sides of that boundary. + File.WriteAllText(_paths.NetclawConfigPath, "{}"); + File.WriteAllText(_paths.SecretsPath, "{}"); + Directory.CreateDirectory(_paths.IdentityDirectory); + File.WriteAllText(_paths.SoulPath, "soul"); + Directory.CreateDirectory(_paths.SoulDirectory); + File.WriteAllText(Path.Combine(_paths.SoulDirectory, "fragment.md"), "detail"); + File.WriteAllText(_paths.SqliteDbPath, "db"); + Directory.CreateDirectory(_paths.SessionsDirectory); + + var vm = Create(); + Select(vm, 2); // Start over + Select(vm, 0); // Reset setup only → confirm 1 + Select(vm, 1); // Yes → confirm 2 + Select(vm, 1); // Yes → perform + + // Removed: config (incl. secrets, which lives under ConfigDirectory) + identity + soul. + Assert.False(Directory.Exists(_paths.ConfigDirectory), "Config should be removed."); + Assert.False(File.Exists(_paths.SecretsPath), "Secrets should be removed."); + Assert.False(Directory.Exists(_paths.IdentityDirectory), "Identity files should be removed."); + Assert.False(Directory.Exists(_paths.SoulDirectory), "Soul fragments should be removed."); + + // Preserved: operator data. + Assert.True(File.Exists(_paths.SqliteDbPath), "Memory db should be preserved."); + Assert.True(Directory.Exists(_paths.SessionsDirectory), "Sessions should be preserved."); + } + + [Fact] + public void ConfirmationCancel_ReturnsToScope() + { + var vm = Create(); + Select(vm, 2); // Start over + Select(vm, 1); // Full reset → confirm 1 + Select(vm, 0); // Cancel → back to scope + + Assert.Equal(Phase.ResetScope, vm.CurrentPhase.Value); + } + + [Fact] + public void GoBack_WalksPhasesBackToMenu() + { + var vm = Create(); + Select(vm, 2); // ResetScope + Select(vm, 1); // ResetConfirm1 + Select(vm, 1); // ResetConfirm2 + + vm.GoBack(); + Assert.Equal(Phase.ResetConfirm1, vm.CurrentPhase.Value); + vm.GoBack(); + Assert.Equal(Phase.ResetScope, vm.CurrentPhase.Value); + vm.GoBack(); + Assert.Equal(Phase.Menu, vm.CurrentPhase.Value); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs index a3519fe52..14c5949ab 100644 --- a/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InitWizardPageTests.cs @@ -4,7 +4,6 @@ // // ----------------------------------------------------------------------- using Microsoft.Extensions.DependencyInjection; -using Netclaw.Actors.Channels; using Netclaw.Cli.Provider; using Netclaw.Cli.Tui; using Netclaw.Cli.Tui.Wizard.Steps; @@ -103,213 +102,6 @@ public async Task DownArrowThenEnter_SelectsSecondProvider() Assert.Equal(_registry.KnownTypeKeys[1], vm.ProviderStep.SelectedProviderType); } - // ── Channels step key routing (#539) ─────────────────────────────────── - - /// - /// Verifies that DownArrow reaches the Channels step view through - /// HandlePageInput, even when a stale SelectionListNode is on the - /// focus stack from a previous step. - /// - [Fact] - public async Task ChannelsStep_DownArrow_RendersChannelList() - { - var (terminal, app, vm) = CreateHeadlessApp(out var input); - - // Make Channels step applicable before the picker's OnLeave - vm.Context.AnyChatServicesEnabled = true; - - // Skip: provider -> security-posture -> feature-selection -> channel-picker -> channels - vm.Orchestrator.GoNext(); // provider → security-posture - vm.Orchestrator.GoNext(); // security-posture → feature-selection - vm.Orchestrator.GoNext(); // feature-selection → channel-picker - vm.Orchestrator.GoNext(); // channel-picker → channels (additive flag preserved) - - Assert.Equal("channels", vm.Orchestrator.CurrentStep?.StepId); - - // Populate entries for the Channels step to render - vm.Context.ChannelEntries[ChannelType.Slack] = - [ - new ChannelEntry("#general", "C123", TrustAudience.Team), - new ChannelEntry("#random", "C456", TrustAudience.Team), - ]; - - // Send DownArrow (the key that was broken) then Ctrl+Q to exit - input.EnqueueKey(ConsoleKey.DownArrow); - input.EnqueueKey(ConsoleKey.Q, control: true); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await app.RunAsync(cts.Token); - - // Terminal should contain both channel names - Assert.True(terminal.Contains("#general"), - $"Expected #general in terminal. Screen:\n{terminal}"); - Assert.True(terminal.Contains("#random"), - $"Expected #random in terminal. Screen:\n{terminal}"); - } - - /// - /// Verifies that the 'A' key enters add-channel mode on the Channels step. - /// - [Fact] - public async Task ChannelsStep_AKey_EntersAddMode() - { - var (terminal, app, vm) = CreateHeadlessApp(out var input); - - // Make Channels step applicable before the picker's OnLeave - vm.Context.AnyChatServicesEnabled = true; - - // Skip: provider -> security-posture -> feature-selection -> channel-picker -> channels - vm.Orchestrator.GoNext(); - vm.Orchestrator.GoNext(); - vm.Orchestrator.GoNext(); - vm.Orchestrator.GoNext(); - - Assert.Equal("channels", vm.Orchestrator.CurrentStep?.StepId); - - // No entries needed — testing add mode ('A' key should work regardless) - - // Send 'A' key then Ctrl+Q to exit - input.EnqueueKey(ConsoleKey.A); - input.EnqueueKey(ConsoleKey.Q, control: true); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await app.RunAsync(cts.Token); - - // Verify the Channels view entered add mode - var channelsView = (ChannelsStepView)vm.StepViews["channels"]; - Assert.True(channelsView.IsAddMode, - $"Expected Channels view to be in add mode after pressing 'A'. " + - $"CurrentStep={vm.Orchestrator.CurrentStep?.StepId}, Screen:\n{terminal}"); - } - - // ── Channel picker sub-flow key routing ────────────────────────────────── - - /// - /// Regression test: entering a valid Slack bot token (xoxb-...) and pressing - /// Enter must advance to the app token sub-step, not loop back to bot token. - /// Exercises the full Termina rendering + ChannelPicker sub-flow pipeline. - /// - [Fact] - public async Task SlackSubFlow_BotTokenSubmit_AdvancesToAppToken() - { - var (terminal, app, vm) = CreateHeadlessApp(out var input); - - // Navigate to channel-picker step - vm.Orchestrator.GoNext(); // provider → security-posture - vm.Orchestrator.GoNext(); // security-posture → feature-selection - vm.Orchestrator.GoNext(); // feature-selection → channel-picker - Assert.Equal("channel-picker", vm.Orchestrator.CurrentStep?.StepId); - - // In picker mode: Enter on Slack (index 0) toggles it on and enters sub-flow - input.EnqueueKey(ConsoleKey.Enter); - - // Now in Slack sub-flow at bot token (sub-step 1, since enable is skipped). - // Type a valid token and press Enter to submit. - input.EnqueueString("xoxb-test-token-12345"); - input.EnqueueKey(ConsoleKey.Enter); - - // If the bug is present, we'd still be on bot token. - // Ctrl+Q to exit after the advance should have happened. - input.EnqueueKey(ConsoleKey.Q, control: true); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await app.RunAsync(cts.Token); - - // The Slack VM should have advanced past bot token to app token (sub-step 2) - var pickerVm = (ChannelPickerStepViewModel)vm.Orchestrator.CurrentStep!; - var slackVm = (SlackStepViewModel)pickerVm.ActiveAdapterVm!; - Assert.Equal("xoxb-test-token-12345", slackVm.BotToken); - Assert.True(terminal.Contains("App Token"), - $"Expected 'App Token' prompt after submitting bot token. Screen:\n{terminal}"); - } - - /// - /// Navigates to channel-picker via keyboard through the security-posture step - /// (instead of programmatic GoNext), building Termina's focus stack naturally. - /// The SecurityPostureStepView's SelectionListNode remains on the focus stack - /// when the Slack sub-flow's TextInputNode takes over — matching the real - /// terminal scenario where stale focused components may intercept keys. - /// - [Fact] - public async Task SlackSubFlow_WithFocusStackFromPriorSteps_BotTokenAdvances() - { - var (terminal, app, vm) = CreateHeadlessApp(out var input); - - // Skip provider (too many sub-steps to drive via keyboard) - vm.Orchestrator.GoNext(); // provider -> security-posture - - // Enter on security-posture selects "Personal" (index 0). - // Personal skips feature-selection, lands on channel-picker. - // SecurityPostureStepView's SelectionListNode is now stale on the focus stack. - input.EnqueueKey(ConsoleKey.Enter); - - // Enter on channel-picker toggles Slack on and enters sub-flow. - // The picker's SelectionListNode is now also stale. - input.EnqueueKey(ConsoleKey.Enter); - - // Type valid bot token and submit - input.EnqueueString("xoxb-focus-stack-test"); - input.EnqueueKey(ConsoleKey.Enter); - - input.EnqueueKey(ConsoleKey.Q, control: true); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await app.RunAsync(cts.Token); - - var pickerVm = (ChannelPickerStepViewModel)vm.Orchestrator.CurrentStep!; - var slackVm = (SlackStepViewModel)pickerVm.ActiveAdapterVm!; - Assert.Equal("xoxb-focus-stack-test", slackVm.BotToken); - Assert.True(terminal.Contains("App Token"), - $"Expected 'App Token' prompt after submitting bot token. Screen:\n{terminal}"); - } - - /// - /// Full Slack sub-flow traversal: bot token -> app token -> channel names -> DM enabled. - /// Exercises multiple TextInputNode and SelectionListNode transitions within the sub-flow, - /// verifying that focus state is correctly managed across sub-step boundaries. - /// By the time the DM SelectionListNode renders, multiple stale TextInputNodes sit - /// on the focus stack. - /// - [Fact] - public async Task SlackSubFlow_FullTraversal_BotTokenThroughDmEnabled() - { - var (terminal, app, vm) = CreateHeadlessApp(out var input); - - vm.Orchestrator.GoNext(); // provider -> security-posture - - // Enter: selects Personal, skips feature-selection, lands on channel-picker - input.EnqueueKey(ConsoleKey.Enter); - - // Enter: toggles Slack on, enters sub-flow at bot token - input.EnqueueKey(ConsoleKey.Enter); - - // Sub-step 1: Bot token - input.EnqueueString("xoxb-full-traversal-token"); - input.EnqueueKey(ConsoleKey.Enter); - - // Sub-step 2: App token - input.EnqueueString("xapp-full-traversal-token"); - input.EnqueueKey(ConsoleKey.Enter); - - // Sub-step 3: Channel names (Enter to skip) - input.EnqueueKey(ConsoleKey.Enter); - - // Sub-step 4: DM enabled (SelectionListNode, Enter selects first = "Yes") - input.EnqueueKey(ConsoleKey.Enter); - - input.EnqueueKey(ConsoleKey.Q, control: true); - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await app.RunAsync(cts.Token); - - var pickerVm = (ChannelPickerStepViewModel)vm.Orchestrator.CurrentStep!; - var slackVm = (SlackStepViewModel)pickerVm.ActiveAdapterVm!; - - Assert.Equal("xoxb-full-traversal-token", slackVm.BotToken); - Assert.Equal("xapp-full-traversal-token", slackVm.AppToken); - Assert.True(slackVm.AllowDirectMessages, - "Expected DM to be enabled after selecting 'Yes' on the DM sub-step"); - } // ── Config integrity: wizard choices must match written config ────────── @@ -322,8 +114,8 @@ public async Task PersonalPosture_WrittenConfig_DoesNotDisableFeatures() { var (_, app, vm) = CreateHeadlessApp(out var input); - // Skip provider step programmatically (too many sub-steps to drive via keyboard) - vm.Orchestrator.GoNext(); // provider → security-posture + // Advance to the posture step (provider → identity → security-posture). + AdvanceToStep(vm, "security-posture"); // Select Personal (index 0) via keyboard — this is the critical decision input.EnqueueKey(ConsoleKey.Enter); @@ -369,7 +161,7 @@ public async Task TeamPosture_DefaultFeatures_AllEnabledInWrittenConfig() { var (_, app, vm) = CreateHeadlessApp(out var input); - vm.Orchestrator.GoNext(); // provider → security-posture + AdvanceToStep(vm, "security-posture"); // provider → identity → security-posture // Select Team (index 1) via keyboard: DownArrow then Enter input.EnqueueKey(ConsoleKey.DownArrow); @@ -407,6 +199,18 @@ public async Task TeamPosture_DefaultFeatures_AllEnabledInWrittenConfig() // ── Helpers ────────────────────────────────────────────────────────────── + /// + /// Drive the orchestrator forward until the named step is current. Identity sits + /// between Provider and Security Posture in the bootstrap flow and advances purely + /// on its sub-step counter, so GoNext walks straight through it. + /// + private static void AdvanceToStep(InitWizardViewModel vm, string stepId) + { + for (var i = 0; i < 30 && vm.Orchestrator.CurrentStep?.StepId != stepId; i++) + vm.Orchestrator.GoNext(); + Assert.Equal(stepId, vm.Orchestrator.CurrentStep?.StepId); + } + private (VirtualTerminal Terminal, TerminaApplication App, InitWizardViewModel Vm) CreateHeadlessApp(out VirtualInputSource input) { diff --git a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs index 74b969fdc..3f50decad 100644 --- a/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ModelManagerViewModelTests.cs @@ -417,6 +417,38 @@ public void GoBack_FromSelectProvider_ReturnsToRoleOverview() Assert.Equal(ModelManagerState.RoleOverview, vm.CurrentState.Value); } + [Fact] + public void GoBack_FromRoleOverview_NavigatesToConfigWhenEmbedded() + { + using var vm = CreateViewModel(); + vm.IsEmbeddedInConfig = true; + vm.CurrentState.Value = ModelManagerState.RoleOverview; + string? route = null; + vm.RouteRequested = r => route = r; + + vm.GoBack(); + + Assert.Equal("/config", route); + } + + [Fact] + public void GoBack_FromRoleOverview_DoesNotNavigateWhenStandalone() + { + // Standalone `netclaw model` host: IsEmbeddedInConfig stays false (no EmbeddedConfigHostMarker + // in DI). Backing out past the root must NOT navigate to /config — that route is not + // registered in the standalone host, and the previous code both navigated and Shutdown(), + // which in the embedded host dropped the queued nav and quit the whole config app. + using var vm = CreateViewModel(); + Assert.False(vm.IsEmbeddedInConfig); + vm.CurrentState.Value = ModelManagerState.RoleOverview; + string? route = null; + vm.RouteRequested = r => route = r; + + vm.GoBack(); + + Assert.Null(route); + } + [Fact] public void Refresh_PopulatesDisplayNameFromRegistry() { diff --git a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs index 76641d172..99e2a3a02 100644 --- a/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ProviderManagerViewModelTests.cs @@ -412,6 +412,43 @@ public async Task ConfirmRename_OnFailure_PreservesCandidateForRedraw() Assert.Equal("my-ollama", vm.RenameNewName); } + [Fact] + public async Task Leaving_detail_view_cancels_in_flight_revalidation() + { + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-ollama"] = new Dictionary + { + ["Type"] = "ollama", + ["Endpoint"] = "http://localhost:11434" + } + } + }); + + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + vm.DetailProvider = vm.DisplayProviders.Single(p => p.IsConfigured); + var item = vm.DetailProvider; + _fakeProbe.Gate = new TaskCompletionSource(); + + vm.RevalidateDetailProvider(); // starts the revalidation — it blocks on the gated probe + Assert.NotNull(vm.RevalidateCompletion); + Assert.Equal(ProviderHealthStatus.Probing, item.Health); + + // Operator leaves the detail view: the in-flight revalidation must be cancelled, not left + // running with CancellationToken.None to update health (or redraw) against an abandoned view. + vm.GoBackToList(); + + await vm.RevalidateCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // The cancelled revalidation did not update health (stayed Probing) and did not throw. + Assert.Equal(ProviderHealthStatus.Probing, item.Health); + } + [Fact] public async Task ActivateSelectedProvider_Healthy_TransitionsToDetails() { @@ -517,6 +554,54 @@ public async Task FixCredentials_Success_ReturnsToList() Assert.False(vm.IsFixFlow); } + [Fact] + public async Task FixCredentials_ProbeFailure_LeavesStoredSecretUnchanged() + { + // An unhealthy provider with an existing, working secret on disk. + _fakeProbe.TypeResults["openrouter"] = new ProviderProbeResult(false, "Unauthorized", []); + WriteConfig(new Dictionary + { + ["configVersion"] = 1, + ["Providers"] = new Dictionary + { + ["my-openrouter"] = new Dictionary + { + ["Type"] = "openrouter", + ["Endpoint"] = "https://openrouter.ai/api/v1", + ["AuthMethod"] = "ApiKey" + } + } + }); + WriteSecrets(new Dictionary + { + ["Providers"] = new Dictionary + { + ["my-openrouter"] = new Dictionary { ["ApiKey"] = "sk-old-working-key" } + } + }); + var secretsBefore = File.ReadAllText(_paths.SecretsPath); + + using var vm = CreateViewModel(); + await ActivateAndProbeAsync(vm); + + var idx = vm.DisplayProviders.FindIndex(p => p.ProviderType == "openrouter"); + vm.SelectedProviderIndex = idx; + vm.ActivateSelectedProvider(); + Assert.Equal(ProviderManagerState.FixCredentials, vm.CurrentState.Value); + + // Submit a NEW (bad) key — the probe still fails. + vm.FixApiKey = "sk-bad-new-key"; + vm.SubmitFixCredentials(); + await vm.ProbeCompletion!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.NotNull(vm.ProbeResult.Value); + Assert.False(vm.ProbeResult.Value!.Success); + // The failed fix must NOT clobber the working secret: the file is byte-for-byte unchanged and + // the bad key was never written. + Assert.Equal(secretsBefore, File.ReadAllText(_paths.SecretsPath)); + Assert.DoesNotContain("sk-bad-new-key", File.ReadAllText(_paths.SecretsPath), StringComparison.Ordinal); + } + [Fact] public async Task Details_RemoveAction_TransitionsToRemoveConfirm() { @@ -839,13 +924,35 @@ public void GoBack_FromFixCredentials_ReturnsToList() } [Fact] - public void GoBack_FromList_ShutdownSignal() + public void GoBack_FromList_DoesNotNavigateWhenStandalone() + { + // Standalone `netclaw provider` host: IsEmbeddedInConfig stays false (no + // EmbeddedConfigHostMarker in DI). Backing out past the root must NOT navigate to /config + // (not registered standalone); it exits the app. The previous code both navigated and + // Shutdown(), which in the embedded host dropped the queued nav and quit the config app. + using var vm = CreateViewModel(); + Assert.False(vm.IsEmbeddedInConfig); + vm.CurrentState.Value = ProviderManagerState.List; + string? route = null; + vm.RouteRequested = r => route = r; + + vm.GoBack(); + + Assert.Null(route); + } + + [Fact] + public void GoBack_FromList_NavigatesToConfigWhenEmbedded() { using var vm = CreateViewModel(); + vm.IsEmbeddedInConfig = true; vm.CurrentState.Value = ProviderManagerState.List; - // GoBack from list should call Shutdown (which we can't easily test without a host, - // but we can verify it doesn't crash) + string? route = null; + vm.RouteRequested = r => route = r; + vm.GoBack(); + + Assert.Equal("/config", route); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs new file mode 100644 index 000000000..81aea991f --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Sections/ConfigEditorSessionTests.cs @@ -0,0 +1,193 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Sections; + +public sealed class ConfigEditorSessionTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + private readonly NetclawPaths _paths; + + public ConfigEditorSessionTests() + { + _paths = new NetclawPaths(_dir.Path); + _paths.EnsureDirectoriesExist(); + } + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void Save_AppliesFieldActionsAndPreservesSiblings() + { + File.WriteAllText(_paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Daemon": { + "ExposureMode": "reverse-proxy", + "Host": "10.0.0.5", + "Port": 5299 + }, + "Security": { + "DeploymentPosture": "Team" + } + } + """); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + FieldActions: + [ + new SectionFieldAction("Daemon.ExposureMode", SectionFieldActionKind.Set, "local"), + new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Delete) + ])); + + session.Save(); + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var exposureMode)); + Assert.Equal("local", exposureMode); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Daemon.Port", out var port)); + Assert.Equal(5299L, port); + Assert.False(ConfigFileHelper.TryGetPathValue(config, "Daemon.Host", out _)); + Assert.True(ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var posture)); + Assert.Equal("Team", posture); + } + + [Fact] + public void Save_AppliesSecretActionsAndPreservesUnrelatedSecrets() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Providers": { + "openai": { + "ApiKey": "stored-provider-key" + } + }, + "Slack": { + "BotToken": "stored-slack-token" + } + } + """); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Providers.openai.ApiKey", SectionSecretActionKind.Delete), + new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ])); + + session.Save(); + + var serializedSecrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("new-brave-key", serializedSecrets, StringComparison.Ordinal); + Assert.DoesNotContain("***REDACTED***", serializedSecrets, StringComparison.Ordinal); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.False(ConfigFileHelper.TryGetPathValue(secrets, "Providers.openai.ApiKey", out _)); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveKey)); + Assert.Equal("new-brave-key", ConfigFileHelper.DecryptIfEncrypted(_paths, braveKey?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var slackToken)); + Assert.Equal("stored-slack-token", ConfigFileHelper.DecryptIfEncrypted(_paths, slackToken?.ToString())); + } + + [Fact] + public void Save_SecretSetNormalizesColonPathAndRemovesLiteralCollision() + { + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Search": { + "BraveApiKey": "old-brave-key", + "OtherSecret": "keep-search" + }, + "Search:BraveApiKey": "literal-collision", + "Slack": { + "BotToken": "stored-slack-token" + } + } + """); + + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Search:BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ])); + + session.Save(); + + var serializedSecrets = File.ReadAllText(_paths.SecretsPath); + Assert.DoesNotContain("\"Search:BraveApiKey\"", serializedSecrets, StringComparison.Ordinal); + Assert.DoesNotContain("new-brave-key", serializedSecrets, StringComparison.Ordinal); + + var secrets = ConfigFileHelper.LoadJsonDict(_paths.SecretsPath); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.BraveApiKey", out var braveKey)); + Assert.Equal("new-brave-key", ConfigFileHelper.DecryptIfEncrypted(_paths, braveKey?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Search.OtherSecret", out var otherSecret)); + Assert.Equal("keep-search", ConfigFileHelper.DecryptIfEncrypted(_paths, otherSecret?.ToString())); + Assert.True(ConfigFileHelper.TryGetPathValue(secrets, "Slack.BotToken", out var slackToken)); + Assert.Equal("stored-slack-token", ConfigFileHelper.DecryptIfEncrypted(_paths, slackToken?.ToString())); + } + + [Fact] + public void Apply_SecretSetThroughScalarIntermediate_RejectsMalformedSecrets() + { + // secrets.json has "Search" as a scalar string, not an object. ConfigEditorSession + // deliberately refuses to traverse INTO a scalar at an intermediate path segment, rejecting + // the write rather than silently overwriting the scalar. (SecretsJsonUpdater, the + // JsonObject-based engine the wizard uses, instead overwrites.) This pins ConfigEditorSession's + // stricter behavior so any future consolidation onto that engine is a conscious change. + File.WriteAllText(_paths.SecretsPath, + """ + { + "configVersion": 1, + "Search": "not-an-object" + } + """); + + var session = new ConfigEditorSession(_paths); + + Assert.ThrowsAny(() => session.Apply(new SectionContribution( + SecretActions: + [ + new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Set, new SensitiveString("new-brave-key")) + ]))); + } + + [Fact] + public void Apply_StoresAndDeletesPassiveEditorState() + { + var session = new ConfigEditorSession(_paths); + session.Apply(new SectionContribution( + StateActions: + [ + new SectionEditorStateAction("exposure", "ReverseProxy.Host", SectionEditorStateActionKind.Set, "10.0.0.5") + ])); + + var state = new ConfigEditorStateStore(_paths); + Assert.True(state.TryGetValue("exposure", "ReverseProxy.Host", out var storedHost)); + Assert.Equal("10.0.0.5", storedHost); + + session.Apply(new SectionContribution( + StateActions: + [ + new SectionEditorStateAction("exposure", "ReverseProxy.Host", SectionEditorStateActionKind.Delete) + ])); + + Assert.False(state.TryGetValue("exposure", "ReverseProxy.Host", out _)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/SingleThreadSynchronizationContext.cs b/src/Netclaw.Cli.Tests/Tui/SingleThreadSynchronizationContext.cs new file mode 100644 index 000000000..5508127e8 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/SingleThreadSynchronizationContext.cs @@ -0,0 +1,90 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Concurrent; + +namespace Netclaw.Cli.Tests.Tui; + +/// +/// A backed by exactly ONE worker thread. Reproduces the +/// bounded-worker condition of xunit v3's MaxConcurrencySyncContext on a low-core CI runner — +/// the environment in which the netclaw config TUI deadlocked on macOS. +/// +/// When code posts a continuation to this context while the single worker is blocked +/// (sync-over-async, e.g. SomethingAsync().GetAwaiter().GetResult()), the continuation can +/// never run and the operation deadlocks. Code that awaits all the way through (never blocking the +/// worker) completes here without hanging. Tests run an operation on this context and assert it +/// finishes within a bounded timeout, so a regression back to a blocking bridge fails deterministically +/// instead of only flaking on a specific runner. +/// +internal sealed class SingleThreadSynchronizationContext : SynchronizationContext, IDisposable +{ + private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new(); + private readonly Thread _worker; + + public SingleThreadSynchronizationContext() + { + _worker = new Thread(Pump) { IsBackground = true, Name = "single-worker-sync-context" }; + _worker.Start(); + } + + public override void Post(SendOrPostCallback d, object? state) + => _queue.Add((d, state)); + + // Send (synchronous dispatch) is intentionally unsupported: a single-worker context cannot run a + // Send from its own worker without deadlocking, and the tests only ever Post. + public override void Send(SendOrPostCallback d, object? state) + => throw new NotSupportedException("SingleThreadSynchronizationContext does not support Send."); + + /// + /// Schedules an async method on the single worker (under this context) and returns a Task that + /// completes — observable from any thread — when the method finishes or faults. The method's awaits + /// resume on this same worker, so a sync-over-async block anywhere in its call chain self-deadlocks. + /// + public Task Run(Func asyncMethod) + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Post(async _ => + { + try + { + await asyncMethod(); + done.SetResult(); + } + catch (Exception ex) + { + done.SetException(ex); + } + }, null); + return done.Task; + } + + private void Pump() + { + SetSynchronizationContext(this); + foreach (var (callback, state) in _queue.GetConsumingEnumerable()) + { + try + { + callback(state); + } + catch (Exception ex) + { + // Keep the single worker alive if a posted continuation throws. The Run() entry point + // already funnels its scenario's exceptions to a TaskCompletionSource (so the test sees + // the real failure); letting one stray continuation kill the worker here would drain no + // further callbacks and make every awaiting test look like a generic deadlock instead. + _lastError ??= ex; + } + } + } + + /// The first exception thrown by a posted continuation, if any — for test diagnostics. + public Exception? LastError => _lastError; + + private volatile Exception? _lastError; + + public void Dispose() => _queue.CompleteAdding(); +} diff --git a/src/Netclaw.Cli.Tests/Tui/StubFileSystemProvider.cs b/src/Netclaw.Cli.Tests/Tui/StubFileSystemProvider.cs new file mode 100644 index 000000000..8d3416b44 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/StubFileSystemProvider.cs @@ -0,0 +1,42 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina.Layout; + +namespace Netclaw.Cli.Tests.Tui; + +/// +/// In-memory for driving the directory picker deterministically +/// in headless tests without touching the real filesystem. +/// +internal sealed class StubFileSystemProvider : IFileSystemProvider +{ + private readonly Dictionary> _entries; + private readonly HashSet _existing; + + public StubFileSystemProvider( + IEnumerable? existingDirectories = null, + IReadOnlyDictionary>? entries = null) + { + _existing = new HashSet(existingDirectories ?? [], StringComparer.Ordinal); + _entries = entries is null + ? new Dictionary>(StringComparer.Ordinal) + : new Dictionary>(entries, StringComparer.Ordinal); + + // A directory we can enumerate necessarily exists. + foreach (var key in _entries.Keys) + _existing.Add(key); + } + + public IReadOnlyList GetEntries(string directoryPath) + => _entries.TryGetValue(directoryPath, out var entries) ? entries : []; + + public bool DirectoryExists(string path) => _existing.Contains(path); + + public string? GetParentDirectory(string path) => Path.GetDirectoryName(path); + + public static FileSystemEntry Dir(string fullPath) + => new(Path.GetFileName(fullPath.TrimEnd(Path.DirectorySeparatorChar)), fullPath, IsDirectory: true, null, null); +} diff --git a/src/Netclaw.Cli.Tests/Tui/VirtualInputSourcePasteExtensions.cs b/src/Netclaw.Cli.Tests/Tui/VirtualInputSourcePasteExtensions.cs new file mode 100644 index 000000000..ee8cb632a --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/VirtualInputSourcePasteExtensions.cs @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Reflection; +using System.Threading.Channels; +using Termina.Input; + +namespace Netclaw.Cli.Tests.Tui; + +internal static class VirtualInputSourcePasteExtensions +{ + private static readonly FieldInfo InputChannelField = typeof(VirtualInputSource) + .GetField("_inputChannel", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Termina VirtualInputSource no longer exposes the expected input channel."); + + public static void EnqueuePaste(this VirtualInputSource input, string content) + { + ArgumentNullException.ThrowIfNull(input); + + var channel = (Channel)InputChannelField.GetValue(input)!; + channel.Writer.TryWrite(new PasteEvent(content)); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/BrowserAutomationStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/BrowserAutomationStepViewModelTests.cs deleted file mode 100644 index e21b9d0d1..000000000 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/BrowserAutomationStepViewModelTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Cli.Tui; -using Netclaw.Cli.Tui.Wizard; -using Netclaw.Cli.Tui.Wizard.Steps; -using Netclaw.Configuration; -using Xunit; - -namespace Netclaw.Cli.Tests.Tui.Wizard; - -public sealed class BrowserAutomationStepViewModelTests : WizardStepTestBase -{ - - [Theory] - [InlineData(false, 1)] - [InlineData(true, 2)] - public void SubStepCount_MatchesEnabledState(bool enabled, int expected) - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = enabled; - Assert.Equal(expected, step.SubStepCount); - } - - [Fact] - public void TryAdvance_ReturnsFalse_WhenDisabled() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = false; - Assert.False(step.TryAdvance()); - } - - [Fact] - public void TryAdvance_AdvancesToBackendSelection_WhenEnabled() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = true; - Assert.True(step.TryAdvance()); - Assert.Equal(1, step.CurrentSubStep); - } - - [Fact] - public void TryGoBack_FromBackend_ReturnsToEnable() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = true; - step.TryAdvance(); // → sub-step 1 - - Assert.True(step.TryGoBack()); - Assert.Equal(0, step.CurrentSubStep); - } - - [Fact] - public void OnEnter_Back_ResumesAtLastSubStep() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = true; - step.TryAdvance(); // → sub-step 1 - - step.OnEnter(Context, NavigationDirection.Back); - Assert.Equal(1, step.CurrentSubStep); - } - - [Fact] - public void ContributeConfig_SetsBackend_WhenEnabled() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = true; - step.SelectedBackend = BrowserAutomationBackend.Playwright; - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.NotNull(builder.BrowserAutomation); - Assert.True(builder.BrowserAutomation!.Enabled); - Assert.Equal(BrowserAutomationBackend.Playwright, builder.BrowserAutomation.Backend); - } - - [Fact] - public void ContributeConfig_NoSection_WhenDisabled() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - step.Enabled = false; - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.Null(builder.BrowserAutomation); - } - - [Fact] - public void DefaultBackend_IsPlaywright() - { - using var step = new BrowserAutomationStepViewModel(false, "test"); - Assert.Equal(BrowserAutomationBackend.Playwright, step.SelectedBackend); - } -} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelPickerStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelPickerStepViewModelTests.cs index 511951078..4df0a2b7c 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelPickerStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelPickerStepViewModelTests.cs @@ -9,6 +9,7 @@ using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; using R3; +using Termina.Input; using Xunit; namespace Netclaw.Cli.Tests.Tui.Wizard; @@ -17,25 +18,26 @@ public sealed class ChannelPickerStepViewModelTests : WizardStepTestBase { private readonly FakeSlackProbe _fakeProbe = new(); private readonly FakeDiscordProbe _fakeDiscordProbe = new(); + private readonly FakeMattermostProbe _fakeMattermostProbe = new(); [Fact] public void StepId_IsChannelPicker() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); Assert.Equal("channel-picker", picker.StepId); } [Fact] public void IsApplicable_AlwaysTrue() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); Assert.True(picker.IsApplicable(Context)); } [Fact] public void PickerMode_TryAdvance_ReturnsFalse() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); Assert.True(picker.IsInPickerMode); @@ -45,7 +47,7 @@ public void PickerMode_TryAdvance_ReturnsFalse() [Fact] public void PickerMode_TryGoBack_ReturnsFalse() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); Assert.False(picker.TryGoBack()); @@ -54,7 +56,7 @@ public void PickerMode_TryGoBack_ReturnsFalse() [Fact] public void ToggleOn_EntersSubFlow() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(0); // Toggle Slack on @@ -67,7 +69,7 @@ public void ToggleOn_EntersSubFlow() [Fact] public void SubFlow_TryAdvance_DelegatesToChild() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(0); // Slack sub-flow starts at sub-step 1 (bot token) @@ -81,7 +83,7 @@ public void SubFlow_TryAdvance_DelegatesToChild() [Fact] public void SubFlow_Complete_ReturnsToPicker_WithSummary() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(0); // Slack sub-flow @@ -106,7 +108,7 @@ public void SubFlow_Complete_ReturnsToPicker_WithSummary() [Fact] public void SubFlow_TryGoBack_AtFirstSubStep_ReturnsToPicker() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(0); // Slack sub-flow at sub-step 1 @@ -122,7 +124,7 @@ public void SubFlow_TryGoBack_AtFirstSubStep_ReturnsToPicker() [Fact] public void SubFlow_TryGoBack_InMiddle_DelegatesToChild() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(0); // Slack sub-flow at sub-step 1 @@ -136,7 +138,7 @@ public void SubFlow_TryGoBack_InMiddle_DelegatesToChild() [Fact] public void ToggleOff_ClearsConfig() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Complete a full Slack sub-flow @@ -155,7 +157,7 @@ public void ToggleOff_ClearsConfig() [Fact] public void EditAdapter_ReEntersSubFlow() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Complete Slack sub-flow first @@ -171,7 +173,7 @@ public void EditAdapter_ReEntersSubFlow() [Fact] public void OnLeave_SetsAnyChatServicesEnabled() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Complete Slack sub-flow @@ -187,7 +189,7 @@ public void OnLeave_SetsAnyChatServicesEnabled() [Fact] public void OnLeave_NoneEnabled_AnyChatServicesDisabled() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.OnLeave(); @@ -198,7 +200,7 @@ public void OnLeave_NoneEnabled_AnyChatServicesDisabled() [Fact] public void OnEnter_Back_ResumesPickerMode() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Complete Slack sub-flow and leave @@ -216,7 +218,7 @@ public void OnEnter_Back_ResumesPickerMode() [Fact] public void ContributeConfig_DelegatesToAllAdapters() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Configure Slack with a bot token @@ -239,7 +241,7 @@ public void ContributeConfig_DelegatesToAllAdapters() [Fact] public void ContributeConfig_DisabledAdapters_DoNotPolluteConfig() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Neither Slack nor Discord enabled — ContributeConfig delegates to both adapters @@ -258,7 +260,7 @@ public void ContributeConfig_DisabledAdapters_DoNotPolluteConfig() [Fact] public void GetHelpText_PickerMode_ReturnsPickerHelp() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); Assert.Contains("channel", picker.GetHelpText(), StringComparison.OrdinalIgnoreCase); @@ -267,7 +269,7 @@ public void GetHelpText_PickerMode_ReturnsPickerHelp() [Fact] public void GetHelpText_SubFlowMode_DelegatesToChild() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(0); // Slack sub-flow @@ -279,7 +281,7 @@ public void GetHelpText_SubFlowMode_DelegatesToChild() [Fact] public void CancelSubFlow_OnEdit_PreservesEnabled() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); // Complete Slack sub-flow @@ -301,7 +303,7 @@ public void CancelSubFlow_OnEdit_PreservesEnabled() [Fact] public void Adapters_IncludeMattermost() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); Assert.Equal(3, picker.Adapters.Count); Assert.Contains(picker.Adapters, a => a.Type == ChannelType.Mattermost); @@ -311,7 +313,7 @@ public void Adapters_IncludeMattermost() [Fact] public void ToggleMattermost_EntersSubFlow_AndCompletes() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(2); // Toggle Mattermost on — enters sub-flow at server URL @@ -334,7 +336,7 @@ public void ToggleMattermost_EntersSubFlow_AndCompletes() [Fact] public void ContributeConfig_Mattermost_WritesMattermostSection() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); picker.OnEnter(Context, NavigationDirection.Forward); picker.ToggleAdapter(2); @@ -355,19 +357,23 @@ public void ContributeConfig_Mattermost_WritesMattermostSection() // ── Regression tests for subscription accumulation (#792) ── - private StepViewCallbacks CreateTestCallbacks(CompositeDisposable subs) => new() + private StepViewCallbacks CreateTestCallbacks( + CompositeDisposable subs, + Action? advanceStep = null, + Action? setStatusMessage = null) => new() { Subscriptions = subs, InvalidateContent = () => { }, InvalidateHelp = () => { }, - AdvanceStep = () => { }, + AdvanceStep = advanceStep ?? (() => { }), RequestRedraw = () => { }, + SetStatusMessage = setStatusMessage, }; [Fact] public void SubFlow_BuildContent_ClearsSubscriptionsOnReRender() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); var view = new ChannelPickerStepView(); using var subs = new CompositeDisposable(); var callbacks = CreateTestCallbacks(subs); @@ -388,7 +394,7 @@ public void SubFlow_BuildContent_ClearsSubscriptionsOnReRender() [Fact] public void SubFlow_BuildContent_ClearsSubscriptionsAcrossSubStepTransitions() { - using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe); + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); var view = new ChannelPickerStepView(); using var subs = new CompositeDisposable(); var callbacks = CreateTestCallbacks(subs); @@ -409,4 +415,56 @@ public void SubFlow_BuildContent_ClearsSubscriptionsAcrossSubStepTransitions() $"Subscriptions should not accumulate across sub-steps: " + $"bot token had {countAtBotToken}, app token has {subs.Count}"); } + + [Fact] + public void Picker_DoneRow_EnterAdvancesWithoutTogglingAdapter() + { + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe) + { + ShowDoneAction = false, + ShowDonePickerRow = true, + DonePickerRowLabel = "Done adding channels" + }; + var view = new ChannelPickerStepView(); + using var subs = new CompositeDisposable(); + var advanced = false; + var callbacks = CreateTestCallbacks(subs, advanceStep: () => advanced = true); + + picker.OnEnter(Context, NavigationDirection.Forward); + view.BuildContent(picker, callbacks); + picker.CursorIndex = picker.Adapters.Count; + + Assert.True(view.HandleKeyPress(new KeyPressed(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false)))); + + Assert.True(advanced); + Assert.False(picker.IsAdapterEnabled(ChannelType.Slack)); + Assert.False(picker.IsAdapterEnabled(ChannelType.Discord)); + Assert.False(picker.IsAdapterEnabled(ChannelType.Mattermost)); + } + + [Fact] + public void SubFlow_PastedSlackBotTokenSurvivesReRenderBeforeSubmit() + { + using var picker = new ChannelPickerStepViewModel(_fakeProbe, _fakeDiscordProbe, _fakeMattermostProbe); + var view = new ChannelPickerStepView(); + using var subs = new CompositeDisposable(); + var status = "not-cleared"; + var callbacks = CreateTestCallbacks( + subs, + advanceStep: () => picker.TryAdvance(), + setStatusMessage: message => status = message); + + picker.OnEnter(Context, NavigationDirection.Forward); + picker.ToggleAdapter(0); + var slack = (SlackStepViewModel)picker.ActiveAdapterVm!; + + view.BuildContent(picker, callbacks); + view.HandlePaste(new PasteEvent("xoxb-pasted-token")); + view.BuildContent(picker, callbacks); + view.HandleKeyPress(new KeyPressed(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false))); + + Assert.Equal(2, slack.CurrentSubStep); + Assert.Equal("xoxb-pasted-token", slack.BotToken); + Assert.Equal(string.Empty, status); + } } diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelsStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelsStepViewModelTests.cs deleted file mode 100644 index 7cd3897b4..000000000 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/ChannelsStepViewModelTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Actors.Channels; -using Netclaw.Cli.Tui; -using Netclaw.Cli.Tui.Wizard; -using Netclaw.Cli.Tui.Wizard.Steps; -using Netclaw.Configuration; -using Xunit; - -namespace Netclaw.Cli.Tests.Tui.Wizard; - -public sealed class ChannelsStepViewModelTests : WizardStepTestBase -{ - - [Fact] - public void IsApplicable_True_WhenChatServicesEnabled() - { - Context.AnyChatServicesEnabled = true; - using var step = new ChannelsStepViewModel(); - Assert.True(step.IsApplicable(Context)); - } - - [Fact] - public void IsApplicable_False_WhenNoChatServices() - { - Context.AnyChatServicesEnabled = false; - using var step = new ChannelsStepViewModel(); - Assert.False(step.IsApplicable(Context)); - } - - [Fact] - public void AllEntries_FlattensAcrossSources() - { - Context.ChannelEntries[ChannelType.Slack] = - [ - new ChannelEntry("#general", "C123", TrustAudience.Team), - new ChannelEntry("DMs", "dm", TrustAudience.Personal, isDmRow: true) - ]; - Context.ChannelEntries[ChannelType.Tui] = - [ - new ChannelEntry("#dev-chat", "123456", TrustAudience.Team) - ]; - - using var step = new ChannelsStepViewModel(); - step.OnEnter(Context, NavigationDirection.Forward); - - var all = step.AllEntries; - Assert.Equal(3, all.Count); - } - - [Fact] - public void AddEntry_AddsToCorrectSourceBucket() - { - using var step = new ChannelsStepViewModel(); - step.OnEnter(Context, NavigationDirection.Forward); - - step.AddEntry(ChannelType.Slack, new ChannelEntry("#random", "random", TrustAudience.Team)); - - Assert.Single(Context.ChannelEntries[ChannelType.Slack]); - Assert.Equal("#random", Context.ChannelEntries[ChannelType.Slack][0].DisplayName); - } - - [Fact] - public void RemoveEntry_RemovesFromCorrectBucket() - { - var entry = new ChannelEntry("#general", "C123", TrustAudience.Team); - Context.ChannelEntries[ChannelType.Slack] = [entry]; - - using var step = new ChannelsStepViewModel(); - step.OnEnter(Context, NavigationDirection.Forward); - - Assert.True(step.RemoveEntry(entry)); - Assert.Empty(Context.ChannelEntries[ChannelType.Slack]); - } - - [Fact] - public void GetSource_ReturnsCorrectSource() - { - var slackEntry = new ChannelEntry("#general", "C123", TrustAudience.Team); - var discordEntry = new ChannelEntry("#dev", "123", TrustAudience.Team); - Context.ChannelEntries[ChannelType.Slack] = [slackEntry]; - Context.ChannelEntries[ChannelType.Tui] = [discordEntry]; - - using var step = new ChannelsStepViewModel(); - step.OnEnter(Context, NavigationDirection.Forward); - - Assert.Equal(ChannelType.Slack, step.GetSource(slackEntry)); - Assert.Equal(ChannelType.Tui, step.GetSource(discordEntry)); - } - - [Fact] - public void GetPreferredAddSource_ReturnsOnlyConfiguredSource() - { - Context.ChannelEntries[ChannelType.Discord] = [new ChannelEntry("Discord DMs", "dm", TrustAudience.Team, true)]; - - using var step = new ChannelsStepViewModel(); - step.OnEnter(Context, NavigationDirection.Forward); - - Assert.Equal(ChannelType.Discord, step.GetPreferredAddSource()); - } - - [Fact] - public void GetPreferredAddSource_PrefersSlack_WhenMultipleSourcesExist() - { - Context.ChannelEntries[ChannelType.Discord] = [new ChannelEntry("Discord DMs", "dm", TrustAudience.Team, true)]; - Context.ChannelEntries[ChannelType.Slack] = [new ChannelEntry("DMs", "dm", TrustAudience.Team, true)]; - - using var step = new ChannelsStepViewModel(); - step.OnEnter(Context, NavigationDirection.Forward); - - Assert.Equal(ChannelType.Slack, step.GetPreferredAddSource()); - } -} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/DiscordStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/DiscordStepViewModelTests.cs index d0b76a464..625c33ce6 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/DiscordStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/DiscordStepViewModelTests.cs @@ -104,7 +104,10 @@ public void OnLeave_PopulatesChannelEntries_WhenEnabled() var entries = Context.ChannelEntries[ChannelType.Discord]; Assert.Equal(3, entries.Count); Assert.True(entries[0].IsDmRow); + // Team posture, no single allow-listed user → DMs and channels both default to Team. + Assert.Equal(TrustAudience.Team, entries[0].Audience); Assert.Equal("129847561203948576", entries[1].Id); + Assert.Equal(TrustAudience.Team, entries[1].Audience); } [Fact] @@ -115,7 +118,14 @@ public void ContributeConfig_Enabled_SetsDiscordSection() DiscordEnabled = true, AllowDirectMessages = true, ChannelIdsInput = "129847561203948576", - AllowedUserIdsInput = "130111223344556677" + AllowedUserIdsInput = "130111223344556677", + // Health check resolves the channel reference to its canonical id; ContributeConfig + // persists only resolved ids (here id == input, so the assertions are unchanged). + LastChannelResolution = new DiscordChannelResolutionResult( + true, + null, + [new ResolvedDiscordChannel("129847561203948576", "general", "Test Guild")], + []) }; step.OnEnter(Context, NavigationDirection.Forward); @@ -131,6 +141,42 @@ public void ContributeConfig_Enabled_SetsDiscordSection() Assert.Equal("130111223344556677", Assert.Single(builder.Discord.AllowedUserIds!)); } + [Fact] + public void ContributeConfig_PersistsResolvedId_NotTypedName_AndOmitsUnresolvedFromAudiences() + { + using var step = new DiscordStepViewModel(_fakeProbe) + { + DiscordEnabled = true, + ChannelIdsInput = "general, ghost-channel", + // The bot can see "general" → canonical id "129847561203948576"; "ghost-channel" is unresolved. + LastChannelResolution = new DiscordChannelResolutionResult( + false, + null, + [new ResolvedDiscordChannel("129847561203948576", "general", "Test Guild")], + ["ghost-channel"]) + }; + + step.OnEnter(Context, NavigationDirection.Forward); + step.OnLeave(); + + foreach (var entry in Context.ChannelEntries[ChannelType.Discord]) + entry.Audience = TrustAudience.Team; + + var builder = new WizardConfigBuilder(Context.Paths); + step.ContributeConfig(builder); + + Assert.NotNull(builder.Discord); + // The resolved channel persists by its canonical id, never the typed name... + Assert.Equal("129847561203948576", Assert.Single(builder.Discord!.AllowedChannelIds!)); + + var audiences = builder.Discord.ChannelAudiences; + Assert.NotNull(audiences); + Assert.True(audiences!.ContainsKey("129847561203948576")); + // ...and the unresolved channel NAME is NOT written as a dead ACL key the runtime can't match. + Assert.DoesNotContain("ghost-channel", audiences.Keys); + Assert.DoesNotContain("general", audiences.Keys); + } + [Fact] public async Task ContributeHealthChecks_MissingBotToken_Fails() { @@ -236,6 +282,81 @@ [new ResolvedDiscordChannel("129847561203948576", "general", "MyServer")], Assert.Equal("MyServer / #general", channelEntry.DisplayName); } + [Fact] + public async Task BackgroundChannelResolution_PublishesResult_AppliedOnLeaveWithoutRace() + { + _fakeProbe.NextResolutionResult = new DiscordChannelResolutionResult( + true, null, + [new ResolvedDiscordChannel("129847561203948576", "general", "MyServer")], + []); + _fakeProbe.ResolveGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Context.SelectedPosture = DeploymentPosture.Team; + using var step = new DiscordStepViewModel(_fakeProbe) + { + DiscordEnabled = true, + BotToken = "test-token", + ChannelIdsInput = "129847561203948576" + }; + step.OnEnter(Context, NavigationDirection.Forward); + + // Advance 0→1→2→3; the 2→3 transition kicks off the background channel-name prefetch. + Assert.True(step.TryAdvance()); + Assert.True(step.TryAdvance()); + Assert.True(step.TryAdvance()); + + var pending = step.PendingResolution; + Assert.NotNull(pending); + Assert.False(pending!.IsCompleted); // gated — still in flight, nothing published yet + Assert.Null(step.LastChannelResolution); + + // Release the probe and await the tracked task (no Task.Delay/polling). + _fakeProbe.ResolveGate.SetResult(); + await pending; + + Assert.NotNull(step.LastChannelResolution); + Assert.True(step.LastChannelResolution!.Success); + + // The loop thread owns ChannelEntries mutation; OnLeave applies the resolved display names. + step.OnLeave(); + var channelEntry = Context.ChannelEntries[ChannelType.Discord].First(e => !e.IsDmRow); + Assert.Equal("MyServer / #general", channelEntry.DisplayName); + } + + [Fact] + public async Task BackgroundChannelResolution_DisposedBeforeProbeReturns_DropsStaleResult() + { + _fakeProbe.NextResolutionResult = new DiscordChannelResolutionResult( + true, null, + [new ResolvedDiscordChannel("129847561203948576", "general", "MyServer")], + []); + _fakeProbe.ResolveGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Context.SelectedPosture = DeploymentPosture.Team; + var step = new DiscordStepViewModel(_fakeProbe) + { + DiscordEnabled = true, + BotToken = "test-token", + ChannelIdsInput = "129847561203948576" + }; + step.OnEnter(Context, NavigationDirection.Forward); + Assert.True(step.TryAdvance()); + Assert.True(step.TryAdvance()); + Assert.True(step.TryAdvance()); + + var pending = step.PendingResolution; + Assert.NotNull(pending); + + // User abandons the step before the probe returns; Dispose cancels the prefetch. + step.Dispose(); + + // Probe completes after cancellation — the token guard must drop the stale result. + _fakeProbe.ResolveGate.SetResult(); + await pending!; + + Assert.Null(step.LastChannelResolution); + } + [Fact] public async Task ContributeHealthChecks_PartialResolution_ReportsUnresolved() { diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/ExposureModeStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/ExposureModeStepViewModelTests.cs index a3e7288b6..9156b8bba 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/ExposureModeStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/ExposureModeStepViewModelTests.cs @@ -11,12 +11,34 @@ using Netclaw.Cli.Tui.Wizard; using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Configuration; +using Netclaw.Providers; using Xunit; namespace Netclaw.Cli.Tests.Tui.Wizard; public sealed class ExposureModeStepViewModelTests : WizardStepTestBase { + [Fact] + public void Prefill_from_unparseable_exposure_mode_falls_back_to_local_without_throwing() + { + // A migrated/hand-edited config with an unsupported ExposureMode must not crash wizard + // prefill; it degrades to the most restrictive Local (local-only) default. + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = () => { }, + ExistingConfig = new Dictionary + { + ["Daemon"] = new Dictionary { ["ExposureMode"] = "WormHole" }, + }, + }; + using var step = new ExposureModeStepViewModel(); + + step.OnEnter(context, NavigationDirection.Forward); + + Assert.Equal(ExposureMode.Local, step.SelectedMode); + } // ── ContributeConfig ────────────────────────────────────────────────────── diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/ExternalSkillsStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/ExternalSkillsStepViewModelTests.cs deleted file mode 100644 index 6049ad93d..000000000 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/ExternalSkillsStepViewModelTests.cs +++ /dev/null @@ -1,192 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Cli.Tui.Wizard; -using Netclaw.Cli.Tui.Wizard.Steps; -using Netclaw.Configuration; -using Xunit; - -namespace Netclaw.Cli.Tests.Tui.Wizard; - -public sealed class ExternalSkillsStepViewModelTests : WizardStepTestBase -{ - private static readonly IReadOnlyList TwoSources = - [ - new("claude-code", "Claude Code", "/home/user/.claude/skills", true), - new("open-code", "Open Code", "/home/user/.open-code/skills", false) - ]; - - private static readonly IReadOnlyList OnlyClaudeCode = - [ - new("claude-code", "Claude Code", "/home/user/.claude/skills", true) - ]; - - private static readonly IReadOnlyList NoSources = []; - - [Fact] - public void IsApplicable_True_WhenSourcesDetected() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - Assert.True(step.IsApplicable(Context)); - } - - [Fact] - public void IsApplicable_False_WhenNoSourcesDetected() - { - using var step = new ExternalSkillsStepViewModel(NoSources); - Assert.False(step.IsApplicable(Context)); - } - - [Fact] - public void AllSourcesEnabledByDefault() - { - using var step = new ExternalSkillsStepViewModel(TwoSources); - Assert.True(step.IsSourceEnabled(0)); - Assert.True(step.IsSourceEnabled(1)); - } - - [Fact] - public void ToggleSource_FlipsEnabled() - { - using var step = new ExternalSkillsStepViewModel(TwoSources); - - step.ToggleSource(0); - Assert.False(step.IsSourceEnabled(0)); - Assert.True(step.IsSourceEnabled(1)); - - step.ToggleSource(0); - Assert.True(step.IsSourceEnabled(0)); - } - - [Theory] - [InlineData(null, 2)] - [InlineData("/opt/team/skills", 3)] - public void SubStepCount_MatchesCustomPath(string? customPath, int expected) - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - if (customPath is not null) step.CustomPath = customPath; - Assert.Equal(expected, step.SubStepCount); - } - - [Fact] - public void TryAdvance_FromChecklist_GoesToCustomPath() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.OnEnter(Context, NavigationDirection.Forward); - - Assert.True(step.TryAdvance()); - Assert.Equal(1, step.CurrentSubStep); - } - - [Fact] - public void TryAdvance_FromCustomPath_GoesToSymlink_WhenPathSet() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.OnEnter(Context, NavigationDirection.Forward); - step.TryAdvance(); // → sub-step 1 - step.CustomPath = "/opt/team/skills"; - - Assert.True(step.TryAdvance()); - Assert.Equal(2, step.CurrentSubStep); - } - - [Fact] - public void TryAdvance_FromCustomPath_Completes_WhenNoPath() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.OnEnter(Context, NavigationDirection.Forward); - step.TryAdvance(); // → sub-step 1 - - Assert.False(step.TryAdvance()); - } - - [Fact] - public void TryGoBack_FromCustomPath_ReturnsToChecklist() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.OnEnter(Context, NavigationDirection.Forward); - step.TryAdvance(); // → sub-step 1 - - Assert.True(step.TryGoBack()); - Assert.Equal(0, step.CurrentSubStep); - } - - [Fact] - public void TryGoBack_FromChecklist_ReturnsFalse() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.OnEnter(Context, NavigationDirection.Forward); - - Assert.False(step.TryGoBack()); - } - - [Fact] - public void OnEnter_Back_ResumesAtLastSubStep() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.OnEnter(Context, NavigationDirection.Forward); - step.TryAdvance(); // → sub-step 1 - - step.OnEnter(Context, NavigationDirection.Back); - Assert.Equal(1, step.CurrentSubStep); - } - - [Fact] - public void ContributeConfig_WritesEnabledSources() - { - using var step = new ExternalSkillsStepViewModel(TwoSources); - step.ToggleSource(1); // disable Open Code - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.NotNull(builder.ExternalSkillSources); - Assert.Equal(2, builder.ExternalSkillSources!.Count); - - var claude = builder.ExternalSkillSources[0]; - Assert.Equal("claude-code", claude.Name); - Assert.Equal("claude-code", claude.WellKnown); - Assert.True(claude.Enabled); - Assert.True(claude.AllowSymlinks); - - var openCode = builder.ExternalSkillSources[1]; - Assert.Equal("open-code", openCode.Name); - Assert.Equal("open-code", openCode.WellKnown); - Assert.False(openCode.Enabled); - Assert.False(openCode.AllowSymlinks); - } - - [Fact] - public void ContributeConfig_IncludesCustomPath() - { - using var step = new ExternalSkillsStepViewModel(OnlyClaudeCode); - step.CustomPath = "/opt/team/skills"; - step.CustomPathAllowSymlinks = true; - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.NotNull(builder.ExternalSkillSources); - Assert.Equal(2, builder.ExternalSkillSources!.Count); - - var custom = builder.ExternalSkillSources[1]; - Assert.Equal("custom", custom.Name); - Assert.Equal("/opt/team/skills", custom.Path); - Assert.Null(custom.WellKnown); - Assert.True(custom.Enabled); - Assert.True(custom.AllowSymlinks); - } - - [Fact] - public void ContributeConfig_NoSection_WhenNoSourcesAndNoCustomPath() - { - using var step = new ExternalSkillsStepViewModel(NoSources); - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.Null(builder.ExternalSkillSources); - } -} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs index 663778579..445aeeeef 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/FeatureSelectionStepViewModelTests.cs @@ -177,4 +177,35 @@ public void ContributeConfig_SkippedStep_ConfigDictionary_OmitsFeatureFlags() AssertNoEnabledKey(config, "Webhooks"); } + [Fact] + public void OnEnter_PrefillsFromExistingConfig() + { + using var step = new FeatureSelectionStepViewModel(); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + SelectedPosture = DeploymentPosture.Team, + ExistingConfig = new Dictionary + { + ["Memory"] = new Dictionary { ["Enabled"] = false }, + ["Search"] = new Dictionary { ["Enabled"] = true }, + ["SkillSync"] = new Dictionary { ["Enabled"] = false }, + ["Scheduling"] = new Dictionary { ["Enabled"] = true }, + ["SubAgents"] = new Dictionary { ["Enabled"] = false }, + ["Webhooks"] = new Dictionary { ["Enabled"] = true } + } + }; + + step.OnEnter(context, NavigationDirection.Forward); + + Assert.False(step.IsFeatureEnabled(0)); + Assert.True(step.IsFeatureEnabled(1)); + Assert.False(step.IsFeatureEnabled(2)); + Assert.True(step.IsFeatureEnabled(3)); + Assert.False(step.IsFeatureEnabled(4)); + Assert.True(step.IsFeatureEnabled(5)); + } + } diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/HealthCheckStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/HealthCheckStepViewModelTests.cs index d6cd2512e..38637ea64 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/HealthCheckStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/HealthCheckStepViewModelTests.cs @@ -92,7 +92,66 @@ await File.WriteAllTextAsync( Assert.Contains(expectedMessage, failure.Label, StringComparison.Ordinal); Assert.DoesNotContain("Daemon did not become ready", failure.Label, StringComparison.Ordinal); Assert.Contains(crashLogPath, failure.Label, StringComparison.Ordinal); - Assert.Equal("Setup complete with warnings. Run `netclaw daemon start` to begin.", context.StatusMessage.Value); + Assert.Equal( + "Setup complete with warnings. Run `netclaw daemon start`, then `netclaw chat`. Adjust settings with `netclaw config`.", + context.StatusMessage.Value); + Assert.False(step.Succeeded.Value); + } + + [Fact] + public async Task ResultsSnapshot_is_safe_to_read_while_results_are_mutated_concurrently() + { + var daemonManager = new DaemonManager(_paths, TimeProvider.System); + using var step = new HealthCheckStepViewModel( + daemonManager, daemonApi: null, navigationState: new ChatNavigationState()); + + var runner = new HealthCheckRunner(step.Results, () => { }); + using var cts = new CancellationTokenSource(); + var writer = Task.Factory.StartNew(() => + { + var writeCount = 0; + while (!cts.IsCancellationRequested) + { + runner.Add(new HealthCheckItem("probe", true)); + + if (++writeCount % 128 != 0) + continue; + + lock (step.Results) + { + if (step.Results.Count > 256) + step.Results.RemoveRange(0, step.Results.Count - 256); + } + + Thread.Yield(); + } + }, + TestContext.Current.CancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + try + { + // Wait (bounded) for the writer to start producing before the read loop, so the reads + // genuinely race concurrent Adds AND the final non-empty assertion is deterministic. On a + // contended CI scheduler the Task.Run writer may not run before cts.Cancel(), which left + // Results empty and failed the assertion intermittently. + Assert.True( + SpinWait.SpinUntil(() => step.ResultsSnapshot().Count > 0, TimeSpan.FromSeconds(10)), + "Writer task did not start adding results within 10s."); + + // Read snapshots while the writer mutates Results off-thread. Without the synchronized + // snapshot, ToArray throws "Collection was modified" during a concurrent Add. + for (var i = 0; i < 50_000; i++) + _ = step.ResultsSnapshot(); + } + finally + { + cts.Cancel(); + await writer.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + + Assert.NotEmpty(step.ResultsSnapshot()); } [Fact] @@ -172,6 +231,8 @@ public async Task RunWithOrchestrator_RunningDaemon_AppliesConfigViaWatcher_NotB daemonApi, navigationState: new ChatNavigationState(), timeProvider: TimeProvider.System); + string? launchedRoute = null; + step.Navigate = route => launchedRoute = route; using var exposureStep = new ExposureModeStepViewModel { SelectedMode = ExposureMode.Local }; using var context = new WizardContext { @@ -188,6 +249,9 @@ public async Task RunWithOrchestrator_RunningDaemon_AppliesConfigViaWatcher_NotB Assert.True(File.Exists(_paths.NetclawConfigPath)); Assert.Contains(step.Results, r => r.Label == "Daemon ready" && r.Passed == true); + // A clean bootstrap launches chat automatically — no second Enter required. + Assert.True(step.Succeeded.Value); + Assert.Equal("/chat", launchedRoute); // It confirmed readiness by polling health (not by spawning/POSTing). Assert.Contains("GET /api/health/ready", handler.Requests); // Watcher-owned: the wizard never stops the daemon and never triggers the restart itself. @@ -212,6 +276,103 @@ public void IsRestartedGeneration_BlocksStale_AllowsNewerOrDownDaemon() Assert.False(HealthCheckStepViewModel.IsRestartedGeneration(before: 1, current: null)); } + [Fact] + public async Task RunWithOrchestrator_SupervisorMarkerSetButNoSupervisor_SurfacesActionableReason() + { + // NETCLAW_CONTAINER_SUPERVISOR is set (IsExternallySupervised) but nothing actually + // starts the daemon — e.g. a derived image that kept the marker yet replaced the + // entrypoint with `sleep infinity`. DaemonManager.Start() defers to the (absent) + // supervisor and the daemon never comes up; the readiness check must surface that + // actionable reason, not the generic "Daemon did not become ready". + var daemonManager = new DaemonManager(_paths, TimeProvider.System, new FakeSupervisor(supervised: true)); + + using var step = new HealthCheckStepViewModel( + daemonManager, + // No readiness probe → the poll loop is skipped and we fall straight through to + // the timeout diagnostic, exercising the message path without a real wait. + daemonApi: null, + navigationState: new ChatNavigationState()); + var launched = false; + step.Navigate = _ => launched = true; + using var exposureStep = new ExposureModeStepViewModel { SelectedMode = ExposureMode.Local }; + using var context = new WizardContext + { + Paths = _paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = () => { } + }; + + step.OnEnter(context, NavigationDirection.Forward); + exposureStep.OnEnter(context, NavigationDirection.Forward); + using var orchestrator = new WizardOrchestrator([exposureStep, step], context); + + await step.RunWithOrchestrator(orchestrator); + + var failure = Assert.Single(step.Results, r => r.Passed is false); + Assert.Contains("container supervisor", failure.Label, StringComparison.Ordinal); + Assert.Contains("marker may be set without a supervisor present", failure.Label, StringComparison.Ordinal); + Assert.DoesNotContain("Daemon did not become ready", failure.Label, StringComparison.Ordinal); + Assert.False(step.Succeeded.Value); + // A failed health check must NOT auto-launch chat — it stays on the summary. + Assert.False(launched); + } + + [Fact] + public async Task RunWithOrchestrator_UnexpectedStepException_ReleasesWizardAndReportsError() + { + // An unexpected exception in a step's ContributeHealthChecksAsync must NOT leave the wizard + // wedged at IsRunning=true / IsComplete=false (GoNext gates on both being false, so the + // operator could neither advance, go back, nor see an error). + var daemonManager = new DaemonManager(_paths, TimeProvider.System); + using var step = new HealthCheckStepViewModel( + daemonManager, + daemonApi: null, + navigationState: new ChatNavigationState()); + using var throwingStep = new ThrowingHealthCheckStep(); + using var context = new WizardContext + { + Paths = _paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = () => { } + }; + step.OnEnter(context, NavigationDirection.Forward); + + using var orchestrator = new WizardOrchestrator([throwingStep, step], context); + + // Must not throw — the catch-all handles it. + await step.RunWithOrchestrator(orchestrator); + + Assert.False(step.IsRunning.Value); + Assert.True(step.IsComplete.Value); + Assert.Contains(step.Results, r => r.Passed == false && r.Label.Contains("Health check failed", StringComparison.OrdinalIgnoreCase)); + } + + // Minimal wizard step whose health-check contribution throws an unexpected (non-cancellation) + // exception, to prove the orchestrator run releases the wizard instead of wedging it. + private sealed class ThrowingHealthCheckStep : IWizardStepViewModel + { + public string StepId => "throwing"; + public string DisplayTitle => "Throwing"; + public bool IsApplicable(WizardContext context) => true; + public int CurrentSubStep => 0; + public int SubStepCount => 1; + public string GetHelpText() => string.Empty; + public bool TryAdvance() => false; + public bool TryGoBack() => false; + public void OnEnter(WizardContext context, NavigationDirection direction) { } + public void OnLeave() { } + public void ContributeConfig(WizardConfigBuilder builder) { } + public void ContributeSecrets(WizardSecretsBuilder builder) { } + public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) + => throw new InvalidOperationException("boom"); + public void Dispose() { } + } + + private sealed class FakeSupervisor(bool supervised) : IContainerSupervisor + { + public bool IsExternallySupervised => supervised; + } + private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs index d6e78fb01..cc87946d7 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/IdentityStepViewModelTests.cs @@ -15,10 +15,10 @@ public sealed class IdentityStepViewModelTests : WizardStepTestBase { [Fact] - public void SubStepCount_IsSix() + public void SubStepCount_IsFour() { using var step = new IdentityStepViewModel(); - Assert.Equal(6, step.SubStepCount); + Assert.Equal(4, step.SubStepCount); } [Fact] @@ -26,13 +26,13 @@ public void TryAdvance_ThroughAllSubSteps() { using var step = new IdentityStepViewModel(); - for (var i = 0; i < 5; i++) + for (var i = 0; i < step.SubStepCount - 1; i++) { Assert.True(step.TryAdvance()); Assert.Equal(i + 1, step.CurrentSubStep); } - // Sub-step 5 → complete + // Last sub-step → complete Assert.False(step.TryAdvance()); } @@ -56,11 +56,12 @@ public void TryGoBack_ThroughSubSteps() public void OnEnter_Back_ResumesAtLastSubStep() { using var step = new IdentityStepViewModel(); - for (var i = 0; i < 5; i++) + var last = step.SubStepCount - 1; + for (var i = 0; i < last; i++) step.TryAdvance(); step.OnEnter(Context, NavigationDirection.Back); - Assert.Equal(5, step.CurrentSubStep); + Assert.Equal(last, step.CurrentSubStep); } [Fact] @@ -71,7 +72,6 @@ public void ContributeConfig_SetsIdentitySection() step.CommunicationStyle = "Detailed & formal"; step.UserName = "Alice"; step.UserTimezone = "America/New_York"; - step.WebhookUrl = "https://hooks.example.com"; var builder = new WizardConfigBuilder(Context.Paths); step.ContributeConfig(builder); @@ -80,19 +80,11 @@ public void ContributeConfig_SetsIdentitySection() Assert.Equal("TestBot", builder.Identity!.AgentName); Assert.Equal("Detailed & formal", builder.Identity.CommunicationStyle); Assert.Equal("Alice", builder.Identity.UserName); - Assert.NotNull(builder.Notifications); - Assert.Equal("https://hooks.example.com", builder.Notifications!.WebhookUrl); - } - - [Fact] - public void ContributeConfig_NoWebhook_WhenEmpty() - { - using var step = new IdentityStepViewModel(); - step.WebhookUrl = null; - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); + Assert.Equal("America/New_York", builder.Identity.UserTimezone); + // Workspaces directory and notification webhooks are post-install settings + // owned by `netclaw config`; the init Identity step must not contribute them. + Assert.Null(builder.Workspaces); Assert.Null(builder.Notifications); } @@ -127,4 +119,33 @@ public void DefaultValues() Assert.Null(step.CommunicationStyle); Assert.Equal(TimeZoneInfo.Local.Id, step.UserTimezone); } + + [Fact] + public void OnEnter_PrefillsFromExistingConfig() + { + using var step = new IdentityStepViewModel(); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + ExistingConfig = new Dictionary + { + ["Identity"] = new Dictionary + { + ["AgentName"] = "ExistingBot", + ["CommunicationStyle"] = "Detailed & casual", + ["UserName"] = "Dana", + ["UserTimezone"] = "UTC" + } + } + }; + + step.OnEnter(context, NavigationDirection.Forward); + + Assert.Equal("ExistingBot", step.AgentName); + Assert.Equal("Detailed & casual", step.CommunicationStyle); + Assert.Equal("Dana", step.UserName); + Assert.Equal("UTC", step.UserTimezone); + } } diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/MattermostStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/MattermostStepViewModelTests.cs index 7ac2a8a3e..a09ea7ab0 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/MattermostStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/MattermostStepViewModelTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Actors.Channels; +using Netclaw.Cli.Mattermost; using Netclaw.Cli.Tui; using Netclaw.Cli.Tui.Wizard; using Netclaw.Cli.Tui.Wizard.Steps; @@ -14,13 +15,15 @@ namespace Netclaw.Cli.Tests.Tui.Wizard; public sealed class MattermostStepViewModelTests : WizardStepTestBase { + private readonly FakeMattermostProbe _probe = new(); + [Theory] [InlineData(false, false, 1)] [InlineData(true, false, 7)] [InlineData(true, true, 8)] public void SubStepCount_MatchesState(bool enabled, bool restrict, int expected) { - using var step = new MattermostStepViewModel(); + using var step = new MattermostStepViewModel(_probe); step.MattermostEnabled = enabled; if (restrict) step.RestrictToSpecificUsers = true; Assert.Equal(expected, step.SubStepCount); @@ -29,7 +32,7 @@ public void SubStepCount_MatchesState(bool enabled, bool restrict, int expected) [Fact] public void TryAdvance_ThroughAllSubSteps_NoRestrict() { - using var step = new MattermostStepViewModel(); + using var step = new MattermostStepViewModel(_probe); step.MattermostEnabled = true; Assert.True(step.TryAdvance()); // 0 -> 1 server URL @@ -58,7 +61,7 @@ public void TryAdvance_ThroughAllSubSteps_NoRestrict() [Fact] public void TryAdvance_WithRestrict_AdvancesThroughAllowedUserIds() { - using var step = new MattermostStepViewModel(); + using var step = new MattermostStepViewModel(_probe); step.MattermostEnabled = true; // Advance to sub-step 5 (user access choice) @@ -79,7 +82,7 @@ public void TryAdvance_WithRestrict_AdvancesThroughAllowedUserIds() [Fact] public void TryAdvance_AllowAnyone_ClearsAllowedUserIds() { - using var step = new MattermostStepViewModel(); + using var step = new MattermostStepViewModel(_probe); step.MattermostEnabled = true; step.AllowedUserIdsInput = "4xp9p3onpins8"; @@ -96,7 +99,7 @@ public void TryAdvance_AllowAnyone_ClearsAllowedUserIds() [Fact] public void TryGoBack_FromCallbackUrl_SkipsAllowedUserIds_WhenNotRestricting() { - using var step = new MattermostStepViewModel(); + using var step = new MattermostStepViewModel(_probe); step.MattermostEnabled = true; // Advance to callback URL without restricting @@ -113,7 +116,7 @@ public void TryGoBack_FromCallbackUrl_SkipsAllowedUserIds_WhenNotRestricting() public void OnLeave_PopulatesChannelEntries_WhenEnabled() { Context.SelectedPosture = DeploymentPosture.Team; - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", @@ -128,7 +131,10 @@ public void OnLeave_PopulatesChannelEntries_WhenEnabled() var entries = Context.ChannelEntries[ChannelType.Mattermost]; Assert.Equal(3, entries.Count); Assert.True(entries[0].IsDmRow); + // Team posture, no single allow-listed user → DMs and channels both default to Team. + Assert.Equal(TrustAudience.Team, entries[0].Audience); Assert.Equal("4xp9p3onpins8", entries[1].Id); + Assert.Equal(TrustAudience.Team, entries[1].Audience); } [Fact] @@ -137,7 +143,7 @@ public void OnLeave_RemovesChannelEntries_WhenDisabled() Context.ChannelEntries[ChannelType.Mattermost] = [new ChannelEntry("Mattermost:abc", "abc", TrustAudience.Team)]; - using var step = new MattermostStepViewModel { MattermostEnabled = false }; + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = false }; step.OnEnter(Context, NavigationDirection.Forward); step.OnLeave(); @@ -147,14 +153,21 @@ public void OnLeave_RemovesChannelEntries_WhenDisabled() [Fact] public void ContributeConfig_Enabled_SetsMattermostSection() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", CallbackUrl = "http://netclaw-host:5199/api/mattermost/actions", AllowDirectMessages = true, ChannelIdsInput = "4xp9p3onpins8", - AllowedUserIdsInput = "9rp7q1abcdef" + AllowedUserIdsInput = "9rp7q1abcdef", + // Health check resolves the channel reference to its canonical id; ContributeConfig + // persists only resolved ids (here id == input, so the assertions are unchanged). + LastChannelResolution = new MattermostChannelResolutionResult( + true, + null, + [new ResolvedMattermostChannel("4xp9p3onpins8", "general", "General")], + []) }; step.OnEnter(Context, NavigationDirection.Forward); @@ -172,10 +185,47 @@ public void ContributeConfig_Enabled_SetsMattermostSection() Assert.Equal("9rp7q1abcdef", Assert.Single(builder.Mattermost.AllowedUserIds!)); } + [Fact] + public void ContributeConfig_PersistsResolvedId_NotTypedName_AndOmitsUnresolvedFromAudiences() + { + using var step = new MattermostStepViewModel(_probe) + { + MattermostEnabled = true, + ServerUrl = "https://mm.example.com", + ChannelIdsInput = "general, ghost-channel", + // The bot can see "general" (slug) → canonical id "9rp7q1abcdef"; "ghost-channel" is unresolved. + LastChannelResolution = new MattermostChannelResolutionResult( + false, + null, + [new ResolvedMattermostChannel("9rp7q1abcdef", "general", "General")], + ["ghost-channel"]) + }; + + step.OnEnter(Context, NavigationDirection.Forward); + step.OnLeave(); + + foreach (var entry in Context.ChannelEntries[ChannelType.Mattermost]) + entry.Audience = TrustAudience.Team; + + var builder = new WizardConfigBuilder(Context.Paths); + step.ContributeConfig(builder); + + Assert.NotNull(builder.Mattermost); + // The resolved channel persists by its canonical id, never the typed slug... + Assert.Equal("9rp7q1abcdef", Assert.Single(builder.Mattermost!.AllowedChannelIds!)); + + var audiences = builder.Mattermost.ChannelAudiences; + Assert.NotNull(audiences); + Assert.True(audiences!.ContainsKey("9rp7q1abcdef")); + // ...and the unresolved channel NAME is NOT written as a dead ACL key the runtime can't match. + Assert.DoesNotContain("ghost-channel", audiences.Keys); + Assert.DoesNotContain("general", audiences.Keys); + } + [Fact] public void ContributeConfig_Disabled_DoesNotSetSection() { - using var step = new MattermostStepViewModel { MattermostEnabled = false }; + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = false }; var builder = new WizardConfigBuilder(Context.Paths); step.ContributeConfig(builder); @@ -186,7 +236,7 @@ public void ContributeConfig_Disabled_DoesNotSetSection() [Fact] public void ContributeConfig_BlankCallbackUrl_OmitsCallbackUrl() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", @@ -206,7 +256,7 @@ public void ContributeConfig_BlankCallbackUrl_OmitsCallbackUrl() [Fact] public void ContributeSecrets_Enabled_AddsBotToken() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", @@ -225,7 +275,7 @@ public void ContributeSecrets_Enabled_AddsBotToken() [Fact] public void ContributeSecrets_NoBotToken_WritesNothing() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", @@ -242,7 +292,7 @@ public void ContributeSecrets_NoBotToken_WritesNothing() [Fact] public async Task ContributeHealthChecks_Disabled_ReportsDisabled() { - using var step = new MattermostStepViewModel { MattermostEnabled = false }; + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = false }; var results = new List(); var runner = new HealthCheckRunner(results, () => { }); @@ -257,7 +307,7 @@ public async Task ContributeHealthChecks_Disabled_ReportsDisabled() [Fact] public async Task ContributeHealthChecks_MissingServerUrl_Fails() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = null, @@ -277,7 +327,7 @@ public async Task ContributeHealthChecks_MissingServerUrl_Fails() [Fact] public async Task ContributeHealthChecks_MissingBotToken_Fails() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", @@ -297,7 +347,7 @@ public async Task ContributeHealthChecks_MissingBotToken_Fails() [Fact] public async Task ContributeHealthChecks_FullyConfigured_Passes() { - using var step = new MattermostStepViewModel + using var step = new MattermostStepViewModel(_probe) { MattermostEnabled = true, ServerUrl = "https://mm.example.com", @@ -314,6 +364,34 @@ public async Task ContributeHealthChecks_FullyConfigured_Passes() Assert.Contains("mm.example.com", results[0].Label); } + [Fact] + public async Task ContributeHealthChecks_WithChannels_ResolvesAndPopulatesResolution() + { + _probe.NextResolutionResult = new MattermostChannelResolutionResult( + true, + null, + [new ResolvedMattermostChannel("9rp7q1abcdef", "general", "General")], + []); + + using var step = new MattermostStepViewModel(_probe) + { + MattermostEnabled = true, + ServerUrl = "https://mm.example.com", + BotToken = "mm-bot-token", + ChannelIdsInput = "general" + }; + + var results = new List(); + var runner = new HealthCheckRunner(results, () => { }); + + await step.ContributeHealthChecksAsync(runner, CancellationToken.None); + + Assert.Equal(1, _probe.ResolveCallCount); + Assert.NotNull(step.LastChannelResolution); + Assert.Equal("9rp7q1abcdef", Assert.Single(step.LastChannelResolution!.Resolved).ChannelId); + Assert.Contains(results, r => r.Passed == true && r.Label.Contains("channels resolved")); + } + [Fact] public void ParseChannelIds_ParsesCommaSeparated() { diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs new file mode 100644 index 000000000..0587b39f3 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/MenuRegistryAuditTests.cs @@ -0,0 +1,104 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Provider; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Wizard; + +public sealed class MenuRegistryAuditTests +{ + [Fact] + public void RegisteredLeafEditors_AreExpectedSet() + { + using var services = BuildServices(); + var registry = services.GetRequiredService(); + + var ids = registry.Editors.Select(e => e.SectionId).OrderBy(static x => x).ToArray(); + Assert.Equal(["exposure-mode", "feature-selection", "identity", "provider", "security-posture"], ids); + } + + [Fact] + public void RegisteredLeafEditors_DeclareDoctorChecks_OrJustifiedExemption() + { + using var services = BuildServices(); + var registry = services.GetRequiredService(); + + foreach (var editor in registry.Editors) + { + var hasChecks = editor.RelevantDoctorChecks.Count > 0; + var justification = SectionEditorAudit.GetDoctorCheckJustification(editor); + + Assert.True(hasChecks || !string.IsNullOrWhiteSpace(justification), + $"Section editor '{editor.SectionId}' must declare relevant doctor checks or a [NoDoctorChecks] justification."); + } + } + + [Fact] + public void MenuHiddenLeafEditors_AreLimitedToKnownInitOwnedExemptions() + { + using var services = BuildServices(); + var registry = services.GetRequiredService(); + + var hiddenEditors = registry.Editors.Where(e => !e.ShowInMenu).Select(e => e.SectionId).OrderBy(static x => x).ToArray(); + Assert.Equal(SectionEditorExemptions.ConfigSmokeExemptions.OrderBy(static x => x).ToArray(), hiddenEditors); + } + + [Fact] + public void RegisteredLeafEditors_HaveConcreteLeafTestClasses() + { + var expected = new Dictionary(StringComparer.Ordinal) + { + ["provider"] = nameof(ProviderSectionEditorTests), + ["identity"] = nameof(IdentitySectionEditorTests), + ["security-posture"] = nameof(SecurityPostureSectionEditorTests), + ["feature-selection"] = nameof(FeatureSelectionSectionEditorTests), + ["exposure-mode"] = nameof(ExposureModeSectionEditorTests) + }; + + using var services = BuildServices(); + var registry = services.GetRequiredService(); + var testTypeNames = typeof(MenuRegistryAuditTests).Assembly.GetTypes().Select(t => t.Name).ToHashSet(StringComparer.Ordinal); + + foreach (var editor in registry.Editors) + { + Assert.True(expected.TryGetValue(editor.SectionId, out var testTypeName), + $"Add a concrete section-editor test mapping for '{editor.SectionId}'."); + Assert.Contains(testTypeName, testTypeNames); + } + } + + private static ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + services.AddSingleton(new NetclawPaths()); + services.AddSingleton(ProviderCommand.CreateDefaultRegistry()); + services.AddSingleton(); + services + .AddSectionEditor() + .AddSectionEditor() + .AddSectionEditor() + .AddSectionEditor() + .AddSectionEditor(); + return services.BuildServiceProvider(); + } + + private sealed class FakeProviderProbe : IProviderProbe + { + public Task ProbeAsync(string providerType, string? endpoint, string? apiKey, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task ProbeAsync(ProviderEntry entry, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task ProbeAsync(string providerType, string? endpoint, string? credential, AuthMethod authMethod, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs index 87cd01a72..b4d1be77e 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/ProviderStepViewModelTests.cs @@ -157,6 +157,34 @@ public async Task ProbeProvider_ReportsFailure() Assert.Contains("Invalid API key", step.ProbeResult.Value.ErrorMessage); } + [Fact] + public async Task Superseded_probe_completion_does_not_cancel_the_replacement_probe() + { + var ct = TestContext.Current.CancellationToken; + using var step = new ProviderStepViewModel(_registry, _fakeProbe); + step.SelectedProviderType = "ollama"; + step.EndpointInput = "http://localhost:11434"; + _fakeProbe.Gate = new TaskCompletionSource(); + _fakeProbe.NextResult = new ProviderProbeResult(true, null, + [new DiscoveredModel { ModelId = new Netclaw.Configuration.ModelId("m") }]); + + step.StartProbe(); // probe A — blocks on the gate + var probeA = step.ProbeCompletion!; + step.StartProbe(); // cancels A, starts probe B (also blocks on the gate) + var probeB = step.ProbeCompletion!; + + // Probe A was cancelled by the second StartProbe; let its finally run. With the bug, that + // finally tore down the shared _probeCts field — which now holds probe B's live CTS. + await probeA.WaitAsync(TimeSpan.FromSeconds(5), ct); + + // Releasing the gate, probe B must complete SUCCESSFULLY: its CTS was not cancelled or + // disposed by the superseded probe's finally. + _fakeProbe.Gate.SetResult(); + await probeB.WaitAsync(TimeSpan.FromSeconds(5), ct); + + Assert.True(step.ProbeResult.Value!.Success); + } + [Fact] public void ContributeConfig_SetsProviderAndModel() { @@ -245,6 +273,10 @@ private sealed class FakeProviderProbe : IProviderProbe public string? LastProviderType { get; private set; } public string? LastApiKey { get; private set; } + // When set, the ProviderEntry probe blocks (observing the token) until completed — used to + // stage overlapping probes for the CTS-lifecycle race test. Null returns immediately. + public TaskCompletionSource? Gate { get; set; } + public Task ProbeAsync( string providerType, string? endpoint, string? apiKey, CancellationToken ct = default) @@ -254,12 +286,14 @@ public Task ProbeAsync( return Task.FromResult(NextResult); } - public Task ProbeAsync( + public async Task ProbeAsync( ProviderEntry entry, CancellationToken ct = default) { LastProviderType = entry.Type; LastApiKey = entry.ApiKey?.Value ?? entry.OAuthAccessToken?.Value; - return Task.FromResult(NextResult); + if (Gate is not null) + await Gate.Task.WaitAsync(ct); + return NextResult; } public Task ProbeAsync( diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SearchStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SearchStepViewModelTests.cs deleted file mode 100644 index 5d3457eca..000000000 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/SearchStepViewModelTests.cs +++ /dev/null @@ -1,115 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Cli.Tui; -using Netclaw.Cli.Tui.Wizard; -using Netclaw.Cli.Tui.Wizard.Steps; -using Netclaw.Configuration; -using Xunit; - -namespace Netclaw.Cli.Tests.Tui.Wizard; - -public sealed class SearchStepViewModelTests : WizardStepTestBase -{ - - [Fact] - public void DefaultBackend_IsDuckDuckGo() - { - using var step = new SearchStepViewModel(); - Assert.Equal(SearchBackend.DuckDuckGo, step.SelectedBackend); - } - - [Theory] - [InlineData(SearchBackend.DuckDuckGo, 1)] - [InlineData(SearchBackend.Brave, 2)] - [InlineData(SearchBackend.SearXng, 2)] - public void SubStepCount_MatchesBackend(SearchBackend backend, int expected) - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = backend; - Assert.Equal(expected, step.SubStepCount); - } - - [Fact] - public void TryAdvance_ReturnsFalse_ForDuckDuckGo() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.DuckDuckGo; - Assert.False(step.TryAdvance()); - } - - [Fact] - public void TryAdvance_AdvancesToCredentials_ForBrave() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.Brave; - Assert.True(step.TryAdvance()); - Assert.Equal(1, step.CurrentSubStep); - } - - [Fact] - public void TryGoBack_FromCredentials_ReturnsToBackendSelection() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.Brave; - step.TryAdvance(); // → sub-step 1 - - Assert.True(step.TryGoBack()); - Assert.Equal(0, step.CurrentSubStep); - } - - [Fact] - public void OnEnter_Back_ResumesAtLastSubStep() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.Brave; - step.TryAdvance(); // → sub-step 1 - - step.OnEnter(Context, NavigationDirection.Back); - Assert.Equal(1, step.CurrentSubStep); - } - - [Fact] - public void ContributeConfig_SetsBraveBackend() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.Brave; - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.NotNull(builder.Search); - Assert.Equal(SearchBackend.Brave, builder.Search!.Backend); - } - - [Fact] - public void ContributeSecrets_AddsBraveApiKey() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.Brave; - step.BraveApiKey = "BSA-test-key"; - - var builder = new WizardSecretsBuilder(Context.Paths); - step.ContributeSecrets(builder); - - // Secrets builder doesn't expose contents directly, but we can verify - // it doesn't throw. Full integration test covers file output. - } - - [Fact] - public void ContributeConfig_SetsSearXngEndpoint() - { - using var step = new SearchStepViewModel(); - step.SelectedBackend = SearchBackend.SearXng; - step.SearXngEndpoint = "http://searxng.local:8080"; - - var builder = new WizardConfigBuilder(Context.Paths); - step.ContributeConfig(builder); - - Assert.NotNull(builder.Search); - Assert.Equal(SearchBackend.SearXng, builder.Search!.Backend); - Assert.Equal("http://searxng.local:8080", builder.Search.SearXngEndpoint); - } -} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs new file mode 100644 index 000000000..24643d279 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorLeafTests.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Provider; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui.Wizard; + +public sealed class ProviderSectionEditorTests : SectionEditorTestBase +{ + [Fact] + public void BuildContribution_EnteredCredential_EmitsSensitiveSecretLeaf() + { + using var editor = CreateEditor(); + editor.SelectedProviderType = "openai"; + editor.SelectedModelId = "gpt-4.1"; + editor.ApiKeyInput = "sk-test"; + + var contribution = editor.BuildContribution(editor); + var action = Assert.Single(contribution.SecretActionsOrEmpty); + + Assert.Equal("Providers.openai.ApiKey", action.Path); + Assert.Equal(SectionSecretActionKind.Set, action.Action); + Assert.NotNull(action.Value); + Assert.Equal("sk-test", action.Value.Value); + } + + [Fact] + public void BuildContribution_BlankCredential_PreservesExistingSecret() + { + File.WriteAllText(Context.Paths.SecretsPath, """ + { "Providers": { "openai": { "ApiKey": "ENC:stored" } } } + """); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = ProviderCommand.CreateDefaultRegistry(), + RequestRedraw = () => { }, + ExistingConfig = new Dictionary + { + ["Models"] = new Dictionary + { + ["Main"] = new Dictionary { ["Provider"] = "openai", ["ModelId"] = "gpt-4.1" } + }, + ["Providers"] = new Dictionary + { + ["openai"] = new Dictionary { ["Type"] = "openai", ["AuthMethod"] = "ApiKey" } + } + } + }; + + using var editor = CreateEditor(); + editor.OnEnter(context, NavigationDirection.Forward); + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.SecretActionsOrEmpty, a => a.Action == SectionSecretActionKind.Preserve); + } +} + +public sealed class IdentitySectionEditorTests : SectionEditorTestBase +{ + [Fact] + public void BuildContribution_WritesSyntheticIdentityFields() + { + using var editor = CreateEditor(); + editor.AgentName = "Netclaw"; + editor.UserTimezone = "UTC"; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Identity.AgentName"); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Identity.UserTimezone"); + + // Workspaces directory and notification webhooks are post-install settings + // owned by `netclaw config`; the init Identity editor must not contribute them. + Assert.DoesNotContain(contribution.FieldActionsOrEmpty, a => a.Path == "Workspaces.Directory"); + Assert.DoesNotContain(contribution.FieldActionsOrEmpty, a => a.Path == "Notifications"); + } +} + +public sealed class SecurityPostureSectionEditorTests : SectionEditorTestBase +{ + [Fact] + public void BuildContribution_PersonalPosture_PreservesShellApprovalDefaults() + { + using var editor = CreateEditor(); + editor.SelectedPosture = DeploymentPosture.Personal; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Security.DeploymentPosture" && Equals(a.Value, "Personal")); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Tools"); + } +} + +public sealed class FeatureSelectionSectionEditorTests : SectionEditorTestBase +{ + [Fact] + public void BuildContribution_EmitsEnabledFlagsForAllFeatureLeaves() + { + using var editor = CreateEditor(); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + SelectedPosture = DeploymentPosture.Team + }; + editor.OnEnter(context, NavigationDirection.Forward); + + var contribution = editor.BuildContribution(editor); + + Assert.Equal(6, contribution.FieldActionsOrEmpty.Count); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Memory.Enabled"); + Assert.Contains(contribution.FieldActionsOrEmpty, a => a.Path == "Webhooks.Enabled"); + } +} + +public sealed class ExposureModeSectionEditorTests : SectionEditorTestBase +{ + [Fact] + public void BuildContribution_ReverseProxy_EmitsExistingDaemonShapeFields() + { + using var editor = CreateEditor(); + editor.SelectedMode = ExposureMode.ReverseProxy; + editor.Host = "10.0.0.5"; + editor.TrustedProxies = ["10.0.0.0/24"]; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.ExposureMode" && Equals(a.Value, "reverse-proxy")); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.Host" && Equals(a.Value, "10.0.0.5")); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.TrustedProxies" && Assert.IsType(a.Value).SequenceEqual(["10.0.0.0/24"])); + } + + [Fact] + public void BuildContribution_Local_StashesInactiveReverseProxyFields() + { + using var editor = CreateEditor(); + editor.SelectedMode = ExposureMode.Local; + editor.Host = "10.0.0.5"; + editor.TrustedProxies = ["10.0.0.0/24"]; + + var contribution = editor.BuildContribution(editor); + + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.ExposureMode" && Equals(a.Value, "local")); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.Host" && a.Action == SectionFieldActionKind.Delete); + Assert.Contains(contribution.FieldActionsOrEmpty, + a => a.Path == "Daemon.TrustedProxies" && a.Action == SectionFieldActionKind.Delete); + Assert.Contains(contribution.StateActionsOrEmpty, + a => a is { SectionId: WizardStepIds.ExposureMode, Key: "ReverseProxy.Host", Action: SectionEditorStateActionKind.Set } + && Equals(a.Value, "10.0.0.5")); + Assert.Contains(contribution.StateActionsOrEmpty, + a => a is { SectionId: WizardStepIds.ExposureMode, Key: "ReverseProxy.TrustedProxies", Action: SectionEditorStateActionKind.Set } + && Assert.IsType(a.Value).SequenceEqual(["10.0.0.0/24"])); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs new file mode 100644 index 000000000..7b2a3ab56 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SectionEditorTestBase.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; + +namespace Netclaw.Cli.Tests.Tui.Wizard; + +public abstract class SectionEditorTestBase : WizardStepTestBase + where TEditor : class, IWizardStepViewModel, ISectionEditor +{ + protected ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + services.AddSingleton(Context.Paths); + services.AddSingleton(new ProviderDescriptorRegistry([])); + services.AddSingleton(); + services.AddTransient(); + return services.BuildServiceProvider(); + } + + protected TEditor CreateEditor() + { + using var services = BuildServices(); + return ActivatorUtilities.CreateInstance(services); + } + + private sealed class FakeProviderProbe : IProviderProbe + { + public Task ProbeAsync(string providerType, string? endpoint, string? apiKey, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task ProbeAsync(ProviderEntry entry, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + + public Task ProbeAsync(string providerType, string? endpoint, string? credential, AuthMethod authMethod, CancellationToken ct = default) + => Task.FromResult(new ProviderProbeResult(true, null, [])); + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/SlackStepViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/SlackStepViewModelTests.cs index 69985bc47..091d2cca1 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/SlackStepViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/SlackStepViewModelTests.cs @@ -176,7 +176,32 @@ public void OnLeave_PopulatesChannelEntries_WhenEnabled() var entries = Context.ChannelEntries[ChannelType.Slack]; Assert.Equal(3, entries.Count); // DMs + #general + #dev Assert.True(entries[0].IsDmRow); + // Team posture, no single allow-listed user → DMs and channels both default to Team. + Assert.Equal(TrustAudience.Team, entries[0].Audience); Assert.Equal("#general", entries[1].DisplayName); + Assert.Equal(TrustAudience.Team, entries[1].Audience); + } + + [Fact] + public void OnLeave_PersonalPosture_DmIsPersonalChannelsAreTeam() + { + // Pins the posture→audience default mapping the wizard shares with the config editor: + // Personal posture keeps the DM row Personal (most private) while a regular channel + // defaults to Team. These two rules differ only outside Public/Team, so Personal is the + // case that distinguishes them. + Context.SelectedPosture = DeploymentPosture.Personal; + using var step = new SlackStepViewModel(_fakeProbe); + step.SlackEnabled = true; + step.AllowDirectMessages = true; + step.ChannelNamesInput = "general"; + step.OnEnter(Context, NavigationDirection.Forward); + + step.OnLeave(); + + var entries = Context.ChannelEntries[ChannelType.Slack]; + Assert.True(entries[0].IsDmRow); + Assert.Equal(TrustAudience.Personal, entries[0].Audience); + Assert.Equal(TrustAudience.Team, entries[1].Audience); } [Fact] @@ -252,6 +277,38 @@ [new ResolvedSlackChannel("netclaw-supervisor", "C0B62888XAL")], Assert.Equal("personal", audience.Value); } + [Fact] + public void ContributeConfig_OmitsUnresolvedChannelNameFromAudiences_KeepsResolvedById() + { + using var step = new SlackStepViewModel(_fakeProbe) + { + SlackEnabled = true, + ChannelNamesInput = "general, ghost-channel", + LastChannelResolution = new SlackChannelResolutionResult( + false, + null, + [new ResolvedSlackChannel("general", "C01GENERAL")], + ["ghost-channel"]) + }; + + step.OnEnter(Context, NavigationDirection.Forward); + step.OnLeave(); + + foreach (var entry in Context.ChannelEntries[ChannelType.Slack]) + entry.Audience = TrustAudience.Team; + + var builder = new WizardConfigBuilder(Context.Paths); + step.ContributeConfig(builder); + + Assert.NotNull(builder.Slack); + var audiences = builder.Slack!.ChannelAudiences; + Assert.NotNull(audiences); + // The resolved channel is keyed by its canonical Slack ID... + Assert.True(audiences!.ContainsKey("C01GENERAL")); + // ...and the unresolved channel NAME is NOT written as a dead ACL key the runtime can't match. + Assert.DoesNotContain("ghost-channel", audiences.Keys); + } + [Fact] public void ContributeSecrets_AddsTokens() { diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs index 42b054ed0..eb7d3cb94 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigBuilderTests.cs @@ -361,52 +361,46 @@ public void BuildConfigDictionary_OmitsExternalSkills_WhenNull() [Fact] public void WriteSecretsFile_ExistingSection_OverwritesContributedSecretsAndPreservesUnrelatedValues() { - var priorProtector = SensitiveStringTypeConverter.Protector; + // WizardSecretsBuilder.WriteSecretsFile now derives its protector from its paths, so this + // test no longer touches the process-wide SensitiveStringTypeConverter.Protector static at + // all — every encrypt/decrypt below uses this explicit, paths-bound protector. var protector = SecretsProtection.CreateProtector(Context.Paths); - SensitiveStringTypeConverter.Protector = protector; - try - { - SecretsFileWriter.Write(Context.Paths.SecretsPath, - """ - { - "Discord": { - "BotToken": "old-token", - "OtherSecret": "keep-discord" - }, - "Discord:BotToken": "literal-collision", - "Search": { - "BraveApiKey": "keep-search" - } - } - """, - protector); - - var builder = new WizardSecretsBuilder(Context.Paths); - builder.AddSection("Discord", new Dictionary + SecretsFileWriter.Write(Context.Paths.SecretsPath, + """ { - ["BotToken"] = "new-token" - }); + "Discord": { + "BotToken": "old-token", + "OtherSecret": "keep-discord" + }, + "Discord:BotToken": "literal-collision", + "Search": { + "BraveApiKey": "keep-search" + } + } + """, + protector); + + var builder = new WizardSecretsBuilder(Context.Paths); + builder.AddSection("Discord", new Dictionary + { + ["BotToken"] = "new-token" + }); - builder.WriteSecretsFile(); + builder.WriteSecretsFile(); - var encryptedJson = File.ReadAllText(Context.Paths.SecretsPath); - Assert.DoesNotContain("\"Discord:BotToken\"", encryptedJson, StringComparison.Ordinal); + var encryptedJson = File.ReadAllText(Context.Paths.SecretsPath); + Assert.DoesNotContain("\"Discord:BotToken\"", encryptedJson, StringComparison.Ordinal); - var decryptedJson = SecretsFileWriter.DecryptJsonLeaves(encryptedJson, protector); - using var document = JsonDocument.Parse(decryptedJson); + var decryptedJson = SecretsFileWriter.DecryptJsonLeaves(encryptedJson, protector); + using var document = JsonDocument.Parse(decryptedJson); - var root = document.RootElement; - var discord = root.GetProperty("Discord"); - Assert.Equal("new-token", discord.GetProperty("BotToken").GetString()); - Assert.Equal("keep-discord", discord.GetProperty("OtherSecret").GetString()); - Assert.Equal("keep-search", root.GetProperty("Search").GetProperty("BraveApiKey").GetString()); - Assert.False(root.TryGetProperty("Discord:BotToken", out _)); - } - finally - { - SensitiveStringTypeConverter.Protector = priorProtector; - } + var root = document.RootElement; + var discord = root.GetProperty("Discord"); + Assert.Equal("new-token", discord.GetProperty("BotToken").GetString()); + Assert.Equal("keep-discord", discord.GetProperty("OtherSecret").GetString()); + Assert.Equal("keep-search", root.GetProperty("Search").GetProperty("BraveApiKey").GetString()); + Assert.False(root.TryGetProperty("Discord:BotToken", out _)); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs index b70948417..38e5cd650 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardConfigScenarioTests.cs @@ -32,15 +32,12 @@ public void PersonalPosture_MinimalSetup_DoesNotDisableFeatures() { var steps = BuildCoreSteps(); EnterAndConfigurePosture(steps, DeploymentPosture.Personal); - ConfigureSearch(steps, SearchBackend.Brave); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); ConfigureIdentity(steps, "Netclaw", "America/Chicago"); var config = AssembleConfig(steps); AssertPosture(config, "Personal"); AssertShellMode(config, "HostAllowed"); - AssertSearchBackend(config, "brave"); Assert.False(config.ContainsKey("Daemon")); // The bug: Personal posture must not inject Enabled:false for any feature @@ -53,8 +50,6 @@ public void TeamPosture_AllFeaturesEnabled() var steps = BuildCoreSteps(); EnterAndConfigurePosture(steps, DeploymentPosture.Team); EnterFeatureSelection(steps); - ConfigureSearch(steps, SearchBackend.DuckDuckGo); - ConfigureExposure(steps, ExposureMode.TailscaleServe, webhooks: true); ConfigureIdentity(steps, "TeamBot", "UTC"); var config = AssembleConfig(steps); @@ -68,8 +63,7 @@ public void TeamPosture_AllFeaturesEnabled() AssertSectionEnabled(config, "SubAgents", true); AssertSectionEnabled(config, "Webhooks", true); - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-serve", daemon["ExposureMode"]); + Assert.False(config.ContainsKey("Daemon")); } [Fact] @@ -85,8 +79,6 @@ public void PublicPosture_SelectiveFeatures() featureStep.ToggleFeature(1); // Search featureStep.OnLeave(); - ConfigureSearch(steps, SearchBackend.SearXng, searXngEndpoint: "https://search.example.com"); - ConfigureExposure(steps, ExposureMode.TailscaleFunnel, webhooks: false); ConfigureIdentity(steps, "PublicBot", "Europe/London"); var config = AssembleConfig(steps); @@ -99,12 +91,7 @@ public void PublicPosture_SelectiveFeatures() AssertSectionEnabled(config, "SubAgents", false); AssertSectionEnabled(config, "Webhooks", false); - var search = GetSection(config, "Search"); - Assert.Equal("searxng", search["Backend"]); - Assert.Equal("https://search.example.com", search["SearXngEndpoint"]); - - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-funnel", daemon["ExposureMode"]); + Assert.False(config.ContainsKey("Daemon")); } [Fact] @@ -120,8 +107,6 @@ public void TeamPosture_SelectivelyDisabledFeatures() featureStep.ToggleFeature(3); // Scheduling OFF featureStep.OnLeave(); - ConfigureSearch(steps, SearchBackend.Brave); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); ConfigureIdentity(steps, "Netclaw", "America/New_York"); var config = AssembleConfig(steps); @@ -135,61 +120,78 @@ public void TeamPosture_SelectivelyDisabledFeatures() } [Fact] - public void PersonalPosture_WithIdentityAndWorkspaces_ConfigMatchesChoices() + public void PersonalPosture_WithIdentity_ConfigMatchesChoices() { var steps = BuildCoreSteps(); EnterAndConfigurePosture(steps, DeploymentPosture.Personal); - ConfigureSearch(steps, SearchBackend.DuckDuckGo); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); - var identityStep = GetStep(steps); identityStep.AgentName = "Jarvis"; identityStep.UserName = "Aaron"; identityStep.UserTimezone = "America/Chicago"; - identityStep.WorkspacesDirectory = "~/projects"; var config = AssembleConfig(steps); - // Identity is written to separate files, not the config dict. - // Workspaces IS in the config dict. - var workspaces = GetSection(config, "Workspaces"); - Assert.Equal("~/projects", workspaces["Directory"]); + // Identity is written to separate files, not the config dict. The init wizard + // no longer collects a workspaces directory — that is a post-install setting + // owned by `netclaw config`, so the assembled config must not pin one. + Assert.False(config.ContainsKey("Workspaces")); AssertNoDisabledFeatureFlags(config); } [Fact] - public void PersonalPosture_ExposureModeLocal_NoDaemonSection() + public void ExistingConfig_PostureEdit_PreservesUnrelatedSections() { - var steps = BuildCoreSteps(); - EnterAndConfigurePosture(steps, DeploymentPosture.Personal); - ConfigureSearch(steps, SearchBackend.DuckDuckGo); - ConfigureExposure(steps, ExposureMode.Local, webhooks: false); - ConfigureIdentity(steps, "Netclaw", "UTC"); + File.WriteAllText(Context.Paths.NetclawConfigPath, + """ + { + "configVersion": 1, + "Slack": { "Enabled": true, "SocketMode": true }, + "Daemon": { "ExposureMode": "reverse-proxy", "Host": "10.0.0.2", "TrustedProxies": ["10.0.0.0/24"] }, + "Search": { "Backend": "duckduckgo" } + } + """); - var config = AssembleConfig(steps); + using var context = new WizardContext + { + Paths = Context.Paths, + Registry = Context.Registry, + RequestRedraw = () => { }, + ExistingConfig = Netclaw.Cli.Config.ConfigFileHelper.LoadJsonDict(Context.Paths.NetclawConfigPath), + SelectedPosture = DeploymentPosture.Personal + }; + + var steps = new List + { + new SecurityPostureStepViewModel { SelectedPosture = DeploymentPosture.Team } + }; - Assert.False(config.ContainsKey("Daemon")); - AssertNoEnabledKey(config, "Webhooks"); + using var orchestrator = new WizardOrchestrator(steps, context, singleStepMode: true); + orchestrator.WriteConfig(); + + var config = LoadWrittenConfig(); + Assert.True(config.ContainsKey("Slack")); + Assert.True(config.ContainsKey("Daemon")); + Assert.True(config.ContainsKey("Search")); + Assert.Equal("Team", GetSection(config, "Security")["DeploymentPosture"]); } [Fact] - public void TeamPosture_ExposureTailscaleFunnel_WebhooksOn() + public void WriteConfig_PersonalPosture_PersistsShellApprovalGateInAudienceProfiles() { + // Security-critical winning path: SecurityPosture emits the Tools section through two + // paths (typed ContributeConfig + the section BuildContribution that is applied last and + // wins). This pins the MERGED on-disk result — the persisted Tools.AudienceProfiles must + // gate shell_execute behind Approval for Personal posture, so any future dedup that drops + // the default-deny override fails here. var steps = BuildCoreSteps(); - EnterAndConfigurePosture(steps, DeploymentPosture.Team); - EnterFeatureSelection(steps); - ConfigureSearch(steps, SearchBackend.Brave); - ConfigureExposure(steps, ExposureMode.TailscaleFunnel, webhooks: true); - ConfigureIdentity(steps, "Netclaw", "UTC"); + EnterAndConfigurePosture(steps, DeploymentPosture.Personal); var config = AssembleConfig(steps); - var daemon = GetSection(config, "Daemon"); - Assert.Equal("tailscale-funnel", daemon["ExposureMode"]); - - // Webhooks: both the feature gate and the exposure step contribute - AssertSectionEnabled(config, "Webhooks", true); + var profiles = GetSection(GetSection(config, "Tools"), "AudienceProfiles"); + var overrides = GetSection(GetSection(GetSection(profiles, "Personal"), "ApprovalPolicy"), "ToolOverrides"); + Assert.Equal("Approval", overrides["shell_execute"]); } // ── Helpers ── @@ -200,9 +202,7 @@ private static List BuildCoreSteps() [ new SecurityPostureStepViewModel(), new FeatureSelectionStepViewModel(), - new SearchStepViewModel(), - new IdentityStepViewModel(), - new ExposureModeStepViewModel() + new IdentityStepViewModel() ]; } @@ -224,22 +224,6 @@ private void EnterFeatureSelection(List steps) step.OnLeave(); } - private static void ConfigureSearch(List steps, SearchBackend backend, - string? searXngEndpoint = null) - { - var step = GetStep(steps); - step.SelectedBackend = backend; - if (searXngEndpoint is not null) - step.SearXngEndpoint = searXngEndpoint; - } - - private static void ConfigureExposure(List steps, ExposureMode mode, bool webhooks) - { - var step = GetStep(steps); - step.SelectedMode = mode; - step.WebhooksEnabled = webhooks; - } - private static void ConfigureIdentity(List steps, string name, string timezone) { var step = GetStep(steps); @@ -252,6 +236,12 @@ private Dictionary AssembleConfig(List ste _orchestrator = new WizardOrchestrator(steps, Context); _orchestrator.WriteConfig(); + return LoadWrittenConfig(); + } + + private Dictionary LoadWrittenConfig() + { + var json = File.ReadAllText(Context.Paths.NetclawConfigPath); var doc = JsonSerializer.Deserialize>(json)!; return ConvertToDictionary(doc); @@ -295,12 +285,6 @@ private static void AssertShellMode(Dictionary config, string ex Assert.Equal(expected, security["ShellExecutionMode"]); } - private static void AssertSearchBackend(Dictionary config, string expected) - { - var search = GetSection(config, "Search"); - Assert.Equal(expected, search["Backend"]); - } - private static void AssertNoDisabledFeatureFlags(Dictionary config) { string[] featureSections = ["Memory", "Search", "SkillSync", "Scheduling", "SubAgents", "Webhooks"]; diff --git a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs index 310c97dba..a26153c88 100644 --- a/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Wizard/WizardOrchestratorTests.cs @@ -80,6 +80,16 @@ public void GoNext_ReturnsFalse_AtEnd() Assert.False(orchestrator.GoNext()); // only one step, already complete } + [Fact] + public void SingleStepMode_GoNext_ReturnsFalse_AfterCurrentStepCompletes() + { + var steps = CreateSteps("a", "b"); + using var orchestrator = new WizardOrchestrator(steps, Context, singleStepMode: true); + + Assert.False(orchestrator.GoNext()); + Assert.Equal("a", orchestrator.CurrentStep!.StepId); + } + [Fact] public void GoNext_SkipsNonApplicableSteps() { diff --git a/src/Netclaw.Cli/Config/ConfigCommand.cs b/src/Netclaw.Cli/Config/ConfigCommand.cs new file mode 100644 index 000000000..4cddbc225 --- /dev/null +++ b/src/Netclaw.Cli/Config/ConfigCommand.cs @@ -0,0 +1,46 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Cli.Config; + +internal static class ConfigCommand +{ + internal const string MissingConfigMessage = "No configuration found. Run `netclaw init` first."; + + public static int Run(string[] args, NetclawPaths paths, TextWriter? output = null, TextWriter? error = null) + { + var writer = output ?? Console.Out; + var errorWriter = error ?? Console.Error; + + if (args.Length > 1 && CliArgsParser.IsHelpToken(args[1])) + return WriteHelp(writer); + + if (args.Length > 1) + { + writer.WriteLine("Usage: netclaw config"); + writer.WriteLine("Run `netclaw config --help` for details."); + return 1; + } + + if (!File.Exists(paths.NetclawConfigPath)) + { + errorWriter.WriteLine(MissingConfigMessage); + return 1; + } + + return 0; + } + + private static int WriteHelp(TextWriter writer) + { + writer.WriteLine("Usage: netclaw config"); + writer.WriteLine(); + writer.WriteLine("Launch the main post-install settings dashboard."); + writer.WriteLine("Use `netclaw init` for bootstrap setup on a new install."); + return 0; + } +} diff --git a/src/Netclaw.Cli/Config/ConfigFileHelper.cs b/src/Netclaw.Cli/Config/ConfigFileHelper.cs index 9cb7d36eb..a3079917f 100644 --- a/src/Netclaw.Cli/Config/ConfigFileHelper.cs +++ b/src/Netclaw.Cli/Config/ConfigFileHelper.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using System.Text.Json.Nodes; using Netclaw.Cli.Json; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -41,6 +42,41 @@ internal static Dictionary LoadJsonDict(string path) ?? new Dictionary { ["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion }; } + /// + /// Load a JSON file as a mutable dictionary, or null when the file is missing or empty. + /// Distinct from , which returns a { "configVersion": 1 } + /// skeleton for a missing file — callers that need to detect "no existing config" use this. + /// + internal static Dictionary? LoadJsonDictOrNull(string path) + { + if (!File.Exists(path)) + return null; + + var config = LoadJsonDict(path); + return config.Count == 0 ? null : config; + } + + /// + /// Like but never throws on an unreadable / malformed file: + /// returns the parsed dict (or null when missing/empty/unreadable) and, via + /// , a human-readable reason when the file existed but could not be read. + /// Lets a view-model constructor degrade to a safe default and surface the error instead of + /// crashing the page on open when netclaw.json is hand-corrupted or its keys directory is gone. + /// + internal static Dictionary? TryLoadJsonDictOrNull(string path, out string? error) + { + error = null; + try + { + return LoadJsonDictOrNull(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + error = $"Could not read {Path.GetFileName(path)}: {ex.Message}"; + return null; + } + } + /// /// Get or create a nested dictionary section. Handles JsonElement deserialization /// when the section was loaded from a file. @@ -94,16 +130,37 @@ internal static Dictionary GetOrCreateSection( } /// - /// Serialize a config dictionary and write it to disk, creating parent directories if needed. + /// Deserialize a loaded config section value into . The value may be a + /// (freshly loaded from disk) or an already-materialized CLR object + /// (just written in-memory) — both shapes are handled. Returns default when the value + /// deserializes to null; callers decide whether that means a fresh instance or an error. /// - internal static void WriteConfigFile(string path, Dictionary data) + internal static T? DeserializeSection(object raw) { - var dir = Path.GetDirectoryName(path); - if (dir is not null) - Directory.CreateDirectory(dir); - File.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.Indented)); + var json = raw is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(raw, JsonDefaults.ConfigFile); + return JsonSerializer.Deserialize(json, JsonDefaults.ConfigRead); } + /// + /// Read a typed config section out of a loaded config dictionary, returning new T() + /// when the section is absent, null, or deserializes to null. + /// + internal static T LoadSection(Dictionary root, string sectionName) where T : new() + { + if (!root.TryGetValue(sectionName, out var raw) || raw is null) + return new T(); + + return DeserializeSection(raw) ?? new T(); + } + + /// + /// Serialize a config dictionary and write it to disk, creating parent directories if needed. + /// + internal static void WriteConfigFile(string path, Dictionary data) + => AtomicFile.WriteAllText(path, JsonSerializer.Serialize(data, JsonDefaults.ConfigFile)); + /// /// Serialize and write secrets.json using hardened permissions and encryption-at-rest. /// @@ -113,6 +170,79 @@ internal static void WriteSecretsFile(Configuration.NetclawPaths paths, Dictiona SecretsFileWriter.Write(paths.SecretsPath, data, options: JsonDefaults.Indented, protector: protector); } + internal static bool PathPresent(Dictionary root, string path) + => TryGetPathValue(root, path, out _); + + internal static bool TryGetPathValue(Dictionary root, string path, out object? value) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + object? current = root; + + foreach (var segment in segments) + { + if (!TryGetChildValue(current, segment, out current)) + { + value = null; + return false; + } + } + + value = NormalizeNodeValue(current); + return true; + } + + internal static void SetPathValue(Dictionary root, string path, object? value) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + Dictionary current = root; + + for (var i = 0; i < segments.Length - 1; i++) + { + var segment = segments[i]; + current = GetOrCreateSection(current, segment); + } + + current[segments[^1]] = value!; + } + + internal static bool RemovePath(Dictionary root, string path) + { + ArgumentNullException.ThrowIfNull(root); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + Dictionary? current = root; + + for (var i = 0; i < segments.Length - 1; i++) + { + current = current is null ? null : GetSectionOrNull(current, segments[i]); + if (current is null) + return false; + } + + if (current is null) + return false; + + var removed = current.Remove(segments[^1]); + if (!removed) + return false; + + PruneEmptySections(root, segments); + return true; + } + + internal static bool SecretPresent(Configuration.NetclawPaths paths, string path) + { + var secrets = LoadJsonDict(paths.SecretsPath); + return PathPresent(secrets, path); + } + internal static string DecryptIfEncrypted(Configuration.NetclawPaths paths, string? value) { if (string.IsNullOrEmpty(value) || !ISecretsProtector.IsEncrypted(value)) @@ -121,4 +251,76 @@ internal static string DecryptIfEncrypted(Configuration.NetclawPaths paths, stri var protector = SecretsProtection.CreateProtector(paths); return protector.Unprotect(value); } + + /// + /// Read a secret value from secrets.json at , decrypting it if it was + /// stored encrypted-at-rest. Returns null when the file or path is absent. Deliberately + /// does NOT apply whitespace normalization — credential surfaces differ on whether a blank + /// value means "null", "empty string", or a trimmed value, so each caller applies its own + /// policy to the result. + /// + internal static string? ReadDecryptedSecret(Configuration.NetclawPaths paths, string path) + { + var secrets = LoadJsonDict(paths.SecretsPath); + return TryGetPathValue(secrets, path, out var value) + ? DecryptIfEncrypted(paths, value?.ToString()) + : null; + } + + private static bool TryGetChildValue(object? current, string segment, out object? child) + { + switch (current) + { + case Dictionary dict when dict.TryGetValue(segment, out child): + return true; + case JsonObject jsonObject when jsonObject.TryGetPropertyValue(segment, out var node): + child = node; + return true; + case JsonElement element when element.ValueKind == JsonValueKind.Object && element.TryGetProperty(segment, out var property): + child = property; + return true; + default: + child = null; + return false; + } + } + + private static object? NormalizeNodeValue(object? value) + => value switch + { + JsonElement element when element.ValueKind == JsonValueKind.Object + => JsonSerializer.Deserialize>(element.GetRawText()), + JsonElement element when element.ValueKind == JsonValueKind.Array + => JsonSerializer.Deserialize(element.GetRawText()), + JsonElement element when element.ValueKind == JsonValueKind.String + => element.GetString(), + JsonElement element when element.ValueKind == JsonValueKind.True + => true, + JsonElement element when element.ValueKind == JsonValueKind.False + => false, + JsonElement element when element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var longValue) + => longValue, + JsonElement element when element.ValueKind == JsonValueKind.Number + => element.GetDouble(), + JsonNode node => node.Deserialize(), + _ => value + }; + + private static void PruneEmptySections(Dictionary root, string[] segments) + { + for (var depth = segments.Length - 1; depth > 0; depth--) + { + var parentPath = string.Join('.', segments.Take(depth)); + if (!TryGetPathValue(root, parentPath, out var parentValue) + || parentValue is not Dictionary parentSection) + { + continue; + } + + if (parentSection.Count != 0) + break; + + RemovePath(root, parentPath); + } + } } diff --git a/src/Netclaw.Cli/Daemon/DaemonApi.cs b/src/Netclaw.Cli/Daemon/DaemonApi.cs index 4af745dce..08f1af603 100644 --- a/src/Netclaw.Cli/Daemon/DaemonApi.cs +++ b/src/Netclaw.Cli/Daemon/DaemonApi.cs @@ -27,7 +27,6 @@ public sealed class DaemonApi private readonly IHttpClientFactory _factory; private readonly string _endpoint; - private readonly string? _deviceToken; private readonly NetclawPaths _paths; /// @@ -40,7 +39,6 @@ public DaemonApi(IHttpClientFactory factory, IConfiguration configuration, Netcl _factory = factory; _paths = paths; _endpoint = ResolveEndpoint(paths); - _deviceToken = DaemonClientFactory.ResolveDeviceToken(_endpoint, paths, DaemonClientFactory.ResolveExposureMode(paths)); } internal DaemonApi(IHttpClientFactory factory, IConfiguration configuration) @@ -363,8 +361,13 @@ private static CancellationTokenSource CreateTimeoutCts(TimeSpan timeout, Cancel private HttpClient CreateHttpClient() { var client = _factory.CreateClient(); - if (!string.IsNullOrWhiteSpace(_deviceToken)) - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _deviceToken); + // Config TUI can switch exposure mode and write the bootstrap token while this singleton is alive. + var deviceToken = DaemonClientFactory.ResolveDeviceToken( + _endpoint, + _paths, + DaemonClientFactory.ResolveExposureMode(_paths)); + if (!string.IsNullOrWhiteSpace(deviceToken)) + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", deviceToken); return client; } diff --git a/src/Netclaw.Cli/Discord/DiscordProbe.cs b/src/Netclaw.Cli/Discord/DiscordProbe.cs index 1207a7cde..61bf095ed 100644 --- a/src/Netclaw.Cli/Discord/DiscordProbe.cs +++ b/src/Netclaw.Cli/Discord/DiscordProbe.cs @@ -90,12 +90,15 @@ public async Task ProbeAsync(string botToken, CancellationTo } } + // Accepts channel IDs (snowflakes) OR display names (#general). Enumerates the bot's guild text + // channels once and matches each reference by id or by name, so operators can enter human-readable + // names instead of snowflakes (the resolved id is what the runtime ACL matches). public async Task ResolveChannelIdsAsync( - string botToken, IReadOnlyList channelIds, CancellationToken ct = default) + string botToken, IReadOnlyList channelRefs, CancellationToken ct = default) { - var normalized = channelIds - .Select(id => id.Trim()) - .Where(id => !string.IsNullOrWhiteSpace(id)) + var normalized = channelRefs + .Select(reference => reference.Trim().TrimStart('#')) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) .Distinct(StringComparer.Ordinal) .ToList(); @@ -107,43 +110,32 @@ public async Task ResolveChannelIdsAsync( try { - var channelTasks = normalized.Select(id => - FetchChannelAsync(botToken, id, timeoutCts.Token)); - var channelResults = await Task.WhenAll(channelTasks); - - var channelPairs = normalized.Zip(channelResults).ToList(); - - var uniqueGuildIds = channelPairs - .Where(p => p.Second is not null && p.Second.Value.GuildId is not null) - .Select(p => p.Second!.Value.GuildId!) - .Distinct(StringComparer.Ordinal) - .ToList(); - - var guildTasks = uniqueGuildIds.Select(async gid => - (GuildId: gid, Name: await FetchGuildNameAsync(botToken, gid, timeoutCts.Token))); - var guildResults = await Task.WhenAll(guildTasks); - var guildNames = guildResults - .Where(g => g.Name is not null) - .ToDictionary(g => g.GuildId, g => g.Name!, StringComparer.Ordinal); - - var resolved = new List(); - var unresolved = new List(); - - foreach (var (channelId, channelInfo) in channelPairs) + var byId = new Dictionary(StringComparer.Ordinal); + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (guildId, guildName) in await FetchBotGuildsAsync(botToken, timeoutCts.Token)) { - if (channelInfo is null) + foreach (var (channelId, channelName) in await FetchGuildTextChannelsAsync(botToken, guildId, timeoutCts.Token)) { - unresolved.Add(channelId); - continue; + var channel = new ResolvedDiscordChannel(channelId, channelName, guildName); + byId[channelId] = channel; + // First match wins when a channel name is duplicated across guilds. + byName.TryAdd(channelName, channel); } + } - var (channelName, guildId) = channelInfo.Value; - guildNames.TryGetValue(guildId ?? "", out var guildName); - resolved.Add(new ResolvedDiscordChannel(channelId, channelName, guildName)); + var resolved = new List(); + var unresolved = new List(); + foreach (var reference in normalized) + { + if (byId.TryGetValue(reference, out var idMatch)) + resolved.Add(idMatch); + else if (byName.TryGetValue(reference, out var nameMatch)) + resolved.Add(nameMatch); + else + unresolved.Add(reference); } - return new DiscordChannelResolutionResult( - unresolved.Count == 0, null, resolved, unresolved); + return new DiscordChannelResolutionResult(unresolved.Count == 0, null, resolved, unresolved); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { @@ -162,41 +154,55 @@ public async Task ResolveChannelIdsAsync( } } - private async Task<(string Name, string? GuildId)?> FetchChannelAsync( - string botToken, string channelId, CancellationToken ct) + private async Task> FetchBotGuildsAsync(string botToken, CancellationToken ct) { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/channels/{channelId}"); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/users/@me/guilds"); request.Headers.Authorization = new AuthenticationHeaderValue("Bot", botToken); using var response = await _httpClient.SendAsync(request, ct); if (!response.IsSuccessStatusCode) - return null; + throw new HttpRequestException(MapHttpError(response.StatusCode, await response.Content.ReadAsStringAsync(ct))); var json = await response.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - var name = root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; - var guildId = root.TryGetProperty("guild_id", out var guildProp) ? guildProp.GetString() : null; + var guilds = new List<(string, string)>(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + var id = element.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + var name = element.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + if (id is not null && name is not null) + guilds.Add((id, name)); + } - return name is not null ? (name, guildId) : null; + return guilds; } - private async Task FetchGuildNameAsync( - string botToken, string guildId, CancellationToken ct) + private async Task> FetchGuildTextChannelsAsync(string botToken, string guildId, CancellationToken ct) { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/guilds/{guildId}"); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/guilds/{guildId}/channels"); request.Headers.Authorization = new AuthenticationHeaderValue("Bot", botToken); using var response = await _httpClient.SendAsync(request, ct); if (!response.IsSuccessStatusCode) - return null; + return []; // a guild whose channels we cannot list is skipped, not fatal var json = await response.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; + var channels = new List<(string, string)>(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + // Discord channel types: 0 = GUILD_TEXT, 5 = GUILD_ANNOUNCEMENT — both accept messages. + var type = element.TryGetProperty("type", out var typeProp) && typeProp.TryGetInt32(out var t) ? t : -1; + if (type != 0 && type != 5) + continue; + + var id = element.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + var name = element.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + if (id is not null && name is not null) + channels.Add((id, name)); + } - return root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + return channels; } private static string MapHttpError(System.Net.HttpStatusCode statusCode, string body) diff --git a/src/Netclaw.Cli/Doctor/DeviceRegistryInspector.cs b/src/Netclaw.Cli/Doctor/DeviceRegistryInspector.cs index 1c57f0ee4..c29cb0d2f 100644 --- a/src/Netclaw.Cli/Doctor/DeviceRegistryInspector.cs +++ b/src/Netclaw.Cli/Doctor/DeviceRegistryInspector.cs @@ -13,12 +13,15 @@ internal static class DeviceRegistryInspector { public static DeviceRegistrySnapshot Read(NetclawPaths paths) { + var devicesFileExists = File.Exists(paths.DevicesPath); var devices = ReadDevices(paths); - var localTokenMatchesDevice = HasMatchingLocalDeviceToken(paths, devices); + var (hasLocalDeviceToken, localTokenMatchesDevice) = ReadLocalDeviceTokenState(paths, devices); var hasCompletedBootstrap = new BootstrapStateStore(paths).HasCompletedNonLocalBootstrap(); return new DeviceRegistrySnapshot( devices.Count, + devicesFileExists, + hasLocalDeviceToken, localTokenMatchesDevice, hasCompletedBootstrap); } @@ -41,31 +44,35 @@ private static List ReadDevices(NetclawPaths paths) } } - private static bool HasMatchingLocalDeviceToken(NetclawPaths paths, IReadOnlyList devices) + private static (bool HasLocalDeviceToken, bool LocalTokenMatchesDevice) ReadLocalDeviceTokenState( + NetclawPaths paths, + IReadOnlyList devices) { if (!File.Exists(paths.SecretsPath)) - return false; + return (false, false); var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); if (!secrets.TryGetValue("DeviceToken", out var rawValue)) - return false; + return (false, false); var token = rawValue is JsonElement jsonElement ? jsonElement.GetString() : rawValue?.ToString(); token = ConfigFileHelper.DecryptIfEncrypted(paths, token); if (string.IsNullOrWhiteSpace(token)) - return false; + return (false, false); foreach (var device in devices) { if (PairedDevice.VerifyToken(token, device)) - return true; + return (true, true); } - return false; + return (true, false); } } internal sealed record DeviceRegistrySnapshot( int DeviceCount, + bool DevicesFileExists, + bool HasLocalDeviceToken, bool LocalTokenMatchesDevice, bool HasCompletedBootstrap); diff --git a/src/Netclaw.Cli/Doctor/InboundWebhookRoutesDoctorCheck.cs b/src/Netclaw.Cli/Doctor/InboundWebhookRoutesDoctorCheck.cs index 4d4633eab..88bbfdbf9 100644 --- a/src/Netclaw.Cli/Doctor/InboundWebhookRoutesDoctorCheck.cs +++ b/src/Netclaw.Cli/Doctor/InboundWebhookRoutesDoctorCheck.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using Netclaw.Cli.Config; using Netclaw.Cli.Json; using Netclaw.Configuration; @@ -16,15 +17,30 @@ public sealed class InboundWebhookRoutesDoctorCheck(NetclawPaths paths) : IDocto public Task RunAsync(CancellationToken cancellationToken = default) { Directory.CreateDirectory(paths.WebhooksDirectory); + var inboundWebhooksEnabled = IsInboundWebhooksEnabled(); var routeFiles = Directory.EnumerateFiles(paths.WebhooksDirectory, "*.json", SearchOption.TopDirectoryOnly) .OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase) .ToList(); if (routeFiles.Count == 0) + { + if (inboundWebhooksEnabled) + { + // Advisory, not an error: `Webhooks.Enabled` is only the feature toggle. + // With no routes the gateway fails every request closed at 404 (inert), + // so enable-first is a valid setup order — nudge, don't fail the config. + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + "Inbound webhooks are enabled but no route files are configured.", + $"Create at least one valid route with `netclaw webhooks set` or disable Webhooks.Enabled. Routes live under {paths.WebhooksDirectory}.")); + } + return Task.FromResult(DoctorCheckResult.Pass(CheckName, "No inbound webhook route files configured.")); + } var invalidRoutes = new List(); + var enabledRouteCount = 0; foreach (var filePath in routeFiles) { var routeName = Path.GetFileNameWithoutExtension(filePath); @@ -34,6 +50,8 @@ public Task RunAsync(CancellationToken cancellationToken = de ?? throw new InvalidOperationException($"Webhook route '{routeName}' could not be parsed."); WebhookRouteValidator.ValidateOrThrow(routeName, route); + if (route.Enabled) + enabledRouteCount++; } catch (Exception ex) { @@ -43,6 +61,16 @@ public Task RunAsync(CancellationToken cancellationToken = de if (invalidRoutes.Count == 0) { + if (inboundWebhooksEnabled && enabledRouteCount == 0) + { + // Advisory, not an error (see above): route files exist but none are + // enabled, so the gateway serves nothing yet. Enable a route or add one. + return Task.FromResult(DoctorCheckResult.Warning( + CheckName, + "Inbound webhooks are enabled but no valid enabled route files are configured.", + "Enable or create at least one valid route with `netclaw webhooks set`, or disable Webhooks.Enabled.")); + } + return Task.FromResult(DoctorCheckResult.Pass( CheckName, $"Validated {routeFiles.Count} inbound webhook route file(s).")); @@ -53,4 +81,12 @@ public Task RunAsync(CancellationToken cancellationToken = de $"Invalid inbound webhook route files: {string.Join("; ", invalidRoutes)}", $"Fix or remove invalid files under {paths.WebhooksDirectory}. Netclaw fails these routes closed at runtime.")); } + + private bool IsInboundWebhooksEnabled() + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + return ConfigFileHelper.TryGetPathValue(config, "Webhooks.Enabled", out var enabled) + && enabled is bool enabledFlag + && enabledFlag; + } } diff --git a/src/Netclaw.Cli/Mattermost/MattermostProbe.cs b/src/Netclaw.Cli/Mattermost/MattermostProbe.cs new file mode 100644 index 000000000..cfd530070 --- /dev/null +++ b/src/Netclaw.Cli/Mattermost/MattermostProbe.cs @@ -0,0 +1,240 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Netclaw.Cli.Mattermost; + +public sealed record MattermostProbeResult( + bool Success, + string? ErrorMessage, + string? BotUsername); + +public sealed record ResolvedMattermostChannel( + string ChannelId, + string ChannelName, + string DisplayName); + +public sealed record MattermostChannelResolutionResult( + bool Success, + string? ErrorMessage, + IReadOnlyList Resolved, + IReadOnlyList Unresolved); + +public interface IMattermostProbe +{ + Task ProbeAsync(string serverUrl, string botToken, CancellationToken ct = default); + + Task ResolveChannelIdsAsync( + string serverUrl, string botToken, IReadOnlyList channelIds, CancellationToken ct = default); +} + +public sealed class MattermostProbe : IMattermostProbe +{ + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ResolveTimeout = TimeSpan.FromSeconds(30); + + private readonly HttpClient _httpClient; + + public MattermostProbe(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task ProbeAsync(string serverUrl, string botToken, CancellationToken ct = default) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(ProbeTimeout); + + try + { + using var request = CreateRequest(HttpMethod.Get, serverUrl, "/api/v4/users/me", botToken); + using var response = await _httpClient.SendAsync(request, timeoutCts.Token); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(timeoutCts.Token); + return new MattermostProbeResult(false, MapHttpError(response.StatusCode, body), null); + } + + var json = await response.Content.ReadAsStringAsync(timeoutCts.Token); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var username = root.TryGetProperty("username", out var usernameProp) + ? usernameProp.GetString() + : null; + return new MattermostProbeResult(true, null, username); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new MattermostProbeResult(false, + "Connection timed out after 10 seconds. Check your network connection.", null); + } + catch (OperationCanceledException) + { + return new MattermostProbeResult(false, "Validation cancelled.", null); + } + catch (HttpRequestException ex) + { + return new MattermostProbeResult(false, $"Connection failed: {ex.Message}", null); + } + catch (InvalidOperationException ex) + { + return new MattermostProbeResult(false, ex.Message, null); + } + } + + // Accepts channel IDs (26-char) OR names/display-names (#town-square). Enumerates the bot's team + // channels once and matches each reference by id, url slug name, or human display name, so + // operators can enter readable names instead of opaque ids (the resolved id is what the runtime + // ACL matches). + public async Task ResolveChannelIdsAsync( + string serverUrl, string botToken, IReadOnlyList channelRefs, CancellationToken ct = default) + { + var normalized = channelRefs + .Select(static reference => reference.Trim().TrimStart('#')) + .Where(static reference => !string.IsNullOrWhiteSpace(reference)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (normalized.Count == 0) + return new MattermostChannelResolutionResult(true, null, [], []); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(ResolveTimeout); + + try + { + var byId = new Dictionary(StringComparer.Ordinal); + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var teamId in await FetchBotTeamIdsAsync(serverUrl, botToken, timeoutCts.Token)) + { + foreach (var channel in await FetchTeamChannelsAsync(serverUrl, botToken, teamId, timeoutCts.Token)) + { + byId[channel.ChannelId] = channel; + // Match either the url slug or the human display name; first match wins. + byName.TryAdd(channel.ChannelName, channel); + byName.TryAdd(channel.DisplayName, channel); + } + } + + var resolved = new List(); + var unresolved = new List(); + foreach (var reference in normalized) + { + if (byId.TryGetValue(reference, out var idMatch)) + resolved.Add(idMatch); + else if (byName.TryGetValue(reference, out var nameMatch)) + resolved.Add(nameMatch); + else + unresolved.Add(reference); + } + + return new MattermostChannelResolutionResult(unresolved.Count == 0, null, resolved, unresolved); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new MattermostChannelResolutionResult(false, + "Channel resolution timed out after 30 seconds.", [], []); + } + catch (OperationCanceledException) + { + return new MattermostChannelResolutionResult(false, + "Channel resolution cancelled.", [], []); + } + catch (HttpRequestException ex) + { + return new MattermostChannelResolutionResult(false, + $"Channel resolution failed: {ex.Message}", [], []); + } + catch (InvalidOperationException ex) + { + return new MattermostChannelResolutionResult(false, ex.Message, [], []); + } + } + + private async Task> FetchBotTeamIdsAsync(string serverUrl, string botToken, CancellationToken ct) + { + using var request = CreateRequest(HttpMethod.Get, serverUrl, "/api/v4/users/me/teams", botToken); + using var response = await _httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException(MapHttpError(response.StatusCode, await response.Content.ReadAsStringAsync(ct))); + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var teamIds = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + if (element.TryGetProperty("id", out var idProp) && idProp.GetString() is { } id) + teamIds.Add(id); + } + + return teamIds; + } + + private async Task> FetchTeamChannelsAsync(string serverUrl, string botToken, string teamId, CancellationToken ct) + { + using var request = CreateRequest(HttpMethod.Get, serverUrl, + $"/api/v4/users/me/teams/{Uri.EscapeDataString(teamId)}/channels", botToken); + using var response = await _httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + return []; // a team whose channels we cannot list is skipped, not fatal + + var json = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(json); + var channels = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + { + var id = element.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + var name = element.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : null; + var displayName = element.TryGetProperty("display_name", out var displayProp) ? displayProp.GetString() : null; + if (id is not null) + channels.Add(new ResolvedMattermostChannel(id, name ?? id, displayName ?? name ?? id)); + } + + return channels; + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, string serverUrl, string path, string botToken) + { + if (!Uri.TryCreate(serverUrl.TrimEnd('/'), UriKind.Absolute, out var baseUri) + || baseUri.Scheme is not ("http" or "https")) + throw new InvalidOperationException("Mattermost server URL must be an absolute http:// or https:// URL."); + + var request = new HttpRequestMessage(method, new Uri(baseUri, path)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", botToken); + return request; + } + + private static string MapHttpError(HttpStatusCode statusCode, string body) + { + var message = TryExtractMattermostMessage(body); + return (statusCode, message) switch + { + (HttpStatusCode.Unauthorized, _) => "Bot token is invalid. Check the Mattermost bot access token.", + (HttpStatusCode.Forbidden, _) => "Access denied. Check bot permissions.", + (HttpStatusCode.NotFound, _) => "Resource not found. Check the ID is correct.", + (HttpStatusCode.TooManyRequests, _) => "Rate limited by Mattermost API. Try again in a few seconds.", + (_, { Length: > 0 }) => $"Mattermost API error: {(int)statusCode} {statusCode}: {message}", + _ => $"Mattermost API error: {(int)statusCode} {statusCode}" + }; + } + + private static string? TryExtractMattermostMessage(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("message", out var messageProp) + ? messageProp.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } +} diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs new file mode 100644 index 000000000..8a32a881e --- /dev/null +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsNavigationState.cs @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Cli.Mcp; + +public sealed class McpToolPermissionsNavigationState +{ + private TrustAudience? _initialAudience; + + public void RequestInitialAudience(TrustAudience audience) + { + _initialAudience = audience; + } + + public TrustAudience? ConsumeInitialAudience() + { + var audience = _initialAudience; + _initialAudience = null; + return audience; + } +} diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs index 18c1edeea..66bee86b8 100644 --- a/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsPage.cs @@ -326,14 +326,16 @@ private void HandleKeyPress(KeyPressed key) if (keyInfo.Key == ConsoleKey.Escape) { - if (ViewModel.CurrentState.Value == ToolPermissionsState.ServerList) + if (ViewModel.CurrentState.Value == ToolPermissionsState.ToolGrid) + { + _gridCursor = 0; + ViewModel.GoBack(); + } + else { ViewModel.RequestQuit(); - return; } - _gridCursor = 0; - ViewModel.GoBack(); return; } diff --git a/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs b/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs index aa70d1b95..37ed6a21c 100644 --- a/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs +++ b/src/Netclaw.Cli/Mcp/McpToolPermissionsViewModel.cs @@ -8,6 +8,7 @@ using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; using Netclaw.Cli.Json; +using Netclaw.Cli.Tui; using Netclaw.Configuration; using Netclaw.Tools; using R3; @@ -28,11 +29,19 @@ public sealed class McpToolPermissionsViewModel : ReactiveViewModel private readonly NetclawPaths _paths; private readonly DaemonApi _daemonApi; private bool _initializedForTests; + private readonly McpToolPermissionsNavigationState? _navigationState; + private readonly TuiNavigation? _navigation; - public McpToolPermissionsViewModel(NetclawPaths paths, DaemonApi daemonApi) + public McpToolPermissionsViewModel( + NetclawPaths paths, + DaemonApi daemonApi, + McpToolPermissionsNavigationState? navigationState = null, + TuiNavigation? navigation = null) { _paths = paths; _daemonApi = daemonApi; + _navigationState = navigationState; + _navigation = navigation; } public ReactiveProperty CurrentState { get; } = new(ToolPermissionsState.Loading); @@ -69,41 +78,67 @@ public McpToolPermissionsViewModel(NetclawPaths paths, DaemonApi daemonApi) public override void OnActivated() { base.OnActivated(); + ApplyPendingNavigationState(); if (_initializedForTests) return; _ = LoadServersAsync(); } - private async Task LoadServersAsync() + internal async Task LoadServersAsync() { StatusMessage.Value = "Loading MCP server statuses..."; + JsonElement statuses; try { - var statuses = await _daemonApi.GetMcpServerStatusesAsync(CancellationToken.None); - Servers.Clear(); + statuses = await _daemonApi.GetMcpServerStatusesAsync(CancellationToken.None); + } + catch (Exception ex) + { + StatusMessage.Value = $"Could not reach daemon: {ex.Message}"; + NotifyStateChanged(); + return; + } + Servers.Clear(); + + // A 200 response whose body is not the expected object shape (or a server entry missing + // its "state") would otherwise throw out of this fire-and-forget task. Surface it as a + // status message like the daemon-call path does, rather than crashing page activation. + try + { foreach (var prop in statuses.EnumerateObject()) { - var state = prop.Value.GetProperty("state").GetString() ?? "unknown"; + var state = prop.Value.TryGetProperty("state", out var stateEl) + ? stateEl.GetString() ?? "unknown" + : "unknown"; var toolCount = prop.Value.TryGetProperty("toolCount", out var tc) ? tc.GetInt32() : 0; Servers.Add((prop.Name, state, toolCount)); } + } + catch (Exception ex) + { + StatusMessage.Value = $"Could not read MCP server statuses: {ex.Message}"; + NotifyStateChanged(); + return; + } + try + { Profiles = LoadToolConfig().AudienceProfiles; - - if (Servers.Count == 0) - { - StatusMessage.Value = "No MCP servers connected. Start the daemon and configure servers first."; - } - else - { - StatusMessage.Value = ""; - CurrentState.Value = ToolPermissionsState.ServerList; - } } catch (Exception ex) { - StatusMessage.Value = $"Could not reach daemon: {ex.Message}"; + StatusMessage.Value = $"Could not load MCP permissions config: {ex.Message}"; + NotifyStateChanged(); + return; + } + + if (Servers.Count == 0) + StatusMessage.Value = "No MCP servers connected. Start the daemon and configure servers first."; + else + { + StatusMessage.Value = ""; + CurrentState.Value = ToolPermissionsState.ServerList; } NotifyStateChanged(); @@ -123,6 +158,7 @@ public void SelectServer(McpServerName serverName) internal void InitializeForTests(McpServerName serverName, IEnumerable tools) { _initializedForTests = true; + ApplyPendingNavigationState(); SelectedServer = serverName.Value; DiscoveredTools.Clear(); DiscoveredTools.AddRange(tools); @@ -445,7 +481,7 @@ public bool Save() var toolsSection = ConfigFileHelper.GetOrCreateSection(config, "Tools"); var profilesSection = ConfigFileHelper.GetOrCreateSection(toolsSection, "AudienceProfiles"); - SaveServerAccess(profilesSection); + SaveServerAccess(config, profilesSection); SaveToolGrants(profilesSection); SaveServerDefaults(profilesSection); SaveToolOverrides(profilesSection); @@ -470,41 +506,59 @@ public bool Save() } } - private void SaveServerAccess(Dictionary profilesSection) + private void SaveServerAccess(Dictionary config, Dictionary profilesSection) { + var knownServers = GetKnownMcpServers(config); + + // Accumulate per-audience working lists WITHOUT mutating the live in-memory profile objects + // (Profiles.Public/Team/Personal back the runtime ACL queries — IsServerAllowed, etc. — so + // coercing them here would leave the ACL in a post-save state if Save throws before the file + // write). Seed each audience's working list from its ORIGINAL profile the first time it is + // touched; later changes for the same audience build on the working list rather than re-reading + // a profile that an earlier iteration would have coerced. + var workingLists = new Dictionary>(StringComparer.Ordinal); foreach (var ((audienceName, serverName), allowed) in _pendingServerAccess) { var audienceSection = ConfigFileHelper.GetOrCreateSection(profilesSection, audienceName); - - var serverList = audienceSection.TryGetValue("AllowedMcpServers", out var existingList) - && existingList is List list - ? list.Select(o => o.ToString()!).ToList() - : []; - - if (allowed && !serverList.Contains(serverName, StringComparer.OrdinalIgnoreCase)) + if (!workingLists.TryGetValue(audienceName, out var serverList)) { - serverList.Add(serverName); + var profile = ResolveProfile(AudienceFromName(audienceName)); + serverList = profile.McpServersMode == ToolProfileMode.All + ? knownServers.ToList() + : profile.AllowedMcpServers.ToList(); + workingLists[audienceName] = serverList; } - else if (!allowed) - { + + if (allowed) + AddServer(serverList, serverName); + else serverList.RemoveAll(s => s.Equals(serverName, StringComparison.OrdinalIgnoreCase)); - } + audienceSection["McpServersMode"] = ToolProfileMode.Allowlist.ToString(); audienceSection["AllowedMcpServers"] = serverList; + } + } - // Also update the in-memory profile so the UI reflects changes immediately - var profile = audienceName switch - { - "Public" => Profiles.Public, - "Team" => Profiles.Team, - _ => Profiles.Personal - }; - - if (allowed && !profile.AllowedMcpServers.Contains(serverName, StringComparer.OrdinalIgnoreCase)) - profile.AllowedMcpServers.Add(serverName); - else if (!allowed) - profile.AllowedMcpServers.RemoveAll(s => s.Equals(serverName, StringComparison.OrdinalIgnoreCase)); + private IReadOnlyList GetKnownMcpServers(Dictionary config) + { + var names = new List(); + foreach (var server in Servers) + AddServer(names, server.Name); + + if (ConfigFileHelper.TryGetPathValue(config, "McpServers", out var configuredServers) + && configuredServers is Dictionary configuredServerMap) + { + foreach (var serverName in configuredServerMap.Keys) + AddServer(names, serverName); } + + return names; + } + + private static void AddServer(List serverList, string serverName) + { + if (!serverList.Contains(serverName, StringComparer.OrdinalIgnoreCase)) + serverList.Add(serverName); } private void SaveToolGrants(Dictionary profilesSection) @@ -612,12 +666,7 @@ public void ToggleServerAccess() { var audienceSection = ConfigFileHelper.GetOrCreateSection(profilesSection, audienceName); var approvalSection = ConfigFileHelper.GetOrCreateSection(audienceSection, "ApprovalPolicy"); - var profile = audienceName switch - { - "Public" => Profiles.Public, - "Team" => Profiles.Team, - _ => Profiles.Personal - }; + var profile = ResolveProfile(AudienceFromName(audienceName)); profile.ApprovalPolicy ??= new ToolApprovalConfig(); return (approvalSection, profile.ApprovalPolicy); } @@ -644,7 +693,20 @@ private static bool IsServerAllowed(McpServerName serverName, ToolAudienceProfil _ => "Personal" }; - public void RequestQuit() => Shutdown(); + private static TrustAudience AudienceFromName(string audienceName) => audienceName switch + { + "Public" => TrustAudience.Public, + "Team" => TrustAudience.Team, + _ => TrustAudience.Personal + }; + + public void RequestQuit() + { + if (_navigation?.TryGoBack() == true) + return; + + Shutdown(); + } public void GoBack() { @@ -668,25 +730,32 @@ private void NotifyStateChanged() RequestRedraw(); } + private void ApplyPendingNavigationState() + { + if (_navigationState?.ConsumeInitialAudience() is { } audience) + SelectedAudience = audience; + } + + public override void Dispose() + { + CurrentState.Dispose(); + StateVersion.Dispose(); + StatusMessage.Dispose(); + base.Dispose(); + } + private ToolConfig LoadToolConfig() { if (!File.Exists(_paths.NetclawConfigPath)) return new ToolConfig(); - try - { - var text = File.ReadAllText(_paths.NetclawConfigPath); - using var doc = JsonDocument.Parse(text); - - if (!doc.RootElement.TryGetProperty("Tools", out var toolsSection)) - return new ToolConfig(); + var text = File.ReadAllText(_paths.NetclawConfigPath); + using var doc = JsonDocument.Parse(text); - return JsonSerializer.Deserialize(toolsSection.GetRawText(), JsonDefaults.EnumAware) - ?? new ToolConfig(); - } - catch - { + if (!doc.RootElement.TryGetProperty("Tools", out var toolsSection)) return new ToolConfig(); - } + + return JsonSerializer.Deserialize(toolsSection.GetRawText(), JsonDefaults.EnumAware) + ?? throw new InvalidDataException("Tools section could not be deserialized."); } } diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 57e11e5f8..3b001ae84 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -15,16 +15,21 @@ using Netclaw.Channels.Slack; using Netclaw.Cli; using Netclaw.Cli.Approvals; +using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; using Netclaw.Cli.Discord; using Netclaw.Cli.Json; using Netclaw.Cli.Doctor; using Netclaw.Cli.Mcp; +using Netclaw.Cli.Mattermost; using Netclaw.Cli.Reminder; using Netclaw.Cli.Secrets; using Netclaw.Cli.Model; using Netclaw.Cli.Provider; using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; using Netclaw.Cli.Skills; using Netclaw.Cli.Update; using Netclaw.Cli.Webhooks; @@ -98,7 +103,10 @@ static async Task RunAsync(string[] args) { if (args.Length > 1 && IsHelpToken(args[1])) { - WriteDoctorHelp(); + if (mode is "init") + WriteInitHelp(); + else + WriteDoctorHelp(); return; } @@ -124,6 +132,7 @@ static async Task RunAsync(string[] args) { builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddHttpClient(); builder.Services.AddDoctorChecks(); } @@ -152,6 +161,7 @@ static async Task RunAsync(string[] args) builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); + builder.Services.AddHttpClient(); // Init wizard + chat page dependencies (daemon lifecycle + SignalR) var initPaths = new NetclawPaths(); @@ -159,6 +169,11 @@ static async Task RunAsync(string[] args) builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services + .AddSectionEditor() + .AddSectionEditor() + .AddSectionEditor() + .AddSectionEditor(); // Register DaemonClient, ChatNavigationState, and SessionConfig for ChatPage // (uses freshly-written config from the wizard's WriteConfig) @@ -200,15 +215,32 @@ static async Task RunAsync(string[] args) }; }); - builder.Services.AddTermina("/init", termina => + builder.Services.AddSingleton(); + + // On an existing install, `netclaw init` opens an explicit action menu instead + // of silently re-walking setup (simplify-netclaw-init). First run starts the + // bootstrap wizard directly. + var initStartRoute = File.Exists(initPaths.NetclawConfigPath) + ? InitExistingInstallViewModel.MenuRoute + : "/init"; + + builder.Services.AddTermina(initStartRoute, termina => { ConfigureNativeSelection(termina); termina.RegisterRoute("/init"); + termina.RegisterRoute(InitExistingInstallViewModel.MenuRoute); + termina.RegisterRoute(InitExistingInstallViewModel.IdentityRoute); termina.RegisterRoute("/chat"); }); - var initApp = builder.Build(); + using var initApp = builder.Build(); + var initNav = initApp.Services.GetRequiredService(); await RunTerminaHostAsync(initApp); + + // "Open configuration editor" from the existing-install menu hands off to the + // config editor once the init host has exited. + if (initNav.PendingAction == InitFollowUpAction.OpenConfigEditor) + await RunConfigEditorAsync(args); return; } @@ -439,7 +471,7 @@ static async Task RunAsync(string[] args) termina.RegisterRoute("/stats"); }); - var statsApp = builder.Build(); + using var statsApp = builder.Build(); await RunTerminaHostAsync(statsApp); return; } @@ -642,6 +674,8 @@ static async Task RunAsync(string[] args) ConfigureConfigServices(builder.Services, builder.Configuration); builder.Logging.ClearProviders(); builder.Logging.SetMinimumLevel(LogLevel.Warning); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var traceFile = Path.Combine(Path.GetTempPath(), "netclaw-mcp-tools-trace.log"); builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Trace); @@ -652,7 +686,8 @@ static async Task RunAsync(string[] args) t.RegisterRoute("/mcp-tools"); }); - await RunTerminaHostAsync(builder.Build()); + using var mcpToolsHost = builder.Build(); + await RunTerminaHostAsync(mcpToolsHost); return; } @@ -713,7 +748,8 @@ static async Task RunAsync(string[] args) t.RegisterRoute("/provider"); }); - await RunTerminaHostAsync(builder.Build()); + using var providerHost = builder.Build(); + await RunTerminaHostAsync(providerHost); return; } @@ -745,7 +781,8 @@ static async Task RunAsync(string[] args) t.RegisterRoute("/model"); }); - await RunTerminaHostAsync(builder.Build()); + using var modelHost = builder.Build(); + await RunTerminaHostAsync(modelHost); return; } @@ -777,7 +814,8 @@ static async Task RunAsync(string[] args) t.RegisterRoute("/approvals"); }); - await RunTerminaHostAsync(builder.Build()); + using var approvalsHost = builder.Build(); + await RunTerminaHostAsync(approvalsHost); return; } @@ -804,7 +842,8 @@ static async Task RunAsync(string[] args) t.RegisterRoute("/reminder"); }); - await RunTerminaHostAsync(builder.Build()); + using var reminderHost = builder.Build(); + await RunTerminaHostAsync(reminderHost); return; } @@ -854,10 +893,20 @@ static async Task RunAsync(string[] args) return; } - // ── Config management stubs ── + // ── Config dashboard ── if (mode is "config") { - Console.WriteLine("netclaw config: not yet implemented"); + var configPaths = new NetclawPaths(); + configPaths.EnsureDirectoriesExist(); + + var configExitCode = ConfigCommand.Run(args, configPaths); + if (configExitCode != 0 || (args.Length > 1 && IsHelpToken(args[1]))) + { + Environment.ExitCode = configExitCode; + return; + } + + await RunConfigEditorAsync(args); return; } @@ -1045,7 +1094,7 @@ static async Task RunAsync(string[] args) return; } - var app = webBuilder.Build(); + using var app = webBuilder.Build(); await RunTerminaHostAsync(app); } @@ -1070,11 +1119,103 @@ static void WriteCrashLog(Exception ex) // never receive a quit key — an un-killable subprocess. Fail fast in that case. // Daemon connectivity failures are handled here because the chat route can // resolve daemon-backed services while TerminaApplication is being constructed. +// Boots the interactive `netclaw config` editor host. Shared by the `config` command +// and the existing-install menu's "Open configuration editor" handoff so both reach an +// identical editor (simplify-netclaw-init). +static async Task RunConfigEditorAsync(string[] args) +{ + var builder = Host.CreateApplicationBuilder(args); + // ConfigureConfigServices registers NetclawPaths (same paths the caller already + // ensured on disk), so no separate paths registration is needed here. + ConfigureConfigServices(builder.Services, builder.Configuration); + builder.Services.AddSingleton(new ConfigDashboardNavigationState()); + // Marks this as the embedded config host so the routed Provider/Model managers navigate back + // to the dashboard (rather than exiting) when backed out — the standalone hosts omit it. + builder.Services.AddSingleton(new EmbeddedConfigHostMarker()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddProviderDescriptors(); + builder.Services.AddHttpClient(); + builder.Services.AddHttpClient(); + builder.Services.AddHttpClient(); + builder.Services.AddHttpClient("OAuthDeviceFlow"); + builder.Services.AddSingleton(sp => + new OAuthDeviceFlowService( + sp.GetRequiredService().CreateClient("OAuthDeviceFlow"), + sp.GetService())); + builder.Services.AddSingleton(sp => + new OpenAiDeviceFlowService( + sp.GetRequiredService().CreateClient("OAuthDeviceFlow"), + sp.GetService())); + builder.Services.AddSingleton(); + builder.Services + .AddSectionEditor() + .AddSectionEditor() + .AddSectionEditor(); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + + var traceFile = Path.Combine(Path.GetTempPath(), "netclaw-config-trace.log"); + builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Trace); + + builder.Services.AddTermina("/config", t => + { + // Every Termina host uses raw input so native terminal text-selection + // (mouse drag-select) and double-press Ctrl+C handling behave identically + // across `init`, `provider`, `model`, `config`, etc. The `config-*` smoke + // tapes are authored against this same raw-input mode. + ConfigureNativeSelection(t); + t.RegisterRoute("/config"); + t.RegisterRoute("/provider"); + t.RegisterRoute("/model"); + t.RegisterRoute("/channels"); + t.RegisterRoute("/inbound-webhooks"); + t.RegisterRoute("/skill-sources"); + t.RegisterRoute("/search", Termina.Pages.NavigationBehavior.PreserveState); + t.RegisterRoute("/browser-automation"); + t.RegisterRoute("/telemetry-alerting"); + t.RegisterRoute("/workspaces"); + t.RegisterRoute("/security"); + t.RegisterRoute("/exposure-mode"); + t.RegisterRoute("/mcp-tools"); + }); + + using var host = builder.Build(); + var navigationState = host.Services.GetRequiredService(); + await RunTerminaHostAsync(host); + + if (navigationState.PendingAction == ConfigDashboardAction.RunDoctor) + { + var doctorArgs = new[] { "doctor" }; + var doctorBuilder = Host.CreateApplicationBuilder(doctorArgs); + ConfigureConfigServices(doctorBuilder.Services, doctorBuilder.Configuration); + doctorBuilder.Services.AddHttpClient(); + doctorBuilder.Services.AddHttpClient(); + doctorBuilder.Services.AddHttpClient(); + doctorBuilder.Services.AddDoctorChecks(); + doctorBuilder.Logging.ClearProviders(); + doctorBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + + using var doctorHost = doctorBuilder.Build(); + using var scope = doctorHost.Services.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + var result = await runner.RunAsync(); + WriteDoctorResult(result); + Environment.ExitCode = result.ExitCode; + } +} + static async Task RunTerminaHostAsync(IHost host) { try { - if (host.Services.GetService() is not null && Console.IsInputRedirected) + var terminaApplication = host.Services.GetService(); + if (terminaApplication is not null) + host.Services.GetService()?.Attach(terminaApplication); + + if (terminaApplication is not null && Console.IsInputRedirected) { Console.Error.WriteLine( "netclaw: this command is an interactive terminal UI and needs a TTY (stdin is redirected)."); @@ -1129,7 +1270,7 @@ static void WriteGeneralHelp() Console.WriteLine(" init First-run setup wizard"); Console.WriteLine(" update Check for and install updates"); Console.WriteLine(" version, --version Show CLI version"); - Console.WriteLine(" config Configuration management (planned)"); + Console.WriteLine(" config Main post-install settings dashboard"); Console.WriteLine(); Console.WriteLine("Run `netclaw --help` for details on any command."); Console.WriteLine(); @@ -1176,6 +1317,14 @@ static void WriteDaemonDevicesHelp() Console.WriteLine("After revoking, the device will receive 401 on next connection attempt."); } +static void WriteInitHelp() +{ + Console.WriteLine("Usage: netclaw init"); + Console.WriteLine(); + Console.WriteLine("Run the first-run setup wizard for bootstrap configuration."); + Console.WriteLine("Use `netclaw config` for ongoing post-install settings changes."); +} + static void WriteDoctorHelp() { Console.WriteLine("Usage: netclaw doctor"); diff --git a/src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigPage.cs b/src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigPage.cs new file mode 100644 index 000000000..c536f3b7a --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigPage.cs @@ -0,0 +1,156 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class BrowserAutomationConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Enabled.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedBackendIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedRow.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.Prerequisites.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Browser Automation", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + var prereq = ViewModel.Prerequisites.Value; + var layout = Layouts.Vertical() + .WithChild(Header(" Browser Automation")) + .WithChild(Hint(" Adds or removes Netclaw's canonical browser MCP profile. Tool grants stay in MCP permissions.")) + .WithChild(Layouts.Empty().Height(1)); + + layout = layout.WithChild(Row(0, + $"Enabled [{Check(ViewModel.Enabled.Value)}]", + "Create or remove the canonical browser MCP server profile.")); + layout = layout.WithChild(Row(1, + $"Backend {ViewModel.SelectedBackendLabel}", + $"Profile: {ViewModel.SelectedCanonicalServerName}")); + layout = layout.WithChild(Row(2, + "MCP permissions open grant editor", + "Grant browser_automation access per audience in `netclaw mcp permissions`.")); + + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Text($" Runtime check: {prereq.Summary}", prereq.CanEnable ? Color.Green : Color.Yellow)) + .WithChild(Hint(" Manual install guidance:")); + + if (prereq.ManualInstallSteps.Count == 0) + { + layout = layout.WithChild(Hint(" - No manual action detected for the selected backend.")); + } + else + { + foreach (var step in prereq.ManualInstallSteps) + layout = layout.WithChild(Hint($" - {step}")); + } + + if (prereq.MissingPrerequisites.Count > 0) + layout = layout.WithChild(Text($" Missing: {string.Join(", ", prereq.MissingPrerequisites)}", Color.Yellow)); + + return layout; + }); + + return _contentNode; + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space/Enter] Activate [←/→] Backend/Save [Esc] Settings Areas [Ctrl+Q] Quit"); + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + return; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + return; + case ConsoleKey.LeftArrow when ViewModel.SelectedRow.Value == 1: + ViewModel.CycleBackend(-1); + return; + case ConsoleKey.RightArrow when ViewModel.SelectedRow.Value == 1: + ViewModel.CycleBackend(1); + return; + case ConsoleKey.Spacebar: + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + return; + } + } + + private ILayoutNode Row(int index, string label, string description) + { + var focused = index == ViewModel.SelectedRow.Value; + var prefix = focused ? "> " : " "; + var color = focused ? Color.Cyan : Color.White; + return Text($" {prefix}{label,-42} {description}", color); + } + + private static string Check(bool value) => value ? "x" : " "; + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.Gray); + private static TextNode Text(string text, Color color) => new TextNode(text).WithForeground(color); + + private static Color ToColor(ConfigStatusTone tone) + => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.Gray + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigViewModel.cs new file mode 100644 index 000000000..6d2ed5365 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/BrowserAutomationConfigViewModel.cs @@ -0,0 +1,328 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Cli.Mcp; +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed record BrowserAutomationPrerequisiteStatus( + bool CanEnable, + string Summary, + IReadOnlyList MissingPrerequisites, + IReadOnlyList ManualInstallSteps); + +internal interface IBrowserAutomationPrerequisiteProbe +{ + BrowserAutomationPrerequisiteStatus Detect(BrowserAutomationBackend backend); +} + +internal sealed class BrowserAutomationPrerequisiteProbe : IBrowserAutomationPrerequisiteProbe +{ + public BrowserAutomationPrerequisiteStatus Detect(BrowserAutomationBackend backend) + { + var missing = new List(); + var steps = new List(); + + if (!BrowserAutomationRuntimeDetector.HasNodeRuntime()) + { + missing.Add("Node.js with npx"); + steps.Add("Install Node.js 20+ or run the Netclaw browser tooling installer outside this TUI."); + } + + switch (backend) + { + case BrowserAutomationBackend.Playwright: + var browser = BrowserAutomationRuntimeDetector.GetPreferredPlaywrightBrowser(); + if (!BrowserAutomationRuntimeDetector.HasPlaywrightBrowserRuntime(browser)) + { + missing.Add($"Playwright {browser} browser runtime"); + steps.Add($"Install the browser runtime manually: npx -y playwright install {browser}"); + } + break; + case BrowserAutomationBackend.ChromeDevTools: + var chrome = BrowserAutomationRuntimeDetector.DetectChrome(); + if (!chrome.IsInstalled) + { + missing.Add("Chrome or Chromium"); + steps.Add("Install Chrome/Chromium or set CHROME_PATH to the browser executable."); + } + break; + default: + throw new ArgumentOutOfRangeException(nameof(backend), backend, null); + } + + return missing.Count == 0 + ? new BrowserAutomationPrerequisiteStatus(true, "Browser automation prerequisites are available.", [], steps) + : new BrowserAutomationPrerequisiteStatus(false, "Browser automation prerequisites are missing.", missing, steps); + } +} + +internal sealed class BrowserAutomationConfigViewModel : ReactiveViewModel +{ + public const string PlaywrightServerName = "browser_playwright"; + public const string ChromeDevToolsServerName = "browser_chrome_devtools"; + + private static readonly BrowserAutomationBackend[] Backends = + [ + BrowserAutomationBackend.Playwright, + BrowserAutomationBackend.ChromeDevTools + ]; + + private readonly NetclawPaths _paths; + private readonly IBrowserAutomationPrerequisiteProbe _probe; + + public BrowserAutomationConfigViewModel( + NetclawPaths paths, + IBrowserAutomationPrerequisiteProbe? probe = null) + { + _paths = paths; + _probe = probe ?? new BrowserAutomationPrerequisiteProbe(); + var state = LoadState(paths); + Enabled = new ReactiveProperty(state.Enabled); + SelectedBackendIndex = new ReactiveProperty(Array.IndexOf(Backends, state.Backend) is var index && index >= 0 ? index : 0); + SelectedRow = new ReactiveProperty(0); + Status = new ReactiveProperty(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + IsSaved = new ReactiveProperty(false); + Prerequisites = new ReactiveProperty(_probe.Detect(SelectedBackend)); + } + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ReactiveProperty Enabled { get; } + public ReactiveProperty SelectedBackendIndex { get; } + public ReactiveProperty SelectedRow { get; } + public ReactiveProperty Status { get; } + public ReactiveProperty IsSaved { get; } + internal ReactiveProperty Prerequisites { get; } + + public BrowserAutomationBackend SelectedBackend => Backends[SelectedBackendIndex.Value]; + public string SelectedBackendLabel => FormatBackend(SelectedBackend); + public string SelectedCanonicalServerName => GetCanonicalServerName(SelectedBackend); + + public IReadOnlyList Rows { get; } = + [ + "Enabled", + "Backend", + "MCP permissions" + ]; + + public void MoveSelection(int delta) + { + var next = Math.Clamp(SelectedRow.Value + delta, 0, Rows.Count - 1); + if (next != SelectedRow.Value) + SelectedRow.Value = next; + } + + public bool ToggleEnabled() + { + var previous = Enabled.Value; + Enabled.Value = !Enabled.Value; + if (AutosaveCompletedAction( + Enabled.Value + ? $"Browser Automation saved as {SelectedCanonicalServerName}. Use MCP permissions to grant access." + : "Browser Automation disabled and canonical browser MCP profiles removed.")) + { + return true; + } + + Enabled.Value = previous; + IsSaved.Value = false; + RequestRedraw(); + return false; + } + + public bool CycleBackend(int delta) + { + var previousIndex = SelectedBackendIndex.Value; + var next = SelectedBackendIndex.Value + delta; + if (next < 0) + next = Backends.Length - 1; + if (next >= Backends.Length) + next = 0; + + SelectedBackendIndex.Value = next; + Prerequisites.Value = _probe.Detect(SelectedBackend); + if (AutosaveCompletedAction( + Enabled.Value + ? $"Browser Automation saved as {SelectedCanonicalServerName}. Use MCP permissions to grant access." + : "Browser Automation backend preference updated; browser profiles remain disabled.")) + { + return true; + } + + SelectedBackendIndex.Value = previousIndex; + Prerequisites.Value = _probe.Detect(SelectedBackend); + IsSaved.Value = false; + RequestRedraw(); + return false; + } + + public void ActivateSelected() + { + switch (SelectedRow.Value) + { + case 0: + ToggleEnabled(); + break; + case 1: + CycleBackend(1); + break; + case 2: + OpenMcpPermissions(); + break; + } + } + + public bool Save() + => Save(Enabled.Value + ? $"Browser Automation saved as {SelectedCanonicalServerName}. Use MCP permissions to grant access." + : "Browser Automation disabled and canonical browser MCP profiles removed."); + + private bool Save(string successMessage) + { + Prerequisites.Value = _probe.Detect(SelectedBackend); + if (Enabled.Value && !Prerequisites.Value.CanEnable) + { + Status.Value = new ConfigStatusMessage( + $"Cannot enable Browser Automation: {string.Join(", ", Prerequisites.Value.MissingPrerequisites)} missing.", + ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + var servers = ConfigFileHelper.GetOrCreateSection(config, "McpServers"); + servers.Remove(PlaywrightServerName); + servers.Remove(ChromeDevToolsServerName); + + if (Enabled.Value) + { + var (name, entry) = BrowserAutomationMcpProfiles.Create(SelectedBackend); + servers[name] = ToDictionary(entry); + } + else if (servers.Count == 0) + { + config.Remove("McpServers"); + } + + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); + IsSaved.Value = true; + Status.Value = new ConfigStatusMessage(successMessage, ConfigStatusTone.Success); + RequestRedraw(); + return true; + } + + private bool AutosaveCompletedAction(string successMessage) + => ConfigAutosave.Run( + () => Save(successMessage), + Status, + "Browser Automation autosave failed", + RequestRedraw); + + public void OpenMcpPermissions() + { + RouteRequested?.Invoke("/mcp-tools"); + Navigate?.Invoke("/mcp-tools"); + } + + public void GoBack() + { + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + Enabled.Dispose(); + SelectedBackendIndex.Dispose(); + SelectedRow.Dispose(); + Status.Dispose(); + IsSaved.Dispose(); + Prerequisites.Dispose(); + base.Dispose(); + } + + private static (bool Enabled, BrowserAutomationBackend Backend) LoadState(NetclawPaths paths) + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var hasPlaywright = ConfigFileHelper.TryGetPathValue(config, $"McpServers.{PlaywrightServerName}", out var playwrightRaw); + var hasChromeDevTools = ConfigFileHelper.TryGetPathValue(config, $"McpServers.{ChromeDevToolsServerName}", out var chromeRaw); + + if (hasPlaywright && playwrightRaw is not null) + return (IsServerEnabled(playwrightRaw), BrowserAutomationBackend.Playwright); + + if (hasChromeDevTools && chromeRaw is not null) + return (IsServerEnabled(chromeRaw), BrowserAutomationBackend.ChromeDevTools); + + return (false, BrowserAutomationBackend.Playwright); + } + + private static bool IsServerEnabled(object? raw) + { + if (raw is Dictionary dict + && ConfigFileHelper.TryGetPathValue(dict, "Enabled", out var dictEnabled) + && dictEnabled is bool dictFlag) + { + return dictFlag; + } + + if (raw is JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object + && element.TryGetProperty("Enabled", out var enabledProp) + && enabledProp.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return enabledProp.GetBoolean(); + } + + // Default-deny: an entry that exists but omits an explicit boolean `Enabled` is NOT + // enabled. A hand-edited or externally synthesized config without `Enabled` must never + // silently activate a browser MCP server (CLAUDE.md no-silent-fallback + default-deny). + return false; + } + + return false; + } + + private static Dictionary ToDictionary(McpServerEntry entry) + => new() + { + ["Transport"] = entry.Transport, + ["Command"] = entry.Command, + ["Arguments"] = entry.Arguments, + ["EnvironmentVariables"] = entry.EnvironmentVariables, + ["Enabled"] = entry.Enabled, + ["GrantCategory"] = entry.GrantCategory + }; + + private static string GetCanonicalServerName(BrowserAutomationBackend backend) + => backend == BrowserAutomationBackend.ChromeDevTools ? ChromeDevToolsServerName : PlaywrightServerName; + + private static string FormatBackend(BrowserAutomationBackend backend) + => backend switch + { + BrowserAutomationBackend.ChromeDevTools => "Chrome DevTools", + _ => "Playwright" + }; + + private void ClearStatus() + { + if (!string.IsNullOrWhiteSpace(Status.Value.Text)) + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs new file mode 100644 index 000000000..3b83c0142 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigPage.cs @@ -0,0 +1,767 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Cli.Tui.Workflow; +using Netclaw.Configuration; +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ChannelsConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _helpTextNode; + private DynamicLayoutNode? _keyBindingsNode; + private TextInputNode? _singleInput; + private ChannelsConfigScreen? _singleInputScreen; + private string? _singleInputKey; + private readonly Dictionary _credentialInputs = []; + private ChannelType? _credentialInputAdapter; + private readonly CompositeDisposable _stepSubs = []; + + protected override void OnBound() + { + base.OnBound(); + + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.IsSaved.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.Screen.Subscribe(_ => + { + ResetTextInputs(); + InvalidateAll(); + }).DisposeWith(Subscriptions); + ViewModel.Status.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.OnStepContentChanged = () => + { + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + }; + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Channels", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(BuildHelpText()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + if (ViewModel.Screen.Value != ChannelsConfigScreen.Picker) + { + _stepSubs.Clear(); + ViewModel.StepView.ClearFocusState(); + return ViewModel.Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => BuildAdapterMenu(), + ChannelsConfigScreen.ChannelPermissions => BuildChannelPermissions(), + ChannelsConfigScreen.EditAudience => BuildEditAudience(), + ChannelsConfigScreen.AddChannel => BuildAddChannel(), + ChannelsConfigScreen.AllowedUsers => BuildAllowedUsers(), + ChannelsConfigScreen.DirectMessages => BuildDirectMessages(), + ChannelsConfigScreen.RotateCredentials => BuildRotateCredentials(), + ChannelsConfigScreen.ResetConfirm => BuildResetConfirmation(), + _ => Layouts.Empty() + }; + } + + if (!ViewModel.StepView.ManagesOwnFocusState) + { + _stepSubs.Clear(); + ViewModel.StepView.ClearFocusState(); + } + + return ViewModel.StepView.BuildContent(ViewModel.Step, CreateCallbacks()); + }); + + return _contentNode; + } + + private ILayoutNode BuildAdapterMenu() + { + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} is configured.")) + .WithChild(Hint($" {ViewModel.GetActiveAdapterSummary()}")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" What would you like to do?").WithForeground(Color.White)) + .WithChild(Layouts.Empty().Height(1)); + + var items = ViewModel.GetManagementMenuItems(); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + var focused = i == ViewModel.ManagementMenuIndex; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{item.Label,-36} {item.Description}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildChannelPermissions() + { + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Channels & Permissions")) + .WithChild(Hint(" Configure allowed channels and their audience/trust level.")) + .WithChild(Layouts.Empty().Height(1)); + + var rows = ViewModel.GetChannelRows(); + if (rows.All(static row => row.IsAction)) + { + layout = layout.WithChild(Hint(" No allowed channels configured.")); + } + + var editableRows = rows.Where(static row => !row.IsAction).ToArray(); + var displayNameWidth = Math.Clamp( + editableRows.Select(static row => row.DisplayName.Length).DefaultIfEmpty(16).Max(), + 16, + 56); + + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var focused = i == ViewModel.ChannelRowIndex; + if (row.IsUnresolved) + { + // A channel the live probe could not resolve. It was still saved (inert + // allow-list entry), but we mark it red with ✗ so the operator can fix or + // remove it. "✗ " keeps the same 3-char width as FocusPrefix. + var unresolvedLine = $"✗ {Column(row.DisplayName, displayNameWidth)} {AudienceCycle(row.Audience)}"; + layout = layout.WithChild(ConfigSelectionRow.Create(unresolvedLine, focused, Color.Red)); + continue; + } + + var line = row.IsAction + ? $"{FocusPrefix(focused)}{row.DisplayName}" + : $"{FocusPrefix(focused)}{Column(row.DisplayName, displayNameWidth)} {AudienceCycle(row.Audience)}"; + layout = layout.WithChild(Row(line, focused)); + } + + return layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint(" Audience controls which tools and data this channel can use.")); + } + + private ILayoutNode BuildEditAudience() + { + var label = ViewModel.EditingAudienceLabel ?? "channel"; + var id = ViewModel.EditingAudienceId ?? string.Empty; + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > {label}")) + .WithChild(Hint(ViewModel.EditingAudienceIsDm ? " Direct messages" : $" Channel ID: {id}")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" Who is this channel for?").WithForeground(Color.White)) + .WithChild(Layouts.Empty().Height(1)); + + for (var i = 0; i < ChannelsConfigViewModel.AudienceOptions.Count; i++) + { + var audience = ChannelsConfigViewModel.AudienceOptions[i]; + var focused = i == ViewModel.AudienceSelectionIndex; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{AudienceLabel(audience),-10} {AudienceDescription(audience)}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildAddChannel() + { + var input = EnsureSingleInput(ChannelsConfigScreen.AddChannel, "channel", ViewModel.AddChannelInput, ViewModel.AddChannelPlaceholder); + input.OnFocused(); + + // Resolve-before-add: no audience picker here. The channel is resolved + // against the adapter, added at the deployment-posture default audience, + // and tuned afterward with ←/→ on the channel list. + return Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Add Channel")) + .WithChild(new TextNode(" Channel name or ID:").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, "Channel")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" Netclaw resolves the channel on {ViewModel.ActiveAdapterName} and adds it at the default audience.")) + .WithChild(Hint(" Change its audience afterward with ←/→ on the channel list.")); + } + + private ILayoutNode BuildAllowedUsers() + { + var input = EnsureSingleInput(ChannelsConfigScreen.AllowedUsers, "users", ViewModel.AllowedUsersInput, "U123, U456"); + input.OnFocused(); + + return Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Allowed Users")) + .WithChild(Hint(" Leave blank to allow anyone in allowed channels.")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" User IDs:").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, "User IDs")); + } + + private ILayoutNode BuildDirectMessages() + { + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Direct Messages")) + .WithChild(Hint(" Enable DMs only for audiences you trust.")) + .WithChild(Layouts.Empty().Height(1)); + + layout = layout.WithChild(Row( + $"{FocusPrefix(ViewModel.DirectMessagesRowIndex == 0)}[{Check(ViewModel.DirectMessagesEnabled)}] Allow direct messages", + ViewModel.DirectMessagesRowIndex == 0, + ViewModel.DirectMessagesEnabled)); + + var audience = ChannelsConfigViewModel.AudienceOptions[ViewModel.AudienceSelectionIndex]; + layout = layout.WithChild(Row( + $"{FocusPrefix(ViewModel.DirectMessagesRowIndex == 1)}DM audience [< {AudienceLabel(audience),-8} >]", + ViewModel.DirectMessagesRowIndex == 1)); + + return layout; + } + + private ILayoutNode BuildRotateCredentials() + { + var fields = ViewModel.GetCredentialFields(); + var layout = Layouts.Vertical() + .WithChild(Header($" {ViewModel.ActiveAdapterName} > Credentials")) + .WithChild(Hint(" Secret fields are blank by design. Leave blank to keep existing secrets.")) + .WithChild(Layouts.Empty().Height(1)); + + for (var i = 0; i < fields.Count; i++) + { + var field = fields[i]; + var input = EnsureCredentialInput(field); + if (i == ViewModel.CredentialFieldIndex) + Focus.SetFocus(input); + + layout = layout + .WithChild(new TextNode($" {field.Label}:").WithForeground(i == ViewModel.CredentialFieldIndex ? Color.Cyan : Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, field.Label)); + + if (!string.IsNullOrWhiteSpace(field.Hint)) + layout = layout.WithChild(Hint($" {field.Hint}")); + } + + return layout; + } + + private ILayoutNode BuildResetConfirmation() + { + var options = new[] { "Cancel", $"Yes, reset {ViewModel.ActiveAdapterName}" }; + var layout = Layouts.Vertical() + .WithChild(Header($" Reset {ViewModel.ActiveAdapterName} connection?")) + .WithChild(Hint($" This removes {ViewModel.ActiveAdapterName} credentials, allowed channels, allowed users,")) + .WithChild(Hint(" DM settings, and channel permission mappings immediately.")) + .WithChild(Layouts.Empty().Height(1)); + + for (var i = 0; i < options.Length; i++) + { + var focused = i == ViewModel.ResetConfirmIndex; + layout = layout.WithChild(Row($"{FocusPrefix(focused)}{options[i]}", focused)); + } + + return layout; + } + + private LayoutNode BuildHelpText() + { + _helpTextNode = new DynamicLayoutNode(() => + { + if (ViewModel.Screen.Value != ChannelsConfigScreen.Picker) + { + var help = ViewModel.Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => " Manage this adapter without re-entering credentials.", + ChannelsConfigScreen.ChannelPermissions => " Enter edits an audience or activates Done. a adds a channel. Delete removes the selected channel.", + ChannelsConfigScreen.EditAudience => " Select the audience profile for this channel.", + ChannelsConfigScreen.AddChannel => " Enter applies the channel draft. Esc cancels.", + ChannelsConfigScreen.AllowedUsers => " Use comma-separated user IDs. Blank means unrestricted users in allowed channels.", + ChannelsConfigScreen.DirectMessages => " Space toggles DMs. Left/right changes the DM audience.", + ChannelsConfigScreen.RotateCredentials => " Blank secret fields preserve existing secrets. Tab switches fields.", + ChannelsConfigScreen.ResetConfirm => " Reset writes immediately when confirmed.", + _ => string.Empty + }; + return (ILayoutNode)new TextNode(help).WithForeground(Color.Gray); + } + + return (ILayoutNode)new TextNode(ViewModel.Step.GetHelpText()).WithForeground(Color.Gray); + }); + + return _helpTextNode.Height(2); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => (ILayoutNode)(string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone)))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + { + _keyBindingsNode = new DynamicLayoutNode(() => + { + var text = ViewModel.Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => " [↑/↓] Navigate [Enter] Select [Esc] Channels [Ctrl+Q] Quit", + ChannelsConfigScreen.ChannelPermissions => " [↑/↓] Navigate [←/→] Audience [Enter] Edit/Done [a] Add [Del] Remove [Esc] Menu", + ChannelsConfigScreen.EditAudience => " [↑/↓] Navigate [Enter] Apply [Esc] Channels [Ctrl+Q] Quit", + ChannelsConfigScreen.AddChannel => " [Type] Channel [Enter] Resolve & add [Esc] Channels [Ctrl+Q] Quit", + ChannelsConfigScreen.AllowedUsers => " [Enter] Apply [Esc] Menu [Ctrl+Q] Quit", + ChannelsConfigScreen.DirectMessages => " [↑/↓] Navigate [Space] Toggle [←/→] Audience [Enter] Apply [Esc] Menu", + ChannelsConfigScreen.RotateCredentials => " [Tab] Field [Enter] Apply [Esc] Menu [Ctrl+Q] Quit", + ChannelsConfigScreen.ResetConfirm => " [↑/↓] Navigate [Enter] Select [Esc] Menu [Ctrl+Q] Quit", + _ => ViewModel.Step.IsInSubFlow + ? " [Enter] Next [Esc] Back [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Space] Toggle/Save [Enter] Open/Done [Esc] Back [Ctrl+Q] Quit" + }; + + return NetclawTuiChrome.BuildKeyHintLine(text); + }); + + return _keyBindingsNode.Height(1); + } + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (base.HandlePageInput(keyInfo)) + return true; + + return HandleKeyInfo(keyInfo); + } + + private bool HandleKeyInfo(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return true; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return true; + } + + if (ViewModel.Screen.Value != ChannelsConfigScreen.Picker) + { + HandleManagementKey(keyInfo); + return true; + } + + if (TryOpenConfiguredAdapter(keyInfo)) + return true; + + if (keyInfo.Key == ConsoleKey.Spacebar && ViewModel.TryToggleSelectedAdapterFromPicker()) + { + ViewModel.RequestRedraw(); + return true; + } + + if (ViewModel.StepView.HandleKeyPress(new KeyPressed(keyInfo))) + { + ViewModel.RequestRedraw(); + return true; + } + + return false; + } + + private void HandleKeyPress(KeyPressed key) + => HandleKeyInfo(key.KeyInfo); + + private void HandlePaste(PasteEvent paste) + { + if (ViewModel.Screen.Value is ChannelsConfigScreen.AddChannel or ChannelsConfigScreen.AllowedUsers) + { + _singleInput?.HandlePaste(paste); + StageSingleInput(); + ViewModel.RequestRedraw(); + return; + } + + if (ViewModel.Screen.Value == ChannelsConfigScreen.RotateCredentials) + { + var fields = ViewModel.GetCredentialFields(); + if (fields.Count > 0) + { + var field = fields[ViewModel.CredentialFieldIndex]; + if (_credentialInputs.TryGetValue(field.Key, out var input)) + { + input.HandlePaste(paste); + ViewModel.StageCredentialDraftValue(field.Key, input.Text); + ViewModel.RequestRedraw(); + } + } + + return; + } + + ViewModel.StepView.HandlePaste(paste); + ViewModel.RequestRedraw(); + } + + private bool TryOpenConfiguredAdapter(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key is not (ConsoleKey.Enter or ConsoleKey.E)) + return false; + + if (!ViewModel.TryOpenSelectedAdapterManagement()) + return false; + + ViewModel.RequestRedraw(); + return true; + } + + private void HandleManagementKey(ConsoleKeyInfo keyInfo) + { + switch (ViewModel.Screen.Value) + { + case ChannelsConfigScreen.AdapterMenu: + HandleAdapterMenuKey(keyInfo); + break; + case ChannelsConfigScreen.ChannelPermissions: + HandleChannelPermissionsKey(keyInfo); + break; + case ChannelsConfigScreen.EditAudience: + HandleEditAudienceKey(keyInfo); + break; + case ChannelsConfigScreen.AddChannel: + HandleAddChannelKey(keyInfo); + break; + case ChannelsConfigScreen.AllowedUsers: + HandleAllowedUsersKey(keyInfo); + break; + case ChannelsConfigScreen.DirectMessages: + HandleDirectMessagesKey(keyInfo); + break; + case ChannelsConfigScreen.RotateCredentials: + HandleRotateCredentialsKey(keyInfo); + break; + case ChannelsConfigScreen.ResetConfirm: + HandleResetConfirmKey(keyInfo); + break; + } + + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void HandleAdapterMenuKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveManagementMenu(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveManagementMenu(1); + break; + case ConsoleKey.Enter: + ViewModel.ActivateManagementMenuItem(); + break; + } + } + + private void HandleChannelPermissionsKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveChannelRow(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveChannelRow(1); + break; + case ConsoleKey.LeftArrow: + ViewModel.ChangeSelectedChannelAudience(-1); + break; + case ConsoleKey.RightArrow: + ViewModel.ChangeSelectedChannelAudience(1); + break; + case ConsoleKey.Enter: + ViewModel.OpenSelectedChannelAudience(); + break; + case ConsoleKey.A: + ViewModel.BeginAddChannel(); + break; + case ConsoleKey.Delete: + ViewModel.RemoveSelectedChannel(); + break; + } + } + + private void HandleEditAudienceKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveAudienceSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveAudienceSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplyAudienceSelection(); + break; + } + } + + private void HandleAddChannelKey(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Enter) + { + StageSingleInput(); + // Fire-and-forget: the add resolves channels against the platform API, so it runs async + // off the loop (ViewModel serializes the write). Blocking here would freeze the TUI. + _ = ViewModel.AddChannelFromInputAsync(); + return; + } + + _singleInput?.HandleInput(keyInfo); + StageSingleInput(); + } + + private void HandleAllowedUsersKey(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Enter) + { + StageSingleInput(); + ViewModel.ApplyAllowedUsers(); + return; + } + + _singleInput?.HandleInput(keyInfo); + StageSingleInput(); + } + + private void HandleDirectMessagesKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveDirectMessagesRow(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveDirectMessagesRow(1); + break; + case ConsoleKey.Spacebar when ViewModel.DirectMessagesRowIndex == 0: + ViewModel.ToggleDirectMessages(); + break; + case ConsoleKey.LeftArrow when ViewModel.DirectMessagesRowIndex == 1: + ViewModel.ChangeDirectMessageAudience(-1); + break; + case ConsoleKey.RightArrow when ViewModel.DirectMessagesRowIndex == 1: + ViewModel.ChangeDirectMessageAudience(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplyDirectMessages(); + break; + } + } + + private void HandleRotateCredentialsKey(ConsoleKeyInfo keyInfo) + { + var fields = ViewModel.GetCredentialFields(); + if (fields.Count == 0) + return; + + if (keyInfo.Key == ConsoleKey.Tab) + { + ViewModel.MoveCredentialField(1); + return; + } + + if (keyInfo.Key == ConsoleKey.Enter) + { + StageCredentialInput(fields[ViewModel.CredentialFieldIndex]); + ViewModel.ApplyCredentials(); + return; + } + + var field = fields[ViewModel.CredentialFieldIndex]; + var input = EnsureCredentialInput(field); + input.HandleInput(keyInfo); + StageCredentialInput(field); + } + + private void HandleResetConfirmKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveResetConfirmation(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveResetConfirmation(1); + break; + case ConsoleKey.Enter: + // Fire-and-forget: the reset cancels-and-awaits any in-flight label refresh before + // writing, so it runs async off the loop (ViewModel serializes the write). + _ = ViewModel.ResetConfirmationFromInputAsync(); + break; + } + } + + private StepViewCallbacks CreateCallbacks() + => new() + { + Subscriptions = _stepSubs, + InvalidateContent = () => _contentNode?.Invalidate(), + InvalidateHelp = () => _helpTextNode?.Invalidate(), + AdvanceStep = ViewModel.GoNext, + RequestRedraw = ViewModel.RequestRedraw, + SetStatusMessage = message => ViewModel.Status.Value = new ConfigStatusMessage(message, ConfigStatusTone.Error), + }; + + private void InvalidateAll() + { + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + } + + private TextInputNode EnsureSingleInput( + ChannelsConfigScreen screen, + string key, + string? seed, + string placeholder) + { + if (_singleInput is not null && _singleInputScreen == screen && string.Equals(_singleInputKey, key, StringComparison.Ordinal)) + return _singleInput; + + _singleInput = new TextInputNode().WithPlaceholder(placeholder); + _singleInput.Text = seed ?? string.Empty; + if (!string.IsNullOrEmpty(_singleInput.Text)) + _singleInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + _singleInputScreen = screen; + _singleInputKey = key; + return _singleInput; + } + + private TextInputNode EnsureCredentialInput(CredentialFieldSpec field) + { + if (_credentialInputAdapter != ViewModel.ActiveAdapterType) + { + _credentialInputs.Clear(); + _credentialInputAdapter = ViewModel.ActiveAdapterType; + } + + if (_credentialInputs.TryGetValue(field.Key, out var existing)) + return existing; + + var input = new TextInputNode().WithPlaceholder(field.Placeholder); + if (field.IsSecret) + input.AsPassword(); + + input.Text = ViewModel.GetCredentialDraftValue(field.Key) ?? string.Empty; + if (!string.IsNullOrEmpty(input.Text)) + input.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + + _credentialInputs[field.Key] = input; + return input; + } + + private void StageSingleInput() + { + if (_singleInputScreen == ChannelsConfigScreen.AddChannel) + ViewModel.AddChannelInput = _singleInput?.Text; + else if (_singleInputScreen == ChannelsConfigScreen.AllowedUsers) + ViewModel.AllowedUsersInput = _singleInput?.Text; + } + + private void StageCredentialInput(CredentialFieldSpec field) + { + if (_credentialInputs.TryGetValue(field.Key, out var input)) + ViewModel.StageCredentialDraftValue(field.Key, input.Text); + } + + private void ResetTextInputs() + { + _singleInput = null; + _singleInputScreen = null; + _singleInputKey = null; + _credentialInputs.Clear(); + _credentialInputAdapter = null; + } + + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.BrightBlack); + + // Constant indent so non-selected rows keep the same content column the + // focused full-width bar uses (the bar replaces the old ▶ marker). + private static string FocusPrefix(bool focused) => " "; + private static string Check(bool enabled) => enabled ? "✓" : " "; + + private static ILayoutNode Row(string line, bool focused, bool enabled = true) + => ConfigSelectionRow.Create(line, focused, enabled ? Color.White : Color.BrightBlack); + + private static string AudienceLabel(TrustAudience audience) => audience switch + { + TrustAudience.Personal => "Personal", + TrustAudience.Team => "Team", + TrustAudience.Public => "Public", + _ => audience.ToString() + }; + + private static string AudienceDescription(TrustAudience audience) => audience switch + { + TrustAudience.Personal => "Private operator or owner-only context.", + TrustAudience.Team => "Trusted internal channel.", + TrustAudience.Public => "Untrusted or broad audience with strict controls.", + _ => string.Empty + }; + + private static string AudienceCycle(TrustAudience audience) => $"[◀ {AudienceLabel(audience),-8} ▶]"; + + private static string Column(string value, int width) + { + if (value.Length <= width) + return value.PadRight(width); + + return width <= 3 + ? value[..width] + : string.Concat(value.AsSpan(0, width - 3), "..."); + } + + private static Color ToColor(ConfigStatusTone tone) => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.White, + }; + + public override void Dispose() + { + _stepSubs.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs new file mode 100644 index 000000000..ea051ba6e --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsConfigViewModel.cs @@ -0,0 +1,2466 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Diagnostics; +using System.Text.Json; +using Netclaw.Actors.Channels; +using Netclaw.Channels.Slack; +using Netclaw.Cli.Config; +using Netclaw.Cli.Discord; +using Netclaw.Cli.Mattermost; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ChannelsConfigViewModel : ReactiveViewModel +{ + private readonly NetclawPaths _paths; + private readonly ISlackProbe _slackProbe; + private readonly IDiscordProbe _discordProbe; + private readonly IMattermostProbe _mattermostProbe; + private readonly TuiNavigation? _navigation; + private readonly ChannelsConfigPersistenceMapper _mapper = new(); + private readonly ChannelsEditorValidationAdapter _validator = new(); + private readonly WizardContext _context; + private readonly HashSet _knownProviders; + private readonly Dictionary> _channelAudiences = []; + private ChannelType _activeAdapterType = ChannelType.Slack; + private string? _editingAudienceId; + private string? _editingAudienceLabel; + private bool _editingAudienceIsDm; + private int _managementMenuIndex; + private int _channelRowIndex; + private int _audienceSelectionIndex; + private int _directMessagesRowIndex; + private int _resetConfirmIndex; + private CancellationTokenSource? _labelResolutionCts; + private Task? _labelRefreshTask; + + // Cancels every input-triggered config write (and its channel-access probe) when the editor is + // torn down. Fire-and-forget writes resume on thread-pool continuations (the loop has no + // SynchronizationContext), so without a lifetime token a write started just before disposal would + // run its probe to completion and then mutate already-disposed reactive state. Threaded into the + // FromInput entry points; cancelled and drained in Dispose. + private readonly CancellationTokenSource _lifetimeCts = new(); + + public ChannelsConfigViewModel( + NetclawPaths paths, + ISlackProbe slackProbe, + IDiscordProbe discordProbe, + IMattermostProbe mattermostProbe, + TuiNavigation? navigation = null) + { + _paths = paths; + _slackProbe = slackProbe; + _discordProbe = discordProbe; + _mattermostProbe = mattermostProbe; + _navigation = navigation; + Status = new ReactiveProperty(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + Step = new ChannelPickerStepViewModel(slackProbe, discordProbe, mattermostProbe) + { + DoneActionText = "return to Settings Areas", + DoneKeyActionLabel = "Done", + DoneKey = ConsoleKey.D, + ShowDoneAction = false, + ShowDonePickerRow = true, + DonePickerRowLabel = "Done adding channels", + PreserveDisabledAdapterDrafts = true + }; + _context = new WizardContext + { + Paths = paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = RequestRedraw, + ExistingConfig = ConfigFileHelper.LoadJsonDictOrNull(paths.NetclawConfigPath), + SelectedPosture = LoadDeploymentPosture(paths) + }; + + Step.OnEnter(_context, NavigationDirection.Forward); + var draft = _mapper.Load(paths); + _knownProviders = [.. draft.KnownProviders]; + LoadAudienceDrafts(draft); + _mapper.ApplyToStep(Step, draft); + } + + public ChannelPickerStepViewModel Step { get; } + public ChannelPickerStepView StepView { get; } = new(); + public WizardContext Context => _context; + public ReactiveProperty IsSaved { get; } = new(false); + internal ReactiveProperty Screen { get; } = new(ChannelsConfigScreen.Picker); + internal ReactiveProperty Status { get; } + public Action? OnStepContentChanged { get; set; } + + internal bool ShutdownRequestedForTest { get; private set; } + + internal ChannelType ActiveAdapterType => _activeAdapterType; + internal string ActiveAdapterName => GetAdapterDisplayName(_activeAdapterType); + + // Matches the first-connect sub-flow's guidance per adapter. Discord/Mattermost display names rarely + // resolve from a name alone (server+channel ambiguity), so steer the operator to channel IDs there. + internal string AddChannelPlaceholder => _activeAdapterType switch + { + ChannelType.Slack => "channel names or IDs, comma-separated", + _ => "channel IDs, comma-separated" + }; + internal int ManagementMenuIndex => _managementMenuIndex; + internal int ChannelRowIndex => _channelRowIndex; + internal int AudienceSelectionIndex => _audienceSelectionIndex; + internal int DirectMessagesRowIndex => _directMessagesRowIndex; + internal int ResetConfirmIndex => _resetConfirmIndex; + internal string? AddChannelInput { get; set; } + internal string? AllowedUsersInput { get; set; } + internal bool DirectMessagesEnabled { get; set; } + internal string? BotTokenInput { get; set; } + internal string? AppTokenInput { get; set; } + internal string? ServerUrlInput { get; set; } + internal string? CallbackUrlInput { get; set; } + internal int CredentialFieldIndex { get; set; } + + internal static IReadOnlyList AudienceOptions { get; } = + [ + TrustAudience.Personal, + TrustAudience.Team, + TrustAudience.Public + ]; + + public void GoNext() + { + if (Step.IsInSubFlow) + { + var activeAdapter = Step.ActiveAdapterType; + if (Step.TryAdvance()) + { + if (!Step.IsInSubFlow && activeAdapter is { } completedAdapter) + { + OpenChannelPermissionsAfterInitialSetup(completedAdapter); + AutosaveCompletedAction($"{GetAdapterDisplayName(completedAdapter)} channel setup saved."); + } + + NotifyContentChanged(); + } + + return; + } + + ReturnToDashboard(); + } + + public void GoBack() + { + if (Screen.Value != ChannelsConfigScreen.Picker) + { + GoBackWithinManagement(); + return; + } + + if (Step.IsInSubFlow && Step.TryGoBack()) + { + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + return; + } + + ReturnToDashboard(); + } + + public async Task SaveAsync(CancellationToken ct = default) + => await SaveAsync("Channels saved.", probeChannelAccess: true, ct); + + private async Task SaveAsync(string successMessage, bool probeChannelAccess, CancellationToken ct = default) + { + // A background channel-label refresh may be in flight; cancel and await it before we + // validate, write, or reset adapter state so it cannot race this save. + await CancelAndAwaitLabelRefreshAsync(); + + var validation = ValidateCurrentStep(); + if (validation.HasErrors) + { + Status.Value = BuildValidationErrorStatus(validation, "Fix channel validation errors before saving."); + RequestRedraw(); + return false; + } + + // Autosave of a completed action persists immediately and does NOT block the UI loop on a + // network channel-access probe: the action that triggered it was already validated (add + // resolves before adding; toggle/remove/audience do not introduce a channel), unresolved + // names stay inert in the ACL, and the background label refresh re-validates asynchronously. + // Only an explicit Save runs the probe and may block on a genuine probe failure. + IReadOnlyList unresolved = []; + if (probeChannelAccess) + { + Status.Value = new ConfigStatusMessage("Validating channel access...", ConfigStatusTone.Neutral); + RequestRedraw(); + + var dynamicValidation = await ValidateChannelAccessAsync(ct); + if (dynamicValidation.Result.HasErrors) + { + // Only a genuine probe failure (bad token / unreachable, surfaced as an + // ErrorMessage) blocks here — we could not validate at all, so persisting + // nothing is correct. Merely-unresolved channel names are NOT errors: they + // persist verbatim and are flagged non-blockingly (see ValidateChannelAccessAsync). + Status.Value = BuildValidationErrorStatus(dynamicValidation.Result, "Fix channel validation errors before saving."); + RequestRedraw(); + return false; + } + + unresolved = dynamicValidation.Unresolved; + } + + WriteChannelConfigToDisk(); + + var savedDraft = _mapper.Load(_paths); + _knownProviders.Clear(); + foreach (var provider in savedDraft.KnownProviders) + _knownProviders.Add(provider); + + LoadAudienceDrafts(savedDraft); + Step.OnEnter(_context, NavigationDirection.Forward); + _mapper.ApplyToStep(Step, savedDraft); + IsSaved.Value = true; + Status.Value = BuildSaveStatus(successMessage, unresolved); + NotifyContentChanged(); + return true; + } + + // Writes the current in-memory Step (tokens + channels + audiences) to disk. The reload + // that SaveAsync performs afterward is deliberately NOT done here so callers that only + // touch the persisted shape (e.g. label normalization) keep their live resolution state. + private void WriteChannelConfigToDisk() + { + var session = new ConfigEditorSession(_paths); + session.Apply(_mapper.BuildContribution( + Step, + _knownProviders, + _channelAudiences, + _context.SelectedPosture ?? DeploymentPosture.Personal)); + session.Save(); + } + + // The probe succeeded but some channel names/ids did not resolve. We saved the + // whole adapter anyway (token + resolved channels + unresolved names kept as-is) + // and flag the unresolved entries non-blockingly so the operator can fix or + // remove them. An unresolved name persisted into AllowedChannelIds is an inert + // allow-list entry — it matches no real channel ID, so it grants nothing. + private static ConfigStatusMessage BuildSaveStatus(string successMessage, IReadOnlyList unresolved) + => unresolved.Count == 0 + ? new ConfigStatusMessage(successMessage, ConfigStatusTone.Success) + : new ConfigStatusMessage( + $"Saved. Could not resolve: {string.Join(", ", unresolved.Select(static name => $"#{name}"))} — flagged below; fix or remove them.", + ConfigStatusTone.Warning); + + // Single autosave wrapper for both the explicit save (probe on) and the completed-action autosaves + // (probe off, via SaveCompletedAsync). ConfigAutosave.RunAsync catches any failure and surfaces it + // as an Error status; this returns whether the save succeeded. + private Task SaveViaAutosaveAsync(string successMessage, bool probeChannelAccess, CancellationToken ct) + => ConfigAutosave.RunAsync( + token => SaveAsync(successMessage, probeChannelAccess, token), + Status, + "Channel settings save failed", + RequestRedraw, + ct); + + internal Task SaveFromInputAsync(CancellationToken ct = default) + => SaveViaAutosaveAsync("Channels saved.", probeChannelAccess: true, ct); + + // Input-triggered config writes (autosave, add-channel, reset) run async OFF the Termina loop so + // they never freeze input/rendering on a probe or disk write. The sync .GetAwaiter().GetResult() + // bridges they replaced were implicitly serialized by the single-threaded loop — exactly one ran + // start-to-finish before the next input. Preserve that ordering explicitly by chaining each write + // behind the previous one, so two rapid mutations can't race the disk write + state reload. In the + // common case (no in-flight label refresh) the prior task is already complete, so the chain runs + // synchronously inline and adds no overhead. Touched only on the loop thread (and the test thread, + // also single-threaded), so the field assignment needs no synchronization. Exposed as + // PendingConfigWrite so tests await completion deterministically instead of sleeping. + private Task _pendingConfigWrite = Task.CompletedTask; + + internal Task PendingConfigWrite => _pendingConfigWrite; + + private Task EnqueueConfigWriteAsync(Func write) + { + var prior = _pendingConfigWrite; + var next = ChainAsync(); + _pendingConfigWrite = next; + return next; + + async Task ChainAsync() + { + // The prior write already surfaced its own failure status via ConfigAutosave; swallowing + // here only prevents one failed write from cancelling the writes queued behind it. + try { await prior; } + catch (Exception priorFailure) + { + // Defensive: every write is exception-safe (autosave via ConfigAutosave, reset/add via + // their own catches), so a faulted prior is not expected. Swallow it here only so one + // failed write can't cancel the writes queued behind it; the prior already surfaced its + // own status. Trace in case this path is ever hit. + Debug.WriteLine($"ChannelsConfig: prior config write faulted (already surfaced): {priorFailure.Message}"); + } + + await write(); + } + } + + // Dispatched fire-and-forget from the synchronous Termina key handler. The loop stays responsive + // while the add resolves channels against the platform API; the write itself is serialized behind + // any prior config write by EnqueueConfigWriteAsync. + internal Task AddChannelFromInputAsync() + => EnqueueConfigWriteAsync(() => ApplyAddChannelAsync(_lifetimeCts.Token)); + + internal Task ResetConfirmationFromInputAsync() + => EnqueueConfigWriteAsync(() => ApplyResetConfirmationAsync(_lifetimeCts.Token)); + + internal bool TryOpenSelectedAdapterManagement() + { + if (!Step.IsInPickerMode || !Step.IsAdapterRowSelected) + return false; + + var type = Step.SelectedAdapterType; + if (!Step.IsAdapterKnown(type)) + return false; + + OpenAdapterManagement(type); + return true; + } + + internal bool TryToggleSelectedAdapterFromPicker() + { + if (!Step.IsInPickerMode || !Step.IsAdapterRowSelected) + return false; + + var type = Step.SelectedAdapterType; + var selectedIndex = GetAdapterIndex(type); + var wasEnabled = Step.IsAdapterEnabled(type); + _activeAdapterType = type; + + Step.ToggleAdapter(selectedIndex); + if (Step.IsInSubFlow) + return true; + + UpdateAdapterPickerSummary(type); + AutosaveCompletedAction($"{GetAdapterDisplayName(type)} {(!wasEnabled ? "enabled" : "disabled")} and saved."); + NotifyContentChanged(); + return true; + } + + internal void OpenAdapterManagement(ChannelType type) + { + _activeAdapterType = type; + _managementMenuIndex = 0; + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + private void OpenChannelPermissionsAfterInitialSetup(ChannelType type) + { + _activeAdapterType = type; + _channelRowIndex = 0; + UpdateAdapterPickerSummary(type); + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + Status.Value = new ConfigStatusMessage( + $"Set {GetAdapterDisplayName(type)} channel audiences. Completed actions save automatically.", + ConfigStatusTone.Neutral); + StartChannelLabelResolution(type); + } + + internal async Task RefreshChannelLabelsAsync(ChannelType type, CancellationToken ct = default) + { + if (!Step.IsAdapterEnabled(type)) + return; + + var channelIds = GetChannelIds(type); + if (channelIds.Count == 0) + return; + + try + { + var resolution = await ResolveChannelReferencesAsync(type, channelIds, ct); + if (resolution is null || ct.IsCancellationRequested) + return; + + ReconcileResolvedChannels(type, channelIds, resolution.Value.Error, resolution.Value.Resolved); + NotifyContentChanged(); + } + catch (Exception ex) + { + // A superseded background refresh (a newer resolution started, or the user navigated + // away) cancels via ct — that is not a lookup failure, so abandon it quietly rather than + // surfacing a warning. Any other failure surfaces the warning status. + if (ex is OperationCanceledException && ct.IsCancellationRequested) + return; + + Status.Value = new ConfigStatusMessage( + $"{GetAdapterDisplayName(type)} channel label lookup failed: {ex.Message}", + ConfigStatusTone.Warning); + } + } + + internal IReadOnlyList GetManagementMenuItems() + { + var enabled = Step.IsAdapterEnabled(_activeAdapterType); + return + [ + new ChannelsManagementMenuItem(ChannelsManagementAction.ManageChannels, "Manage channels and permissions", "Edit allowed channels and audience levels."), + new ChannelsManagementMenuItem(ChannelsManagementAction.AddChannel, $"Add a {ActiveAdapterName} channel", "Add channel ingress without touching credentials."), + new ChannelsManagementMenuItem(ChannelsManagementAction.ManageUsers, "Manage allowed users", "Restrict messages to specific user IDs."), + new ChannelsManagementMenuItem(ChannelsManagementAction.DirectMessages, "Direct messages", "Enable or disable DM ingress and audience."), + new ChannelsManagementMenuItem(ChannelsManagementAction.RotateCredentials, "Rotate credentials", "Replace tokens only when explicitly entered."), + new ChannelsManagementMenuItem(ChannelsManagementAction.ToggleEnabled, enabled ? $"Disable {ActiveAdapterName}" : $"Enable {ActiveAdapterName}", "Preserve saved setup while changing runtime state."), + new ChannelsManagementMenuItem(ChannelsManagementAction.ResetConnection, $"Reset {ActiveAdapterName} connection", "Remove saved config and credentials.") + ]; + } + + internal void MoveManagementMenu(int delta) + { + _managementMenuIndex = Clamp(_managementMenuIndex + delta, GetManagementMenuItems().Count); + NotifyContentChanged(); + } + + internal void ActivateManagementMenuItem() + { + var item = GetManagementMenuItems()[_managementMenuIndex]; + switch (item.Action) + { + case ChannelsManagementAction.ManageChannels: + _channelRowIndex = 0; + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + StartChannelLabelResolution(_activeAdapterType); + break; + case ChannelsManagementAction.AddChannel: + BeginAddChannel(); + break; + case ChannelsManagementAction.ManageUsers: + BeginAllowedUsers(); + break; + case ChannelsManagementAction.DirectMessages: + BeginDirectMessages(); + break; + case ChannelsManagementAction.RotateCredentials: + BeginRotateCredentials(); + break; + case ChannelsManagementAction.ToggleEnabled: + SetActiveAdapterEnabled(!Step.IsAdapterEnabled(_activeAdapterType)); + Screen.Value = ChannelsConfigScreen.Picker; + break; + case ChannelsManagementAction.ResetConnection: + _resetConfirmIndex = 0; + Screen.Value = ChannelsConfigScreen.ResetConfirm; + break; + } + + NotifyContentChanged(); + } + + internal string GetActiveAdapterSummary() + { + var channelCount = GetChannelIds(_activeAdapterType).Count; + var userCount = GetAllowedUserIds(_activeAdapterType).Count; + var credentials = GetCredentialSummary(_activeAdapterType); + var dm = GetAllowDirectMessages(_activeAdapterType) ? "enabled" : "disabled"; + var enabled = Step.IsAdapterEnabled(_activeAdapterType) ? "enabled" : "disabled"; + return $"{enabled} · {credentials} · {Pluralize(channelCount, "channel", "channels")} · {Pluralize(userCount, "user", "users")} · DMs {dm}"; + } + + internal IReadOnlyList GetChannelRows(bool includeAddAction = true) + { + var rows = new List(); + var unresolved = GetActiveAdapterUnresolved(); + foreach (var channelId in GetChannelIds(_activeAdapterType)) + { + rows.Add(new ChannelPermissionRow( + channelId, + FormatChannelLabel(_activeAdapterType, channelId), + GetChannelAudience(_activeAdapterType, channelId, DefaultChannelAudience()), + IsDirectMessage: false, + IsAddAction: false, + IsDoneAction: false, + IsUnresolved: unresolved.Contains(channelId))); + } + + if (GetAllowDirectMessages(_activeAdapterType)) + { + rows.Add(new ChannelPermissionRow( + "dm", + "Direct messages", + GetChannelAudience(_activeAdapterType, "dm", DefaultDirectMessageAudience()), + IsDirectMessage: true, + IsAddAction: false, + IsDoneAction: false)); + } + + if (includeAddAction) + { + rows.Add(new ChannelPermissionRow( + string.Empty, + "+ Add channel", + DefaultChannelAudience(), + IsDirectMessage: false, + IsAddAction: true, + IsDoneAction: false)); + rows.Add(new ChannelPermissionRow( + string.Empty, + "Done adding channels", + DefaultChannelAudience(), + IsDirectMessage: false, + IsAddAction: false, + IsDoneAction: true)); + } + + if (_channelRowIndex >= rows.Count) + _channelRowIndex = Math.Max(rows.Count - 1, 0); + + return rows; + } + + internal void MoveChannelRow(int delta) + { + _channelRowIndex = Clamp(_channelRowIndex + delta, GetChannelRows().Count); + NotifyContentChanged(); + } + + internal void OpenSelectedChannelAudience() + { + var rows = GetChannelRows(); + if (rows.Count == 0) + return; + + var row = rows[_channelRowIndex]; + if (row.IsAddAction) + { + BeginAddChannel(); + return; + } + + if (row.IsDoneAction) + { + FinishChannelPermissions(); + return; + } + + _editingAudienceId = row.Id; + _editingAudienceLabel = row.DisplayName; + _editingAudienceIsDm = row.IsDirectMessage; + _audienceSelectionIndex = AudienceIndex(row.Audience); + Screen.Value = ChannelsConfigScreen.EditAudience; + NotifyContentChanged(); + } + + internal void ChangeSelectedChannelAudience(int delta) + { + var rows = GetChannelRows(); + if (rows.Count == 0) + return; + + var row = rows[_channelRowIndex]; + if (row.IsAction) + return; + + var currentIndex = AudienceIndex(row.Audience); + var next = AudienceOptions[Wrap(currentIndex + delta, AudienceOptions.Count)]; + SetChannelAudience(_activeAdapterType, row.Id, next); + // Autosave like every other ChannelPermissions mutation (RemoveSelectedChannel, + // ApplyAddChannel): this ←/→ toggle sets a security-relevant ACL trust tier, and without an + // immediate save an Esc would silently discard it (the next load resets from disk). + AutosaveCompletedAction($"{row.DisplayName} audience set to {next} and saved."); + } + + internal void RemoveSelectedChannel() + { + var rows = GetChannelRows(); + if (rows.Count == 0) + return; + + var row = rows[_channelRowIndex]; + if (row.IsAction || row.IsDirectMessage) + return; + + var remaining = GetChannelIds(_activeAdapterType) + .Where(id => !string.Equals(id, row.Id, StringComparison.Ordinal)) + .ToArray(); + SetChannelIds(_activeAdapterType, remaining); + if (_channelAudiences.TryGetValue(_activeAdapterType, out var audiences)) + audiences.Remove(row.Id); + + UpdateAdapterPickerSummary(_activeAdapterType); + _channelRowIndex = Clamp(_channelRowIndex, GetChannelRows().Count); + AutosaveCompletedAction($"Removed {row.DisplayName} and saved."); + NotifyContentChanged(); + } + + internal void BeginAddChannel() + { + AddChannelInput = null; + _audienceSelectionIndex = AudienceIndex(DefaultChannelAudience()); + Screen.Value = ChannelsConfigScreen.AddChannel; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + /// + /// Appends the typed channel reference(s) to the active adapter, then canonicalizes them through the + /// SAME path the first-connect flow uses (): an id-shaped + /// reference is kept as the stable ACL key (even when the bot can't enumerate it — e.g. a Discord + /// channel id), a resolvable display name becomes its id, and anything that maps to no channel id is + /// dropped and flagged. Accepts a comma-separated list via the one shared ChannelCsv parser, + /// same as onboarding. Audiences are tuned afterward with ←/→ on the channel list. + /// + internal async Task ApplyAddChannelAsync(CancellationToken ct = default) + { + var references = ChannelCsv.ParseCsv(AddChannelInput, trimHash: true); + if (references.Count == 0) + { + Status.Value = new ConfigStatusMessage("At least one channel is required.", ConfigStatusTone.Error); + NotifyContentChanged(); + return; + } + + var existing = GetChannelIds(_activeAdapterType); + var existingSet = new HashSet(existing, StringComparer.Ordinal); + var fresh = references.Where(reference => existingSet.Add(reference)).ToList(); + if (fresh.Count == 0) + { + Status.Value = new ConfigStatusMessage("Those channels are already configured.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + return; + } + + // Append the typed references with the default audience and move to the permissions list, exactly + // like completing the first-connect sub-flow. Settle ALL on-loop UI state — the screen and the + // focus on the newly added row — synchronously, BEFORE the async persist. This runs fire-and-forget + // from the key handler, so a subsequent keypress must navigate from a deterministic position; the + // async tail below only updates status + labels (via RequestRedraw), never navigation or focus. The + // row index is computed from the in-memory append, which the save's reload preserves. + SetChannelIds(_activeAdapterType, [.. existing, .. fresh]); + foreach (var reference in fresh) + SetChannelAudience(_activeAdapterType, reference, DefaultChannelAudience()); + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + + var lastRow = GetChannelRows() + .Select((row, index) => (row, index)) + .LastOrDefault(entry => !entry.row.IsDirectMessage && !entry.row.IsAction); + if (lastRow.row is not null) + _channelRowIndex = lastRow.index; + NotifyContentChanged(); + + // Persist the appended list, then canonicalize through the shared reconcile — the front door. It + // resolves display names to ids, keeps id-shaped references the bot can't enumerate, drops the + // unmappable ones, and sets the final status. There is no bespoke single-channel resolver. + // Already inside an enqueued config write (AddChannelFromInputAsync), so persist via the + // awaitable autosave directly rather than re-enqueueing — re-enqueue would deadlock the add + // behind itself. The bool gates the follow-up label refresh. + if (await SaveCompletedAsync(fresh.Count == 1 + ? $"Added {fresh[0]} at the {DefaultChannelAudience()} default and saved." + : $"Added {Pluralize(fresh.Count, "channel", "channels")} and saved.", ct)) + await RefreshChannelLabelsAsync(_activeAdapterType, ct); + } + + internal void FinishChannelPermissions() + { + Screen.Value = ChannelsConfigScreen.AdapterMenu; + Status.Value = new ConfigStatusMessage("Done adding channels. Completed changes are already saved.", ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal string? EditingAudienceLabel => _editingAudienceLabel; + internal string? EditingAudienceId => _editingAudienceId; + internal bool EditingAudienceIsDm => _editingAudienceIsDm; + + internal void MoveAudienceSelection(int delta) + { + _audienceSelectionIndex = Wrap(_audienceSelectionIndex + delta, AudienceOptions.Count); + NotifyContentChanged(); + } + + internal void ApplyAudienceSelection() + { + if (string.IsNullOrWhiteSpace(_editingAudienceId)) + return; + + SetChannelAudience(_activeAdapterType, _editingAudienceId, AudienceOptions[_audienceSelectionIndex]); + Screen.Value = ChannelsConfigScreen.ChannelPermissions; + AutosaveCompletedAction($"Updated {_editingAudienceLabel} audience and saved."); + NotifyContentChanged(); + } + + internal void BeginAllowedUsers() + { + AllowedUsersInput = ChannelCsv.JoinOrNull(GetAllowedUserIds(_activeAdapterType)); + Screen.Value = ChannelsConfigScreen.AllowedUsers; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void ApplyAllowedUsers() + { + var userIds = ChannelCsv.ParseCsv(AllowedUsersInput, trimHash: false); + SetAllowedUserIds(_activeAdapterType, userIds); + UpdateAdapterPickerSummary(_activeAdapterType); + Screen.Value = ChannelsConfigScreen.AdapterMenu; + AutosaveCompletedAction("Allowed users saved."); + NotifyContentChanged(); + } + + internal void BeginDirectMessages() + { + DirectMessagesEnabled = GetAllowDirectMessages(_activeAdapterType); + _directMessagesRowIndex = 0; + _audienceSelectionIndex = AudienceIndex(GetChannelAudience(_activeAdapterType, "dm", DefaultDirectMessageAudience())); + Screen.Value = ChannelsConfigScreen.DirectMessages; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal void MoveDirectMessagesRow(int delta) + { + _directMessagesRowIndex = Clamp(_directMessagesRowIndex + delta, 2); + NotifyContentChanged(); + } + + internal void ToggleDirectMessages() + { + DirectMessagesEnabled = !DirectMessagesEnabled; + NotifyContentChanged(); + } + + internal void ChangeDirectMessageAudience(int delta) + { + _audienceSelectionIndex = Wrap(_audienceSelectionIndex + delta, AudienceOptions.Count); + NotifyContentChanged(); + } + + internal void ApplyDirectMessages() + { + SetAllowDirectMessages(_activeAdapterType, DirectMessagesEnabled); + SetChannelAudience(_activeAdapterType, "dm", AudienceOptions[_audienceSelectionIndex]); + UpdateAdapterPickerSummary(_activeAdapterType); + Screen.Value = ChannelsConfigScreen.AdapterMenu; + AutosaveCompletedAction("Direct message settings saved."); + NotifyContentChanged(); + } + + internal void BeginRotateCredentials() + { + BotTokenInput = null; + AppTokenInput = null; + ServerUrlInput = GetServerUrl(_activeAdapterType); + CallbackUrlInput = GetCallbackUrl(_activeAdapterType); + CredentialFieldIndex = 0; + Screen.Value = ChannelsConfigScreen.RotateCredentials; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + internal IReadOnlyList GetCredentialFields() + { + return _activeAdapterType switch + { + ChannelType.Slack => + [ + new CredentialFieldSpec("bot", "Bot token", IsSecret: true, "xoxb-...", GetCredentialPresenceText("bot")), + new CredentialFieldSpec("app", "App token", IsSecret: true, "xapp-...", GetCredentialPresenceText("app")) + ], + ChannelType.Discord => + [ + new CredentialFieldSpec("bot", "Bot token", IsSecret: true, "Discord bot token", GetCredentialPresenceText("bot")) + ], + ChannelType.Mattermost => + [ + new CredentialFieldSpec("server", "Server URL", IsSecret: false, "https://mattermost.example.com", null), + new CredentialFieldSpec("bot", "Bot token", IsSecret: true, "Mattermost bot token", GetCredentialPresenceText("bot")), + new CredentialFieldSpec("callback", "Callback URL", IsSecret: false, "https://netclaw.example.com/api/mattermost/actions", "Optional interactive button callback URL.") + ], + _ => [] + }; + } + + internal string? GetCredentialDraftValue(string key) => key switch + { + "bot" => BotTokenInput, + "app" => AppTokenInput, + "server" => ServerUrlInput, + "callback" => CallbackUrlInput, + _ => null + }; + + internal void StageCredentialDraftValue(string key, string? value) + { + switch (key) + { + case "bot": + BotTokenInput = value; + break; + case "app": + AppTokenInput = value; + break; + case "server": + ServerUrlInput = value; + break; + case "callback": + CallbackUrlInput = value; + break; + } + } + + internal void MoveCredentialField(int delta) + { + CredentialFieldIndex = Clamp(CredentialFieldIndex + delta, GetCredentialFields().Count); + NotifyContentChanged(); + } + + internal void ApplyCredentials() + { + var issue = ValidateCredentialDrafts(); + if (issue is not null) + { + Status.Value = new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + NotifyContentChanged(); + return; + } + + switch (_activeAdapterType) + { + case ChannelType.Slack: + var slack = Step.GetAdapterViewModel(ChannelType.Slack); + slack.BotToken = Normalize(BotTokenInput); + slack.AppToken = Normalize(AppTokenInput); + break; + case ChannelType.Discord: + Step.GetAdapterViewModel(ChannelType.Discord).BotToken = Normalize(BotTokenInput); + break; + case ChannelType.Mattermost: + var mattermost = Step.GetAdapterViewModel(ChannelType.Mattermost); + mattermost.ServerUrl = Normalize(ServerUrlInput); + mattermost.BotToken = Normalize(BotTokenInput); + mattermost.CallbackUrl = Normalize(CallbackUrlInput); + break; + } + + Screen.Value = ChannelsConfigScreen.AdapterMenu; + AutosaveCompletedAction("Credential changes saved."); + NotifyContentChanged(); + } + + private ChannelsEditorValidationResult ValidateCurrentStep() + => _validator.Validate(ChannelsEditorModel.FromStep(Step)); + + // Carries the genuinely blocking validation issues (probe failure / !Success) + // separately from the non-blocking unresolved channel names that we persist and + // flag rather than reject. + private readonly record struct ChannelAccessValidation( + ChannelsEditorValidationResult Result, + IReadOnlyList Unresolved); + + private async Task ValidateChannelAccessAsync(CancellationToken ct) + { + var issues = new List(); + var unresolved = new List(); + + var slack = await ValidateSlackChannelsAsync(ct); + ApplyChannelAccessOutcome(slack, issues, unresolved); + + var discord = await ValidateDiscordChannelsAsync(ct); + ApplyChannelAccessOutcome(discord, issues, unresolved); + + var mattermost = await ValidateMattermostChannelsAsync(ct); + ApplyChannelAccessOutcome(mattermost, issues, unresolved); + + var result = issues.Count == 0 + ? ChannelsEditorValidationResult.Empty + : new ChannelsEditorValidationResult(issues); + return new ChannelAccessValidation(result, unresolved); + } + + private static void ApplyChannelAccessOutcome( + ChannelAccessOutcome outcome, + List issues, + List unresolved) + { + if (outcome.BlockingIssue is not null) + issues.Add(outcome.BlockingIssue); + + unresolved.AddRange(outcome.Unresolved); + } + + // Result of probing one adapter's channels: a blocking issue only when the probe + // itself failed, plus the names/ids that the probe could not resolve (non-blocking). + // The operator chose fail-loud (no inert allow-list entries): a channel that cannot be resolved + // to an id the runtime ACL will match blocks the save with this message until it is fixed/removed. + private static string BuildUnresolvedChannelMessage(IReadOnlyList unresolved) + => $"Could not resolve {string.Join(", ", unresolved.Select(static channel => $"#{channel}"))} to a channel the bot can see — fix or remove before saving (an unmatchable channel grants nothing)."; + + private readonly record struct ChannelAccessOutcome( + ChannelsEditorValidationIssue? BlockingIssue, + IReadOnlyList Unresolved) + { + internal static ChannelAccessOutcome None { get; } = new(null, []); + internal static ChannelAccessOutcome Blocked(ChannelsEditorValidationIssue issue) => new(issue, []); + internal static ChannelAccessOutcome Flagged(IReadOnlyList unresolved) => new(null, unresolved); + } + + private async Task ValidateSlackChannelsAsync(CancellationToken ct) + { + if (!Step.IsAdapterEnabled(ChannelType.Slack)) + return ChannelAccessOutcome.None; + + var slack = Step.GetAdapterViewModel(ChannelType.Slack); + var configuredChannels = ChannelCsv.ParseCsv(slack.ChannelNamesInput, trimHash: true); + var namesToResolve = configuredChannels + .Where(static channel => !IsSlackChannelId(channel)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (namesToResolve.Length == 0) + return ChannelAccessOutcome.None; + + var botToken = GetEffectiveSecret("Slack.BotToken", slack.BotToken, slack.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.SlackBotToken, ChannelsEditorValidationMessages.SlackBotTokenRequired)); + + var result = await _slackProbe.ResolveChannelNamesAsync(botToken, namesToResolve, ct); + slack.LastChannelResolution = result; + + // The probe itself failed — we cannot validate at all, so block the save. Only a + // genuine failure (auth/scope/network/timeout) sets ErrorMessage. NOTE: do NOT also + // block on !result.Success: the probe sets Success = "did EVERY name resolve?", so it + // is false whenever any name is merely not found — which must stay non-blocking, or a + // single unverifiable channel drops the whole adapter (token + valid channels) again. + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.SlackAllowedChannelIds, $"Slack channel lookup failed: {result.ErrorMessage}")); + + // Probe reachable. Map resolved names to IDs. + var resolvedByName = result.Resolved.ToDictionary( + static channel => channel.Name, + static channel => channel.Id, + StringComparer.OrdinalIgnoreCase); + var remap = new Dictionary(StringComparer.Ordinal); + var resolvedChannels = new List(); + + foreach (var channel in configuredChannels) + { + if (IsSlackChannelId(channel)) + { + resolvedChannels.Add(channel); + continue; + } + + if (resolvedByName.TryGetValue(channel, out var channelId)) + { + resolvedChannels.Add(channelId); + remap[channel] = channelId; + } + + // Unresolved names are intentionally NOT added — see the fail-loud block below. + } + + // Fail loud: a name that does not resolve to a real channel id is an inert allow-list entry + // that the runtime ACL (SlackAclPolicy, ordinal id match) can never match, so it would + // silently grant nothing. Block the save and make the operator fix or remove it rather than + // persisting a dead entry. Do not mutate the draft on a blocked save. + if (result.Unresolved.Count > 0) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.SlackAllowedChannelIds, BuildUnresolvedChannelMessage(result.Unresolved))); + + SetChannelIds(ChannelType.Slack, [.. resolvedChannels.Distinct(StringComparer.Ordinal)]); + RemapChannelAudiences(ChannelType.Slack, remap); + UpdateAdapterPickerSummary(ChannelType.Slack); + return ChannelAccessOutcome.None; + } + + private async Task ValidateDiscordChannelsAsync(CancellationToken ct) + { + if (!Step.IsAdapterEnabled(ChannelType.Discord)) + return ChannelAccessOutcome.None; + + var discord = Step.GetAdapterViewModel(ChannelType.Discord); + var channelIds = ChannelCsv.ParseCsv(discord.ChannelIdsInput, trimHash: true); + if (channelIds.Count == 0) + return ChannelAccessOutcome.None; + + var botToken = GetEffectiveSecret("Discord.BotToken", discord.BotToken, discord.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.DiscordBotToken, ChannelsEditorValidationMessages.DiscordBotTokenRequired)); + + var result = await _discordProbe.ResolveChannelIdsAsync(botToken, channelIds, ct); + discord.LastChannelResolution = result; + + // Only a genuine probe failure (ErrorMessage) blocks. result.Success is false whenever + // any id is unresolved (Success = "did every id resolve?"), which must stay non-blocking. + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.DiscordAllowedChannelIds, $"Discord channel lookup failed: {result.ErrorMessage}")); + + // Fail loud: a reference that does not resolve to a real channel the bot can see is an inert + // allow-list entry the runtime ACL can never match, so block the save rather than persist a + // dead entry. + if (result.Unresolved.Count > 0) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.DiscordAllowedChannelIds, BuildUnresolvedChannelMessage(result.Unresolved))); + + // All references resolved: map any names to their channel ids and persist the ids (the + // runtime ACL matches ids, not names). Mirrors the Slack validator. + SetResolvedChannels(ChannelType.Discord, channelIds, result.Resolved.Select(c => (c.ChannelId, c.ChannelName))); + return ChannelAccessOutcome.None; + } + + private async Task ValidateMattermostChannelsAsync(CancellationToken ct) + { + if (!Step.IsAdapterEnabled(ChannelType.Mattermost)) + return ChannelAccessOutcome.None; + + var mattermost = Step.GetAdapterViewModel(ChannelType.Mattermost); + var channelIds = ChannelCsv.ParseCsv(mattermost.ChannelIdsInput, trimHash: true); + if (channelIds.Count == 0) + return ChannelAccessOutcome.None; + + var serverUrl = Normalize(mattermost.ServerUrl); + if (string.IsNullOrWhiteSpace(serverUrl)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.MattermostServerUrl, ChannelsEditorValidationMessages.MattermostServerUrlRequired)); + + var botToken = GetEffectiveSecret("Mattermost.BotToken", mattermost.BotToken, mattermost.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.MattermostBotToken, ChannelsEditorValidationMessages.MattermostBotTokenRequired)); + + var result = await _mattermostProbe.ResolveChannelIdsAsync(serverUrl, botToken, channelIds, ct); + mattermost.LastChannelResolution = result; + + // Only a genuine probe failure (ErrorMessage) blocks. result.Success is false whenever + // any id is unresolved (Success = "did every id resolve?"), which must stay non-blocking. + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.MattermostAllowedChannelIds, $"Mattermost channel lookup failed: {result.ErrorMessage}")); + + // Fail loud: a reference that does not resolve to a real channel the bot can see is an inert + // allow-list entry the runtime ACL can never match, so block the save rather than persist a + // dead entry. + if (result.Unresolved.Count > 0) + return ChannelAccessOutcome.Blocked(Error(ChannelsEditorFieldPaths.MattermostAllowedChannelIds, BuildUnresolvedChannelMessage(result.Unresolved))); + + // All references resolved: map any names to their channel ids and persist the ids (the + // runtime ACL matches ids, not names). Mirrors the Slack validator. + SetResolvedChannels(ChannelType.Mattermost, channelIds, result.Resolved.Select(c => (c.ChannelId, c.ChannelName))); + return ChannelAccessOutcome.None; + } + + // Shared name→id remap for Discord/Mattermost: each configured reference is either already a + // resolved channel id (kept as-is) or a name that resolves to one (replaced by the id and remapped + // in ChannelAudiences). Unresolved references are blocked before this runs, so every reference maps. + private void SetResolvedChannels( + ChannelType type, IReadOnlyList references, IEnumerable<(string Id, string Name)> resolved) + { + var byId = new HashSet(StringComparer.Ordinal); + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (id, name) in resolved) + { + byId.Add(id); + byName.TryAdd(name, id); + } + + var remap = new Dictionary(StringComparer.Ordinal); + var resolvedChannels = new List(); + foreach (var reference in references) + { + if (byId.Contains(reference)) + { + resolvedChannels.Add(reference); + } + else if (byName.TryGetValue(reference, out var id)) + { + resolvedChannels.Add(id); + remap[reference] = id; + } + } + + SetChannelIds(type, [.. resolvedChannels.Distinct(StringComparer.Ordinal)]); + RemapChannelAudiences(type, remap); + UpdateAdapterPickerSummary(type); + } + + // The single place that knows each transport's probe shape: probe the live adapter for the given + // references and return (probe-error, resolved id/name pairs) — or null when the adapter's credentials + // aren't available. Everything downstream (ReconcileResolvedChannels) is transport-agnostic, so + // onboarding and the add-channel flow share one resolution path for every channel type. + private async Task<(string? Error, IEnumerable<(string Id, string Name)> Resolved)?> ResolveChannelReferencesAsync( + ChannelType type, IReadOnlyList channelIds, CancellationToken ct) + { + switch (type) + { + case ChannelType.Slack: + { + var slack = Step.GetAdapterViewModel(ChannelType.Slack); + var botToken = GetEffectiveSecret("Slack.BotToken", slack.BotToken, slack.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return null; + + var result = await _slackProbe.ResolveChannelNamesAsync(botToken, channelIds, ct); + slack.LastChannelResolution = result; + return (result.ErrorMessage, result.Resolved.Select(static c => (c.Id, c.Name))); + } + + case ChannelType.Discord: + { + var discord = Step.GetAdapterViewModel(ChannelType.Discord); + var botToken = GetEffectiveSecret("Discord.BotToken", discord.BotToken, discord.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(botToken)) + return null; + + var result = await _discordProbe.ResolveChannelIdsAsync(botToken, channelIds, ct); + discord.LastChannelResolution = result; + return (result.ErrorMessage, result.Resolved.Select(static c => (c.ChannelId, c.ChannelName))); + } + + case ChannelType.Mattermost: + { + var mattermost = Step.GetAdapterViewModel(ChannelType.Mattermost); + var serverUrl = Normalize(mattermost.ServerUrl); + var botToken = GetEffectiveSecret("Mattermost.BotToken", mattermost.BotToken, mattermost.HasPersistedBotToken); + if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(botToken)) + return null; + + var result = await _mattermostProbe.ResolveChannelIdsAsync(serverUrl, botToken, channelIds, ct); + mattermost.LastChannelResolution = result; + return (result.ErrorMessage, result.Resolved.Select(static c => (c.ChannelId, c.ChannelName))); + } + + default: + return null; + } + } + + // Canonicalize the persisted allow-list against a completed channel resolution. The stored ACL key + // is the platform's IMMUTABLE channel id (what the runtime matches); a human display name is mutable + // and resolved dynamically for rendering only — it is NEVER the stored key. So, per reference: + // - already a channel id -> keep it (it IS the stable key; never dropped, even when the bot can't + // currently fetch its display label — a transient display miss must not delete a real ACL entry) + // - a display name that maps to an id -> store the id (and remap its audience key) + // - a display name with NO id mapping -> drop it: we do not persist a display name we can't map to + // a real channel id (it would be an inert allow-list entry that silently grants nothing). Fail loud. + // A probe failure (auth/scope/network) produces no id mapping for the typed display names, so by the + // same rule they are not persisted — only id-shaped references survive it — and the underlying reason + // is surfaced. This runs off the loop thread: it only mutates view-model/status state and persists, + // then NotifyContentChanged posts the redraw (see the termina-tui-patterns skill); never blocks the loop. + private void ReconcileResolvedChannels( + ChannelType type, + IReadOnlyList stored, + string? errorMessage, + IEnumerable<(string Id, string Name)> resolved) + { + var byId = new HashSet(StringComparer.Ordinal); + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (id, name) in resolved) + { + byId.Add(id); + byName.TryAdd(name, id); + } + + var remap = new Dictionary(StringComparer.Ordinal); + var canonical = new List(stored.Count); + var dropped = new List(); + foreach (var reference in stored) + { + if (byId.Contains(reference)) + canonical.Add(reference); // probe confirmed it as a channel id — keep + else if (byName.TryGetValue(reference, out var id)) + { + canonical.Add(id); // display name → its channel id + remap[reference] = id; + } + else if (IsChannelId(type, reference)) + canonical.Add(reference); // id-shaped but the bot can't enumerate it now — + // keep: a real id is the stable key, never dropped + else + dropped.Add(reference); // a display name with no id mapping — fail loud + } + + canonical = [.. canonical.Distinct(StringComparer.Ordinal)]; + var changed = !stored.SequenceEqual(canonical, StringComparer.Ordinal); + if (changed) + { + SetChannelIds(type, canonical); + RemapChannelAudiences(type, remap); + UpdateAdapterPickerSummary(type); + WriteChannelConfigToDisk(); + IsSaved.Value = true; + } + + // Fail loud only when something is actually lost. A drop is the meaningful failure: name the + // dropped channels and the reason — the probe error if there was one, else the usual checklist. + // A probe error that dropped NOTHING (every reference is an id-shaped key the bot just couldn't + // enrich with a display label) is benign: leave the prior status (e.g. the add's "Added …") + // intact rather than masking a successful add with a lookup failure. + if (dropped.Count > 0) + { + var reason = string.IsNullOrWhiteSpace(errorMessage) + ? "check the name, that the bot is invited, and that it has channel-read scope" + : errorMessage; + Status.Value = new ConfigStatusMessage( + $"Dropped {string.Join(", ", dropped.Select(static c => $"#{c}"))} — {reason}", + ConfigStatusTone.Warning); + } + else if (changed) + { + Status.Value = new ConfigStatusMessage( + $"Resolved {GetAdapterDisplayName(type)} channels to canonical IDs and saved.", + ConfigStatusTone.Neutral); + } + } + + // Whether a typed reference is already the platform's canonical channel id (the stable ACL key) as + // opposed to a human display name that must be resolved to one. Mirrors each platform's id format: + // Slack C…/G…; Discord numeric snowflake; Mattermost 26-char base-32. + private static bool IsChannelId(ChannelType type, string reference) => type switch + { + ChannelType.Slack => IsSlackChannelId(reference), + ChannelType.Discord => IsDiscordChannelId(reference), + ChannelType.Mattermost => IsMattermostChannelId(reference), + _ => false + }; + + private static bool IsDiscordChannelId(string value) + => value.Length is >= 17 and <= 20 && value.All(char.IsAsciiDigit); + + private static bool IsMattermostChannelId(string value) + => value.Length == 26 && value.All(static c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c)); + + private static ChannelsEditorValidationIssue Error(string fieldId, string message) + => new(fieldId, message, ConfigValidationSeverity.Error); + + private string? GetEffectiveSecret(string path, string? draftValue, bool hasPersistedSecret) + { + var normalized = Normalize(draftValue); + if (!string.IsNullOrWhiteSpace(normalized)) + return normalized; + + if (!hasPersistedSecret) + return null; + + return Normalize(ConfigFileHelper.ReadDecryptedSecret(_paths, path)); + } + + private void RemapChannelAudiences(ChannelType type, IReadOnlyDictionary remap) + { + if (remap.Count == 0 || !_channelAudiences.TryGetValue(type, out var audiences)) + return; + + foreach (var (oldId, newId) in remap) + { + if (!audiences.TryGetValue(oldId, out var audience)) + continue; + + audiences.Remove(oldId); + audiences.TryAdd(newId, audience); + } + } + + private ChannelsEditorValidationIssue? ValidateCredentialDrafts() + { + var candidate = ChannelsEditorModel.FromStep(Step); + ApplyCredentialDrafts(candidate); + var validation = _validator.Validate(candidate); + var activeFieldPaths = GetCredentialFieldPaths(_activeAdapterType); + return validation.Issues.FirstOrDefault(issue => issue.FieldId is null || activeFieldPaths.Contains(issue.FieldId)); + } + + private void ApplyCredentialDrafts(ChannelsEditorModel model) + { + switch (_activeAdapterType) + { + case ChannelType.Slack: + model.Slack.Enabled = true; + model.Slack.BotTokenDraft = Normalize(BotTokenInput); + model.Slack.AppTokenDraft = Normalize(AppTokenInput); + break; + case ChannelType.Discord: + model.Discord.Enabled = true; + model.Discord.BotTokenDraft = Normalize(BotTokenInput); + break; + case ChannelType.Mattermost: + model.Mattermost.Enabled = true; + model.Mattermost.ServerUrl = Normalize(ServerUrlInput); + model.Mattermost.BotTokenDraft = Normalize(BotTokenInput); + model.Mattermost.CallbackUrl = Normalize(CallbackUrlInput); + break; + } + } + + private static IReadOnlySet GetCredentialFieldPaths(ChannelType type) + => type switch + { + ChannelType.Slack => new HashSet(StringComparer.Ordinal) + { + ChannelsEditorFieldPaths.SlackBotToken, + ChannelsEditorFieldPaths.SlackAppToken, + }, + ChannelType.Discord => new HashSet(StringComparer.Ordinal) + { + ChannelsEditorFieldPaths.DiscordBotToken, + }, + ChannelType.Mattermost => new HashSet(StringComparer.Ordinal) + { + ChannelsEditorFieldPaths.MattermostServerUrl, + ChannelsEditorFieldPaths.MattermostBotToken, + ChannelsEditorFieldPaths.MattermostCallbackUrl, + }, + _ => new HashSet(StringComparer.Ordinal), + }; + + private static ConfigStatusMessage BuildValidationErrorStatus( + ChannelsEditorValidationResult validation, + string fallbackMessage) + { + var issue = validation.Issues.FirstOrDefault(); + return issue is null + ? new ConfigStatusMessage(fallbackMessage, ConfigStatusTone.Error) + : new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + } + + internal void MoveResetConfirmation(int delta) + { + _resetConfirmIndex = Clamp(_resetConfirmIndex + delta, 2); + NotifyContentChanged(); + } + + internal async Task ApplyResetConfirmationAsync(CancellationToken ct = default) + { + if (_resetConfirmIndex == 0) + { + Screen.Value = ChannelsConfigScreen.AdapterMenu; + NotifyContentChanged(); + return; + } + + // Cancel and await any in-flight Slack label refresh before persisting the reset and + // rebuilding view-model state. A live background normalizer would otherwise write a stale + // snapshot over the reset's config file, or clobber the just-reloaded view-model state — the + // same race SaveAsync guards at its top. This path bypasses SaveAsync, so it needs the same + // guard. Awaited (not blocked via .GetResult()) so it never freezes the Termina loop and + // never deadlocks under a host that installs a SynchronizationContext (e.g. the xunit v3 test + // runner). Dispatched fire-and-forget via ResetConfirmationFromInputAsync. + await CancelAndAwaitLabelRefreshAsync(); + + var resetType = _activeAdapterType; + var resetName = ActiveAdapterName; + try + { + var session = new ConfigEditorSession(_paths); + session.Apply(_mapper.BuildResetContribution(resetType)); + session.Save(); + + var savedDraft = _mapper.Load(_paths); + _knownProviders.Clear(); + foreach (var provider in savedDraft.KnownProviders) + _knownProviders.Add(provider); + + LoadAudienceDrafts(savedDraft); + Step.OnEnter(_context, NavigationDirection.Forward); + _mapper.ApplyToStep(Step, savedDraft); + _activeAdapterType = resetType; + Screen.Value = ChannelsConfigScreen.Picker; + Status.Value = new ConfigStatusMessage($"{resetName} reset saved.", ConfigStatusTone.Success); + IsSaved.Value = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // A disk-full / permission-denied write, or a malformed existing netclaw.json (the reload + // deserializes it), must surface to the operator — not escape into the Termina event loop. + // Stay on the confirmation screen so the reset can be retried. Mirrors the autosave paths, + // which catch the same way inside ConfigAutosave.RunAsync; this reset path bypasses + // SaveAsync, so it carries its own equivalent guard. + Status.Value = new ConfigStatusMessage($"Could not save reset: {ex.Message}", ConfigStatusTone.Error); + } + catch (Exception ex) + { + // Fail LOUD on any other error (e.g. a type-malformed but JSON-valid config surfacing as + // InvalidOperationException from the reload's deserialization). This runs as a fire-and-forget + // chained write whose ChainAsync swallows a faulted prior task, so an unsurfaced throw here + // would vanish with no operator feedback — exactly the silent fallback to avoid. Surface it + // and stay on the confirmation screen for retry; catching here also keeps the chained task + // from faulting, so subsequent writes are unaffected. + Status.Value = new ConfigStatusMessage($"Could not reset {resetName}: {ex.Message}", ConfigStatusTone.Error); + } + + NotifyContentChanged(); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + // Cancel any in-flight config write / label refresh, then DRAIN them before disposing the + // reactive state they publish to. A fire-and-forget write resumes on a thread-pool continuation + // (the loop has no SynchronizationContext), so without this a write could mutate a disposed + // ReactiveProperty / Step after teardown. Cancellation makes the in-flight probe abort promptly; + // the bounded Wait is a last-resort backstop so Dispose can never block the loop indefinitely on + // a wedged probe (it returns false on timeout rather than throwing or hanging). + _lifetimeCts.Cancel(); + _labelResolutionCts?.Cancel(); + try + { + Task.WhenAll(_pendingConfigWrite, _labelRefreshTask ?? Task.CompletedTask) + .Wait(TimeSpan.FromSeconds(5)); + } + catch (Exception drainFailure) + { + // A faulted/cancelled in-flight write has already surfaced its own status (autosave/reset + // both catch and report); trace at debug level and let teardown complete regardless. + Debug.WriteLine($"ChannelsConfig: in-flight write drain on dispose faulted: {drainFailure.Message}"); + } + + _labelResolutionCts?.Dispose(); + _lifetimeCts.Dispose(); + IsSaved.Dispose(); + Screen.Dispose(); + Status.Dispose(); + Step.Dispose(); + _context.Dispose(); + base.Dispose(); + } + + private void GoBackWithinManagement() + { + Screen.Value = Screen.Value switch + { + ChannelsConfigScreen.AdapterMenu => ChannelsConfigScreen.Picker, + ChannelsConfigScreen.ChannelPermissions => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.EditAudience => ChannelsConfigScreen.ChannelPermissions, + ChannelsConfigScreen.AddChannel => ChannelsConfigScreen.ChannelPermissions, + ChannelsConfigScreen.AllowedUsers => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.DirectMessages => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.RotateCredentials => ChannelsConfigScreen.AdapterMenu, + ChannelsConfigScreen.ResetConfirm => ChannelsConfigScreen.AdapterMenu, + _ => ChannelsConfigScreen.Picker + }; + + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + NotifyContentChanged(); + } + + private void SetActiveAdapterEnabled(bool enabled) + { + var selectedIndex = GetAdapterIndex(_activeAdapterType); + + if (Step.IsAdapterEnabled(_activeAdapterType) != enabled) + Step.ToggleAdapter(selectedIndex); + + UpdateAdapterPickerSummary(_activeAdapterType); + + AutosaveCompletedAction($"{ActiveAdapterName} {(enabled ? "enabled" : "disabled")} and saved."); + } + + // Fire-and-forget autosave from a synchronous Termina key handler: the calling handler already + // mutated VM state on the loop thread; this enqueues only the persist (disk write + reload) so it + // is serialized behind any in-flight write and never blocks the loop. Autosave skips the blocking + // channel-access probe (the background label refresh re-validates instead). + private void AutosaveCompletedAction(string successMessage) + => _ = EnqueueConfigWriteAsync(() => SaveCompletedAsync(successMessage, _lifetimeCts.Token)); + + // Awaitable autosave for callers already running inside an enqueued write (ApplyAddChannelAsync), + // where re-enqueueing would deadlock the op behind itself. Returns whether the save succeeded. + private Task SaveCompletedAsync(string successMessage, CancellationToken ct = default) + => SaveViaAutosaveAsync(successMessage, probeChannelAccess: false, ct); + + private int GetAdapterIndex(ChannelType type) + => Step.Adapters + .Select((entry, index) => (entry.Type, index)) + .Single(entry => entry.Type == type) + .index; + + private void UpdateAdapterPickerSummary(ChannelType type) + { + if (!Step.IsAdapterEnabled(type)) + { + Step.SetAdapterSummary(type, "disabled, saved setup"); + return; + } + + var channelCount = GetChannelIds(type).Count; + var userCount = GetAllowedUserIds(type).Count; + var parts = new List + { + channelCount > 0 + ? Pluralize(channelCount, "channel", "channels") + : GetAllowDirectMessages(type) ? "DMs only" : "no channels" + }; + + if (userCount > 0) + parts.Add(Pluralize(userCount, "user", "users")); + + Step.SetAdapterSummary(type, string.Join(", ", parts)); + } + + private void LoadAudienceDrafts(ChannelsConfigDraft draft) + { + _channelAudiences.Clear(); + AddAudienceDraft(ChannelType.Slack, draft.Slack.ChannelAudiences); + AddAudienceDraft(ChannelType.Discord, draft.Discord.ChannelAudiences); + AddAudienceDraft(ChannelType.Mattermost, draft.Mattermost.ChannelAudiences); + } + + private void AddAudienceDraft(ChannelType type, IReadOnlyDictionary audiences) + { + if (audiences.Count == 0) + return; + + _channelAudiences[type] = new Dictionary(audiences, StringComparer.Ordinal); + } + + private TrustAudience GetChannelAudience(ChannelType type, string channelId, TrustAudience defaultAudience) + => _channelAudiences.TryGetValue(type, out var audiences) && audiences.TryGetValue(channelId, out var audience) + ? audience + : defaultAudience; + + private void SetChannelAudience(ChannelType type, string channelId, TrustAudience audience) + { + if (!_channelAudiences.TryGetValue(type, out var audiences)) + { + audiences = new Dictionary(StringComparer.Ordinal); + _channelAudiences[type] = audiences; + } + + audiences[channelId] = audience; + } + + private TrustAudience DefaultChannelAudience() + => ChannelAudienceDefaults.ForChannel(_context.SelectedPosture ?? DeploymentPosture.Personal); + + private TrustAudience DefaultDirectMessageAudience() + => ChannelAudienceDefaults.ForDirectMessage( + _context.SelectedPosture ?? DeploymentPosture.Personal, + GetAllowedUserIds(_activeAdapterType).Count); + + private IReadOnlyList GetChannelIds(ChannelType type) => type switch + { + ChannelType.Slack => ChannelCsv.ParseCsv(Step.GetAdapterViewModel(ChannelType.Slack).ChannelNamesInput, trimHash: true), + ChannelType.Discord => ChannelCsv.ParseCsv(Step.GetAdapterViewModel(ChannelType.Discord).ChannelIdsInput, trimHash: true), + ChannelType.Mattermost => ChannelCsv.ParseCsv(Step.GetAdapterViewModel(ChannelType.Mattermost).ChannelIdsInput, trimHash: true), + _ => [] + }; + + private void SetChannelIds(ChannelType type, IReadOnlyList channelIds) + { + var value = ChannelCsv.JoinOrNull(channelIds); + switch (type) + { + case ChannelType.Slack: + Step.GetAdapterViewModel(ChannelType.Slack).ChannelNamesInput = value; + break; + case ChannelType.Discord: + Step.GetAdapterViewModel(ChannelType.Discord).ChannelIdsInput = value; + break; + case ChannelType.Mattermost: + Step.GetAdapterViewModel(ChannelType.Mattermost).ChannelIdsInput = value; + break; + } + } + + private IReadOnlyList GetAllowedUserIds(ChannelType type) => type switch + { + ChannelType.Slack => ChannelCsv.ParseCsv(Step.GetAdapterViewModel(ChannelType.Slack).AllowedUserIdsInput, trimHash: false), + ChannelType.Discord => ChannelCsv.ParseCsv(Step.GetAdapterViewModel(ChannelType.Discord).AllowedUserIdsInput, trimHash: false), + ChannelType.Mattermost => ChannelCsv.ParseCsv(Step.GetAdapterViewModel(ChannelType.Mattermost).AllowedUserIdsInput, trimHash: false), + _ => [] + }; + + private void SetAllowedUserIds(ChannelType type, IReadOnlyList userIds) + { + var value = ChannelCsv.JoinOrNull(userIds); + switch (type) + { + case ChannelType.Slack: + var slack = Step.GetAdapterViewModel(ChannelType.Slack); + slack.RestrictToSpecificUsers = userIds.Count > 0; + slack.AllowedUserIdsInput = value; + break; + case ChannelType.Discord: + var discord = Step.GetAdapterViewModel(ChannelType.Discord); + discord.RestrictToSpecificUsers = userIds.Count > 0; + discord.AllowedUserIdsInput = value; + break; + case ChannelType.Mattermost: + var mattermost = Step.GetAdapterViewModel(ChannelType.Mattermost); + mattermost.RestrictToSpecificUsers = userIds.Count > 0; + mattermost.AllowedUserIdsInput = value; + break; + } + } + + private bool GetAllowDirectMessages(ChannelType type) => type switch + { + ChannelType.Slack => Step.GetAdapterViewModel(ChannelType.Slack).AllowDirectMessages, + ChannelType.Discord => Step.GetAdapterViewModel(ChannelType.Discord).AllowDirectMessages, + ChannelType.Mattermost => Step.GetAdapterViewModel(ChannelType.Mattermost).AllowDirectMessages, + _ => false + }; + + private void SetAllowDirectMessages(ChannelType type, bool enabled) + { + switch (type) + { + case ChannelType.Slack: + Step.GetAdapterViewModel(ChannelType.Slack).AllowDirectMessages = enabled; + break; + case ChannelType.Discord: + Step.GetAdapterViewModel(ChannelType.Discord).AllowDirectMessages = enabled; + break; + case ChannelType.Mattermost: + Step.GetAdapterViewModel(ChannelType.Mattermost).AllowDirectMessages = enabled; + break; + } + } + + private string? GetServerUrl(ChannelType type) + => type == ChannelType.Mattermost + ? Step.GetAdapterViewModel(ChannelType.Mattermost).ServerUrl + : null; + + private string? GetCallbackUrl(ChannelType type) + => type == ChannelType.Mattermost + ? Step.GetAdapterViewModel(ChannelType.Mattermost).CallbackUrl + : null; + + private string? GetCredentialPresenceText(string key) + { + return _activeAdapterType switch + { + ChannelType.Slack when key == "bot" && Step.GetAdapterViewModel(ChannelType.Slack).HasPersistedBotToken => + "configured - leave blank to keep", + ChannelType.Slack when key == "app" && Step.GetAdapterViewModel(ChannelType.Slack).HasPersistedAppToken => + "configured - leave blank to keep", + ChannelType.Discord when key == "bot" && Step.GetAdapterViewModel(ChannelType.Discord).HasPersistedBotToken => + "configured - leave blank to keep", + ChannelType.Mattermost when key == "bot" && Step.GetAdapterViewModel(ChannelType.Mattermost).HasPersistedBotToken => + "configured - leave blank to keep", + _ => null + }; + } + + private string GetCredentialSummary(ChannelType type) + { + return type switch + { + ChannelType.Slack => + (Step.GetAdapterViewModel(ChannelType.Slack).HasPersistedBotToken ? "bot token configured" : "bot token missing") + + " · " + + (Step.GetAdapterViewModel(ChannelType.Slack).HasPersistedAppToken ? "app token configured" : "app token missing"), + ChannelType.Discord => Step.GetAdapterViewModel(ChannelType.Discord).HasPersistedBotToken + ? "bot token configured" + : "bot token missing", + ChannelType.Mattermost => Step.GetAdapterViewModel(ChannelType.Mattermost).HasPersistedBotToken + ? "bot token configured" + : "bot token missing", + _ => "credentials unknown" + }; + } + + private static string GetAdapterDisplayName(ChannelType type) => type switch + { + ChannelType.Slack => "Slack", + ChannelType.Discord => "Discord", + ChannelType.Mattermost => "Mattermost", + _ => type.ToString() + }; + + // Channel names/ids the active adapter's most recent probe could not resolve. + // Used to flag the matching channel rows; comparison is case-insensitive because + // resolution is name-based for Slack and the operator's casing may not match. + private IReadOnlySet GetActiveAdapterUnresolved() + { + var unresolved = _activeAdapterType switch + { + ChannelType.Slack => Step.GetAdapterViewModel(ChannelType.Slack).LastChannelResolution?.Unresolved, + ChannelType.Discord => Step.GetAdapterViewModel(ChannelType.Discord).LastChannelResolution?.Unresolved, + ChannelType.Mattermost => Step.GetAdapterViewModel(ChannelType.Mattermost).LastChannelResolution?.Unresolved, + _ => null + }; + + return unresolved is null or { Count: 0 } + ? EmptyUnresolved + : new HashSet(unresolved, StringComparer.OrdinalIgnoreCase); + } + + private static readonly IReadOnlySet EmptyUnresolved = + new HashSet(StringComparer.OrdinalIgnoreCase); + + private string FormatChannelLabel(ChannelType type, string channelId) + => type switch + { + ChannelType.Slack => FormatSlackChannelLabel(channelId), + ChannelType.Discord => FormatDiscordChannelLabel(channelId), + ChannelType.Mattermost => FormatMattermostChannelLabel(channelId), + _ => channelId + }; + + private string FormatSlackChannelLabel(string channelId) + { + var slack = Step.GetAdapterViewModel(ChannelType.Slack); + var resolved = slack.LastChannelResolution?.Resolved.FirstOrDefault(channel => + string.Equals(channel.Id, channelId, StringComparison.Ordinal)); + return resolved is null ? channelId : $"#{resolved.Name}"; + } + + private string FormatDiscordChannelLabel(string channelId) + { + var discord = Step.GetAdapterViewModel(ChannelType.Discord); + var resolved = discord.LastChannelResolution?.Resolved.FirstOrDefault(channel => + string.Equals(channel.ChannelId, channelId, StringComparison.Ordinal)); + return resolved?.ToDisplayName() ?? channelId; + } + + private string FormatMattermostChannelLabel(string channelId) + { + var mattermost = Step.GetAdapterViewModel(ChannelType.Mattermost); + var resolved = mattermost.LastChannelResolution?.Resolved.FirstOrDefault(channel => + string.Equals(channel.ChannelId, channelId, StringComparison.Ordinal)); + if (resolved is null) + return channelId; + + // Mattermost exposes a human display name and a url slug; prefer the display name, fall back to + // the #slug, and only show the opaque id when neither resolved. + return !string.IsNullOrWhiteSpace(resolved.DisplayName) + ? resolved.DisplayName + : $"#{resolved.ChannelName}"; + } + + private static int AudienceIndex(TrustAudience audience) + { + for (var i = 0; i < AudienceOptions.Count; i++) + { + if (AudienceOptions[i] == audience) + return i; + } + + return 0; + } + + private static bool IsSlackChannelId(string value) + => value.Length > 1 + && value[0] is 'C' or 'G' + && value.Skip(1).All(static ch => char.IsUpper(ch) || char.IsDigit(ch)); + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static int Clamp(int index, int count) + => count == 0 ? 0 : Math.Clamp(index, 0, count - 1); + + private static int Wrap(int index, int count) + => count == 0 ? 0 : (index % count + count) % count; + + private static string Pluralize(int count, string singular, string plural) + => count == 1 ? $"1 {singular}" : $"{count} {plural}"; + + private void ReturnToDashboard() + { + if (TryGoBack()) + return; + + RequestQuit(); + } + + private bool TryGoBack() + { + if (_navigation is null) + return false; + + try + { + return _navigation.TryGoBack(); + } + catch (InvalidOperationException) + { + return false; + } + } + + private void NotifyContentChanged() + { + OnStepContentChanged?.Invoke(); + RequestRedraw(); + } + + private void StartChannelLabelResolution(ChannelType type) + { + if (type is not (ChannelType.Slack or ChannelType.Discord or ChannelType.Mattermost)) + return; + + _labelResolutionCts?.Cancel(); + _labelResolutionCts?.Dispose(); + _labelResolutionCts = new CancellationTokenSource(); + _labelRefreshTask = RefreshChannelLabelsAsync(type, _labelResolutionCts.Token); + } + + // Exposes the in-flight background label refresh for tests asserting save/dispose serialization. + internal Task? PendingLabelRefresh => _labelRefreshTask; + + // Stop the in-flight background label refresh (if any) and wait for it to unwind before the + // caller validates, persists, or resets channel state. Without this, a probe that resumes after + // a save could clobber the just-reloaded view-model state or write a stale snapshot over the + // save — the two HIGH races in the deep review (background normalizer vs SaveAsync). + private async Task CancelAndAwaitLabelRefreshAsync() + { + _labelResolutionCts?.Cancel(); + var inFlight = _labelRefreshTask; + if (inFlight is null) + return; + + // RefreshChannelLabelsAsync catches all of its own exceptions (cancellation is abandoned + // quietly, any other failure surfaces a warning status), so awaiting the tracked task here + // observes completion without throwing. + await inFlight; + + _labelRefreshTask = null; + } + + // Fail CLOSED to Public on a corrupt posture via the shared reader rather than throwing into this + // view-model's constructor. The Security editor (the posture's owner) surfaces the corruption; + // Channels only consumes posture for channel/DM audience ACL defaults, where Public is the safe + // restrictive default. Throwing here previously made the entire Channels page inaccessible on a + // value the Security page reads without crashing. + private static DeploymentPosture LoadDeploymentPosture(NetclawPaths paths) + { + DeploymentPostureReader.TryRead(ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath), out var posture, out _); + return posture; + } +} + +internal enum ChannelsConfigScreen +{ + Picker, + AdapterMenu, + ChannelPermissions, + EditAudience, + AddChannel, + AllowedUsers, + DirectMessages, + RotateCredentials, + ResetConfirm +} + +internal enum ChannelsManagementAction +{ + ManageChannels, + AddChannel, + ManageUsers, + DirectMessages, + RotateCredentials, + ToggleEnabled, + ResetConnection +} + +internal sealed record ChannelsManagementMenuItem( + ChannelsManagementAction Action, + string Label, + string Description); + +internal sealed record ChannelPermissionRow( + string Id, + string DisplayName, + TrustAudience Audience, + bool IsDirectMessage, + bool IsAddAction, + bool IsDoneAction, + bool IsUnresolved = false) +{ + internal bool IsAction => IsAddAction || IsDoneAction; +} + +internal sealed record CredentialFieldSpec( + string Key, + string Label, + bool IsSecret, + string Placeholder, + string? Hint); + +internal sealed record ChannelPersistenceSpec( + string ConfigSection, + IReadOnlyList SecretPaths); + +internal sealed class ChannelsConfigPersistenceMapper +{ + private static readonly IReadOnlyDictionary ChannelSpecs = + new Dictionary + { + [ChannelType.Slack] = new ChannelPersistenceSpec("Slack", ["Slack.BotToken", "Slack.AppToken"]), + [ChannelType.Discord] = new ChannelPersistenceSpec("Discord", ["Discord.BotToken"]), + [ChannelType.Mattermost] = new ChannelPersistenceSpec("Mattermost", ["Mattermost.BotToken"]) + }; + + internal ChannelsConfigDraft Load(NetclawPaths paths) + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + var draft = new ChannelsConfigDraft + { + Slack = LoadSlack(paths, config, secrets), + Discord = LoadDiscord(paths, config, secrets), + Mattermost = LoadMattermost(paths, config, secrets) + }; + + AddKnownProvider(draft.KnownProviders, ChannelType.Slack, draft.Slack.IsKnown); + AddKnownProvider(draft.KnownProviders, ChannelType.Discord, draft.Discord.IsKnown); + AddKnownProvider(draft.KnownProviders, ChannelType.Mattermost, draft.Mattermost.IsKnown); + return draft; + } + + internal void ApplyToStep(ChannelPickerStepViewModel step, ChannelsConfigDraft draft) + { + step.LoadAdapterState( + ChannelType.Slack, + draft.Slack.Enabled, + BuildSummary(draft.Slack), + vm => ApplySlack((SlackStepViewModel)vm, draft.Slack), + draft.Slack.IsKnown); + + step.LoadAdapterState( + ChannelType.Discord, + draft.Discord.Enabled, + BuildSummary(draft.Discord), + vm => ApplyDiscord((DiscordStepViewModel)vm, draft.Discord), + draft.Discord.IsKnown); + + step.LoadAdapterState( + ChannelType.Mattermost, + draft.Mattermost.Enabled, + BuildSummary(draft.Mattermost), + vm => ApplyMattermost((MattermostStepViewModel)vm, draft.Mattermost), + draft.Mattermost.IsKnown); + } + + internal SectionContribution BuildContribution( + ChannelPickerStepViewModel step, + IReadOnlySet knownProviders, + IReadOnlyDictionary> channelAudiences, + DeploymentPosture posture) + { + var fields = new List(); + var secrets = new List(); + + // The picker (Step.IsAdapterEnabled) is the single source of truth for + // "is this adapter enabled?" — the same source dynamic validation uses. The + // sub-VM's own *Enabled flag is a parallel copy; gating the contribution on it + // instead let a validated+probed adapter persist nothing (Enabled=false, no + // channels) while Save() still reported success. Read the canonical flag here + // so a save can never half-write an adapter the editor treats as enabled. + AddSlackContribution( + fields, + secrets, + step.GetAdapterViewModel(ChannelType.Slack), + step.IsAdapterEnabled(ChannelType.Slack), + knownProviders.Contains(ChannelType.Slack), + channelAudiences, + posture); + AddDiscordContribution( + fields, + secrets, + step.GetAdapterViewModel(ChannelType.Discord), + step.IsAdapterEnabled(ChannelType.Discord), + knownProviders.Contains(ChannelType.Discord), + channelAudiences, + posture); + AddMattermostContribution( + fields, + secrets, + step.GetAdapterViewModel(ChannelType.Mattermost), + step.IsAdapterEnabled(ChannelType.Mattermost), + knownProviders.Contains(ChannelType.Mattermost), + channelAudiences, + posture); + + return new SectionContribution(fields, secrets); + } + + internal SectionContribution BuildResetContribution(ChannelType type) + { + var fields = new List(); + var secrets = new List(); + AddResetActions(fields, secrets, type); + + return new SectionContribution(fields, secrets); + } + + private static SlackChannelDraft LoadSlack( + NetclawPaths paths, + Dictionary config, + Dictionary secrets) + { + var hasBotToken = HasSecret(paths, secrets, "Slack.BotToken"); + var hasAppToken = HasSecret(paths, secrets, "Slack.AppToken"); + var sectionPresent = SectionPresent(config, "Slack"); + var channels = ReadConfiguredChannels(config, "Slack"); + var users = GetStringArray(config, "Slack.AllowedUserIds"); + return new SlackChannelDraft + { + IsKnown = sectionPresent || hasBotToken || hasAppToken, + Enabled = sectionPresent && GetBool(config, "Slack.Enabled", defaultValue: false), + HasPersistedBotToken = hasBotToken, + HasPersistedAppToken = hasAppToken, + ChannelIds = channels, + AllowDirectMessages = GetBool(config, "Slack.AllowDirectMessages", defaultValue: false), + AllowedUserIds = users, + ChannelAudiences = GetChannelAudiences(config, "Slack.ChannelAudiences") + }; + } + + private static DiscordChannelDraft LoadDiscord( + NetclawPaths paths, + Dictionary config, + Dictionary secrets) + { + var hasBotToken = HasSecret(paths, secrets, "Discord.BotToken"); + var sectionPresent = SectionPresent(config, "Discord"); + var channels = ReadConfiguredChannels(config, "Discord"); + var users = GetStringArray(config, "Discord.AllowedUserIds"); + return new DiscordChannelDraft + { + IsKnown = sectionPresent || hasBotToken, + Enabled = sectionPresent && GetBool(config, "Discord.Enabled", defaultValue: false), + HasPersistedBotToken = hasBotToken, + ChannelIds = channels, + AllowDirectMessages = GetBool(config, "Discord.AllowDirectMessages", defaultValue: false), + AllowedUserIds = users, + ChannelAudiences = GetChannelAudiences(config, "Discord.ChannelAudiences") + }; + } + + private static MattermostChannelDraft LoadMattermost( + NetclawPaths paths, + Dictionary config, + Dictionary secrets) + { + var hasBotToken = HasSecret(paths, secrets, "Mattermost.BotToken"); + var sectionPresent = SectionPresent(config, "Mattermost"); + var channels = ReadConfiguredChannels(config, "Mattermost"); + var users = GetStringArray(config, "Mattermost.AllowedUserIds"); + return new MattermostChannelDraft + { + IsKnown = sectionPresent || hasBotToken, + Enabled = sectionPresent && GetBool(config, "Mattermost.Enabled", defaultValue: false), + HasPersistedBotToken = hasBotToken, + ServerUrl = GetString(config, "Mattermost.ServerUrl"), + CallbackUrl = GetString(config, "Mattermost.CallbackUrl"), + ChannelIds = channels, + AllowDirectMessages = GetBool(config, "Mattermost.AllowDirectMessages", defaultValue: false), + AllowedUserIds = users, + ChannelAudiences = GetChannelAudiences(config, "Mattermost.ChannelAudiences") + }; + } + + private static void ApplySlack(SlackStepViewModel vm, SlackChannelDraft draft) + { + vm.SlackEnabled = draft.Enabled; + vm.BotToken = null; + vm.AppToken = null; + vm.HasPersistedBotToken = draft.HasPersistedBotToken; + vm.HasPersistedAppToken = draft.HasPersistedAppToken; + vm.ChannelNamesInput = ChannelCsv.JoinOrNull(draft.ChannelIds); + vm.AllowDirectMessages = draft.AllowDirectMessages; + vm.RestrictToSpecificUsers = draft.AllowedUserIds.Count > 0; + vm.AllowedUserIdsInput = ChannelCsv.JoinOrNull(draft.AllowedUserIds); + } + + private static void ApplyDiscord(DiscordStepViewModel vm, DiscordChannelDraft draft) + { + vm.DiscordEnabled = draft.Enabled; + vm.BotToken = null; + vm.HasPersistedBotToken = draft.HasPersistedBotToken; + vm.ChannelIdsInput = ChannelCsv.JoinOrNull(draft.ChannelIds); + vm.AllowDirectMessages = draft.AllowDirectMessages; + vm.RestrictToSpecificUsers = draft.AllowedUserIds.Count > 0; + vm.AllowedUserIdsInput = ChannelCsv.JoinOrNull(draft.AllowedUserIds); + } + + private static void ApplyMattermost(MattermostStepViewModel vm, MattermostChannelDraft draft) + { + vm.MattermostEnabled = draft.Enabled; + vm.ServerUrl = draft.ServerUrl; + vm.BotToken = null; + vm.HasPersistedBotToken = draft.HasPersistedBotToken; + vm.ChannelIdsInput = ChannelCsv.JoinOrNull(draft.ChannelIds); + vm.AllowDirectMessages = draft.AllowDirectMessages; + vm.RestrictToSpecificUsers = draft.AllowedUserIds.Count > 0; + vm.AllowedUserIdsInput = ChannelCsv.JoinOrNull(draft.AllowedUserIds); + vm.CallbackUrl = draft.CallbackUrl; + } + + private static void AddSlackContribution( + List fields, + List secrets, + SlackStepViewModel vm, + bool enabled, + bool knownProvider, + IReadOnlyDictionary> channelAudiences, + DeploymentPosture posture) + { + if (!enabled) + { + if (knownProvider) + fields.Add(new SectionFieldAction("Slack.Enabled", SectionFieldActionKind.Set, false)); + AddSecretPreserveOrSet(secrets, "Slack.BotToken", vm.BotToken, vm.HasPersistedBotToken); + AddSecretPreserveOrSet(secrets, "Slack.AppToken", vm.AppToken, vm.HasPersistedAppToken); + return; + } + + var channelIds = ChannelCsv.ParseCsv(vm.ChannelNamesInput, trimHash: true); + var userIds = vm.RestrictToSpecificUsers ? ChannelCsv.ParseCsv(vm.AllowedUserIdsInput, trimHash: false) : []; + + fields.Add(new SectionFieldAction("Slack.Enabled", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Slack.SocketMode", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Slack.AllowDirectMessages", SectionFieldActionKind.Set, vm.AllowDirectMessages)); + SetArrayOrDelete(fields, "Slack.AllowedChannelIds", channelIds); + SetStringOrDelete(fields, "Slack.DefaultChannelId", channelIds.FirstOrDefault()); + fields.Add(new SectionFieldAction("Slack.DefaultChannelName", SectionFieldActionKind.Delete)); + SetArrayOrDelete(fields, "Slack.AllowedUserIds", userIds); + SetDictionaryOrDelete(fields, "Slack.ChannelAudiences", BuildAudienceMap(ChannelType.Slack, channelIds, userIds, vm.AllowDirectMessages, channelAudiences, posture)); + AddSecretPreserveOrSet(secrets, "Slack.BotToken", vm.BotToken, vm.HasPersistedBotToken); + AddSecretPreserveOrSet(secrets, "Slack.AppToken", vm.AppToken, vm.HasPersistedAppToken); + } + + private static void AddDiscordContribution( + List fields, + List secrets, + DiscordStepViewModel vm, + bool enabled, + bool knownProvider, + IReadOnlyDictionary> channelAudiences, + DeploymentPosture posture) + { + if (!enabled) + { + if (knownProvider) + fields.Add(new SectionFieldAction("Discord.Enabled", SectionFieldActionKind.Set, false)); + AddSecretPreserveOrSet(secrets, "Discord.BotToken", vm.BotToken, vm.HasPersistedBotToken); + return; + } + + var channelIds = ChannelCsv.ParseCsv(vm.ChannelIdsInput, trimHash: true); + var userIds = vm.RestrictToSpecificUsers ? ChannelCsv.ParseCsv(vm.AllowedUserIdsInput, trimHash: false) : []; + + fields.Add(new SectionFieldAction("Discord.Enabled", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Discord.AllowDirectMessages", SectionFieldActionKind.Set, vm.AllowDirectMessages)); + SetArrayOrDelete(fields, "Discord.AllowedChannelIds", channelIds); + SetStringOrDelete(fields, "Discord.DefaultChannelId", channelIds.FirstOrDefault()); + SetArrayOrDelete(fields, "Discord.AllowedUserIds", userIds); + SetDictionaryOrDelete(fields, "Discord.ChannelAudiences", BuildAudienceMap(ChannelType.Discord, channelIds, userIds, vm.AllowDirectMessages, channelAudiences, posture)); + AddSecretPreserveOrSet(secrets, "Discord.BotToken", vm.BotToken, vm.HasPersistedBotToken); + } + + private static void AddMattermostContribution( + List fields, + List secrets, + MattermostStepViewModel vm, + bool enabled, + bool knownProvider, + IReadOnlyDictionary> channelAudiences, + DeploymentPosture posture) + { + if (!enabled) + { + if (knownProvider) + fields.Add(new SectionFieldAction("Mattermost.Enabled", SectionFieldActionKind.Set, false)); + AddSecretPreserveOrSet(secrets, "Mattermost.BotToken", vm.BotToken, vm.HasPersistedBotToken); + return; + } + + var channelIds = ChannelCsv.ParseCsv(vm.ChannelIdsInput, trimHash: true); + var userIds = vm.RestrictToSpecificUsers ? ChannelCsv.ParseCsv(vm.AllowedUserIdsInput, trimHash: false) : []; + + fields.Add(new SectionFieldAction("Mattermost.Enabled", SectionFieldActionKind.Set, true)); + fields.Add(new SectionFieldAction("Mattermost.AllowDirectMessages", SectionFieldActionKind.Set, vm.AllowDirectMessages)); + SetStringOrDelete(fields, "Mattermost.ServerUrl", Normalize(vm.ServerUrl)); + SetStringOrDelete(fields, "Mattermost.CallbackUrl", Normalize(vm.CallbackUrl)); + SetArrayOrDelete(fields, "Mattermost.AllowedChannelIds", channelIds); + SetStringOrDelete(fields, "Mattermost.DefaultChannelId", channelIds.FirstOrDefault()); + SetArrayOrDelete(fields, "Mattermost.AllowedUserIds", userIds); + SetDictionaryOrDelete(fields, "Mattermost.ChannelAudiences", BuildAudienceMap(ChannelType.Mattermost, channelIds, userIds, vm.AllowDirectMessages, channelAudiences, posture)); + AddSecretPreserveOrSet(secrets, "Mattermost.BotToken", vm.BotToken, vm.HasPersistedBotToken); + } + + private static void AddSecretPreserveOrSet( + List secrets, + string path, + string? draftValue, + bool hasPersistedSecret) + { + var normalized = Normalize(draftValue); + if (!string.IsNullOrWhiteSpace(normalized)) + secrets.Add(new SectionSecretAction(path, SectionSecretActionKind.Set, new SensitiveString(normalized))); + else if (hasPersistedSecret) + secrets.Add(new SectionSecretAction(path, SectionSecretActionKind.Preserve)); + } + + private static void AddResetActions( + List fields, + List secrets, + ChannelType type) + { + if (!ChannelSpecs.TryGetValue(type, out var spec)) + return; + + fields.Add(new SectionFieldAction(spec.ConfigSection, SectionFieldActionKind.Delete)); + foreach (var secretPath in spec.SecretPaths) + secrets.Add(new SectionSecretAction(secretPath, SectionSecretActionKind.Delete)); + } + + private static void SetArrayOrDelete(List fields, string path, IReadOnlyList values) + { + fields.Add(values.Count > 0 + ? new SectionFieldAction(path, SectionFieldActionKind.Set, values.ToArray()) + : new SectionFieldAction(path, SectionFieldActionKind.Delete)); + } + + private static void SetDictionaryOrDelete(List fields, string path, IReadOnlyDictionary values) + { + fields.Add(values.Count > 0 + ? new SectionFieldAction(path, SectionFieldActionKind.Set, new Dictionary(values, StringComparer.Ordinal)) + : new SectionFieldAction(path, SectionFieldActionKind.Delete)); + } + + private static void SetStringOrDelete(List fields, string path, string? value) + { + var normalized = Normalize(value); + fields.Add(!string.IsNullOrWhiteSpace(normalized) + ? new SectionFieldAction(path, SectionFieldActionKind.Set, normalized) + : new SectionFieldAction(path, SectionFieldActionKind.Delete)); + } + + private static bool SectionPresent(Dictionary config, string sectionName) + { + if (!ConfigFileHelper.TryGetPathValue(config, sectionName, out var value) || value is null) + return false; + + if (value is Dictionary) + return true; + + throw new InvalidOperationException($"Configuration section '{sectionName}' must be an object."); + } + + private static Dictionary BuildAudienceMap( + ChannelType type, + IReadOnlyList channelIds, + IReadOnlyList userIds, + bool allowDirectMessages, + IReadOnlyDictionary> channelAudiences, + DeploymentPosture posture) + { + channelAudiences.TryGetValue(type, out var explicitAudiences); + var map = new Dictionary(StringComparer.Ordinal); + foreach (var channelId in channelIds) + { + var audience = explicitAudiences is not null && explicitAudiences.TryGetValue(channelId, out var explicitAudience) + ? explicitAudience + : ChannelAudienceDefaults.ForChannel(posture); + map[channelId] = audience.ToWireValue(); + } + + if (explicitAudiences is not null && explicitAudiences.TryGetValue("dm", out var explicitDmAudience)) + { + map["dm"] = explicitDmAudience.ToWireValue(); + } + else if (allowDirectMessages) + { + map["dm"] = ChannelAudienceDefaults.ForDirectMessage(posture, userIds.Count).ToWireValue(); + } + + return map; + } + + private static bool GetBool(Dictionary config, string path, bool defaultValue) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return defaultValue; + + return value is bool boolValue + ? boolValue + : throw new InvalidOperationException($"Configuration value '{path}' must be a boolean."); + } + + private static string? GetString(Dictionary config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return null; + + return value is string stringValue + ? stringValue + : throw new InvalidOperationException($"Configuration value '{path}' must be a string."); + } + + private static IReadOnlyList ReadConfiguredChannels(Dictionary config, string sectionName) + { + var channels = new List(); + channels.AddRange(GetStringArray(config, $"{sectionName}.AllowedChannelIds")); + + var defaultChannelId = GetString(config, $"{sectionName}.DefaultChannelId"); + if (!string.IsNullOrWhiteSpace(defaultChannelId)) + channels.Add(defaultChannelId); + + if (string.Equals(sectionName, "Slack", StringComparison.Ordinal)) + { + var defaultChannelName = GetString(config, "Slack.DefaultChannelName"); + if (!string.IsNullOrWhiteSpace(defaultChannelName)) + channels.Add(defaultChannelName.StartsWith('#') ? defaultChannelName : $"#{defaultChannelName}"); + } + + return [.. channels + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal)]; + } + + private static IReadOnlyList GetStringArray(Dictionary config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return []; + + if (value is object[] objectValues) + { + return [.. objectValues + .Select(static item => item switch + { + string stringValue => stringValue, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString()!, + _ => throw new InvalidOperationException("Channel list values must be strings.") + }) + .Where(static item => !string.IsNullOrWhiteSpace(item))]; + } + + if (value is string[] stringValues) + return [.. stringValues.Where(static item => !string.IsNullOrWhiteSpace(item))]; + + throw new InvalidOperationException($"Configuration value '{path}' must be an array of strings."); + } + + private static Dictionary GetChannelAudiences(Dictionary config, string path) + { + if (!ConfigFileHelper.TryGetPathValue(config, path, out var value) || value is null) + return []; + + if (value is not Dictionary values) + throw new InvalidOperationException($"Configuration value '{path}' must be an object."); + + var audiences = new Dictionary(StringComparer.Ordinal); + foreach (var (channelId, rawAudience) in values) + { + var wire = rawAudience switch + { + string text => text, + JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(), + _ => throw new InvalidOperationException($"Channel audience '{path}.{channelId}' must be a string.") + }; + + if (!SecurityPolicyDefaults.TryParseAudience(wire, out var audience)) + throw new InvalidOperationException($"Channel audience '{path}.{channelId}' is not valid: {wire}."); + + audiences[channelId] = audience; + } + + return audiences; + } + + private static bool HasSecret(NetclawPaths paths, Dictionary secrets, string path) + { + if (!ConfigFileHelper.TryGetPathValue(secrets, path, out var value)) + return false; + + return !string.IsNullOrWhiteSpace(ConfigFileHelper.DecryptIfEncrypted(paths, value?.ToString())); + } + + private static string? BuildSummary(ChannelProviderDraft draft) + { + if (!draft.IsKnown) + return null; + + if (!draft.Enabled) + return "disabled, saved setup"; + + var channelCount = draft.ChannelIds.Count; + var userCount = draft.AllowedUserIds.Count; + var parts = new List + { + channelCount > 0 + ? Pluralize(channelCount, "channel", "channels") + : draft.AllowDirectMessages ? "DMs only" : "no channels" + }; + + if (userCount > 0) + parts.Add(Pluralize(userCount, "user", "users")); + + return string.Join(", ", parts); + } + + private static void AddKnownProvider(HashSet knownProviders, ChannelType type, bool isKnown) + { + if (isKnown) + knownProviders.Add(type); + } + + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string Pluralize(int count, string singular, string plural) + => count == 1 ? $"1 {singular}" : $"{count} {plural}"; +} + +internal sealed class ChannelsConfigDraft +{ + public required SlackChannelDraft Slack { get; init; } + public required DiscordChannelDraft Discord { get; init; } + public required MattermostChannelDraft Mattermost { get; init; } + public HashSet KnownProviders { get; } = []; +} + +internal abstract class ChannelProviderDraft +{ + public bool IsKnown { get; init; } + public bool Enabled { get; init; } + public IReadOnlyList ChannelIds { get; init; } = []; + public bool AllowDirectMessages { get; init; } + public IReadOnlyList AllowedUserIds { get; init; } = []; + public IReadOnlyDictionary ChannelAudiences { get; init; } = new Dictionary(StringComparer.Ordinal); +} + +internal sealed class SlackChannelDraft : ChannelProviderDraft +{ + public bool HasPersistedBotToken { get; init; } + public bool HasPersistedAppToken { get; init; } +} + +internal sealed class DiscordChannelDraft : ChannelProviderDraft +{ + public bool HasPersistedBotToken { get; init; } +} + +internal sealed class MattermostChannelDraft : ChannelProviderDraft +{ + public string? ServerUrl { get; init; } + public bool HasPersistedBotToken { get; init; } + public string? CallbackUrl { get; init; } +} + +/// +/// Shared parsing for the comma-separated channel/user lists in the Channels editor. One copy +/// feeds the display/read path and one feeds the persistence-mapper write path — keeping them +/// here guarantees a channel list is canonicalized identically in both directions. +/// +internal static class ChannelCsv +{ + internal static List ParseCsv(string? input, bool trimHash) + { + if (string.IsNullOrWhiteSpace(input)) + return []; + + return [.. input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => trimHash ? value.Trim().TrimStart('#') : value.Trim()) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal)]; + } + + internal static string? JoinOrNull(IReadOnlyList values) + => values.Count == 0 ? null : string.Join(", ", values); +} diff --git a/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs new file mode 100644 index 000000000..94b1232cd --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ChannelsEditorModel.cs @@ -0,0 +1,216 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Options; +using Netclaw.Actors.Channels; +using Netclaw.Cli.Tui.Wizard.Steps; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class ChannelsEditorModel +{ + public SlackChannelEditorModel Slack { get; } = new(); + + public DiscordChannelEditorModel Discord { get; } = new(); + + public MattermostChannelEditorModel Mattermost { get; } = new(); + + public static ChannelsEditorModel FromStep(ChannelPickerStepViewModel step) + { + var slack = step.GetAdapterViewModel(ChannelType.Slack); + var discord = step.GetAdapterViewModel(ChannelType.Discord); + var mattermost = step.GetAdapterViewModel(ChannelType.Mattermost); + + var model = new ChannelsEditorModel + { + Slack = + { + Enabled = step.IsAdapterEnabled(ChannelType.Slack), + BotTokenDraft = Normalize(slack.BotToken), + HasPersistedBotToken = slack.HasPersistedBotToken, + AppTokenDraft = Normalize(slack.AppToken), + HasPersistedAppToken = slack.HasPersistedAppToken, + }, + Discord = + { + Enabled = step.IsAdapterEnabled(ChannelType.Discord), + BotTokenDraft = Normalize(discord.BotToken), + HasPersistedBotToken = discord.HasPersistedBotToken, + }, + Mattermost = + { + Enabled = step.IsAdapterEnabled(ChannelType.Mattermost), + ServerUrl = Normalize(mattermost.ServerUrl), + BotTokenDraft = Normalize(mattermost.BotToken), + HasPersistedBotToken = mattermost.HasPersistedBotToken, + CallbackUrl = Normalize(mattermost.CallbackUrl), + } + }; + + return model; + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal abstract class ChannelEditorProviderModel +{ + public bool Enabled { get; set; } +} + +internal sealed class SlackChannelEditorModel : ChannelEditorProviderModel +{ + public string? BotTokenDraft { get; set; } + + public bool HasPersistedBotToken { get; set; } + + public string? AppTokenDraft { get; set; } + + public bool HasPersistedAppToken { get; set; } +} + +internal sealed class DiscordChannelEditorModel : ChannelEditorProviderModel +{ + public string? BotTokenDraft { get; set; } + + public bool HasPersistedBotToken { get; set; } +} + +internal sealed class MattermostChannelEditorModel : ChannelEditorProviderModel +{ + public string? ServerUrl { get; set; } + + public string? BotTokenDraft { get; set; } + + public bool HasPersistedBotToken { get; set; } + + public string? CallbackUrl { get; set; } +} + +internal sealed class ChannelsEditorValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, ChannelsEditorModel options) + { + var errors = new List(); + + if (options.Slack.Enabled) + { + if (!HasEffectiveSecret(options.Slack.BotTokenDraft, options.Slack.HasPersistedBotToken)) + errors.Add(ChannelsEditorValidationMessages.SlackBotTokenRequired); + else if (!string.IsNullOrWhiteSpace(options.Slack.BotTokenDraft) + && !options.Slack.BotTokenDraft.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) + errors.Add(ChannelsEditorValidationMessages.SlackBotTokenPrefix); + + if (!HasEffectiveSecret(options.Slack.AppTokenDraft, options.Slack.HasPersistedAppToken)) + errors.Add(ChannelsEditorValidationMessages.SlackAppTokenRequired); + else if (!string.IsNullOrWhiteSpace(options.Slack.AppTokenDraft) + && !options.Slack.AppTokenDraft.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) + errors.Add(ChannelsEditorValidationMessages.SlackAppTokenPrefix); + } + + if (options.Discord.Enabled + && !HasEffectiveSecret(options.Discord.BotTokenDraft, options.Discord.HasPersistedBotToken)) + errors.Add(ChannelsEditorValidationMessages.DiscordBotTokenRequired); + + if (options.Mattermost.Enabled) + { + if (string.IsNullOrWhiteSpace(options.Mattermost.ServerUrl)) + errors.Add(ChannelsEditorValidationMessages.MattermostServerUrlRequired); + else if (!IsHttpUrl(options.Mattermost.ServerUrl)) + errors.Add(ChannelsEditorValidationMessages.MattermostServerUrlAbsoluteHttp); + + if (!HasEffectiveSecret(options.Mattermost.BotTokenDraft, options.Mattermost.HasPersistedBotToken)) + errors.Add(ChannelsEditorValidationMessages.MattermostBotTokenRequired); + + if (!string.IsNullOrWhiteSpace(options.Mattermost.CallbackUrl) + && !IsHttpUrl(options.Mattermost.CallbackUrl)) + errors.Add(ChannelsEditorValidationMessages.MattermostCallbackUrlAbsoluteHttp); + } + + return errors.Count > 0 + ? ValidateOptionsResult.Fail(errors) + : ValidateOptionsResult.Success; + } + + private static bool HasEffectiveSecret(string? draftValue, bool hasPersistedSecret) + => !string.IsNullOrWhiteSpace(draftValue) || hasPersistedSecret; + + internal static bool IsHttpUrl(string value) + => Uri.TryCreate(value, UriKind.Absolute, out var uri) + && uri.Scheme is "http" or "https"; +} + +internal static class ChannelsEditorFieldPaths +{ + internal const string SlackBotToken = "Slack.BotToken"; + internal const string SlackAppToken = "Slack.AppToken"; + internal const string SlackAllowedChannelIds = "Slack.AllowedChannelIds"; + internal const string DiscordBotToken = "Discord.BotToken"; + internal const string DiscordAllowedChannelIds = "Discord.AllowedChannelIds"; + internal const string MattermostServerUrl = "Mattermost.ServerUrl"; + internal const string MattermostBotToken = "Mattermost.BotToken"; + internal const string MattermostCallbackUrl = "Mattermost.CallbackUrl"; + internal const string MattermostAllowedChannelIds = "Mattermost.AllowedChannelIds"; +} + +internal static class ChannelsEditorValidationMessages +{ + internal const string SlackBotTokenRequired = "Slack bot token is required."; + internal const string SlackBotTokenPrefix = "Slack bot token must start with xoxb-."; + internal const string SlackAppTokenRequired = "Slack Socket Mode app token is required."; + internal const string SlackAppTokenPrefix = "Slack app token must start with xapp-."; + internal const string DiscordBotTokenRequired = "Discord bot token is required."; + internal const string MattermostServerUrlRequired = "Mattermost server URL is required."; + internal const string MattermostServerUrlAbsoluteHttp = "Mattermost server URL must be an absolute http:// or https:// URL."; + internal const string MattermostBotTokenRequired = "Mattermost bot token is required."; + internal const string MattermostCallbackUrlAbsoluteHttp = "Mattermost callback URL must be an absolute http:// or https:// URL."; +} + +internal sealed record ChannelsEditorValidationIssue(string? FieldId, string Message, ConfigValidationSeverity Severity); + +internal sealed record ChannelsEditorValidationResult(IReadOnlyList Issues) +{ + public static readonly ChannelsEditorValidationResult Empty = new([]); + + public bool HasErrors => Issues.Any(static issue => issue.Severity == ConfigValidationSeverity.Error); + + public IReadOnlyList IssuesFor(string fieldId) + => [.. Issues.Where(issue => string.Equals(issue.FieldId, fieldId, StringComparison.Ordinal))]; +} + +internal sealed class ChannelsEditorValidationAdapter +{ + private readonly ChannelsEditorValidator _validator = new(); + + internal ChannelsEditorValidationResult Validate(ChannelsEditorModel model) + { + var result = _validator.Validate(name: null, model); + if (result.Succeeded) + return ChannelsEditorValidationResult.Empty; + + var failures = result.Failures ?? []; + var issues = new List(); + foreach (var failure in failures) + issues.Add(new ChannelsEditorValidationIssue(FieldForMessage(failure), failure, ConfigValidationSeverity.Error)); + + return new ChannelsEditorValidationResult(issues); + } + + private static string? FieldForMessage(string message) + => message switch + { + ChannelsEditorValidationMessages.SlackBotTokenRequired => ChannelsEditorFieldPaths.SlackBotToken, + ChannelsEditorValidationMessages.SlackBotTokenPrefix => ChannelsEditorFieldPaths.SlackBotToken, + ChannelsEditorValidationMessages.SlackAppTokenRequired => ChannelsEditorFieldPaths.SlackAppToken, + ChannelsEditorValidationMessages.SlackAppTokenPrefix => ChannelsEditorFieldPaths.SlackAppToken, + ChannelsEditorValidationMessages.DiscordBotTokenRequired => ChannelsEditorFieldPaths.DiscordBotToken, + ChannelsEditorValidationMessages.MattermostServerUrlRequired => ChannelsEditorFieldPaths.MattermostServerUrl, + ChannelsEditorValidationMessages.MattermostServerUrlAbsoluteHttp => ChannelsEditorFieldPaths.MattermostServerUrl, + ChannelsEditorValidationMessages.MattermostBotTokenRequired => ChannelsEditorFieldPaths.MattermostBotToken, + ChannelsEditorValidationMessages.MattermostCallbackUrlAbsoluteHttp => ChannelsEditorFieldPaths.MattermostCallbackUrl, + _ => null, + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/ConfigAutosave.cs b/src/Netclaw.Cli/Tui/Config/ConfigAutosave.cs new file mode 100644 index 000000000..118dea68f --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ConfigAutosave.cs @@ -0,0 +1,48 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; + +namespace Netclaw.Cli.Tui.Config; + +internal static class ConfigAutosave +{ + internal static bool Run( + Func save, + ReactiveProperty status, + string failurePrefix, + Action requestRedraw) + { + try + { + return save(); + } + catch (Exception ex) + { + status.Value = new ConfigStatusMessage($"{failurePrefix}: {ex.Message}", ConfigStatusTone.Error); + requestRedraw(); + return false; + } + } + + internal static async Task RunAsync( + Func> saveAsync, + ReactiveProperty status, + string failurePrefix, + Action requestRedraw, + CancellationToken ct = default) + { + try + { + return await saveAsync(ct); + } + catch (Exception ex) + { + status.Value = new ConfigStatusMessage($"{failurePrefix}: {ex.Message}", ConfigStatusTone.Error); + requestRedraw(); + return false; + } + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ConfigSelectionRow.cs b/src/Netclaw.Cli/Tui/Config/ConfigSelectionRow.cs new file mode 100644 index 000000000..e6e2815e9 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ConfigSelectionRow.cs @@ -0,0 +1,125 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina.Layout; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +/// +/// A single config-page list row that renders the selected entry as a +/// full-width teal highlight bar (teal background, dark foreground) — the same +/// look gives the dashboard — instead of a +/// /> marker prefix. This unifies the selection style across +/// the bespoke config sub-pages, which render their rows as manual nodes rather +/// than through a . +/// +/// +/// The bar is drawn by filling the row's full bounds width with the highlight +/// background before writing the label, mirroring +/// SelectionListNode.RenderItemLine. A alone would +/// only colour the glyph cells of the text, so a manual fill is required to get +/// an edge-to-edge bar at the runtime panel width. +/// +internal sealed class ConfigSelectionRow : LayoutNode +{ + internal static readonly Color BarBackground = Color.Cyan; + internal static readonly Color BarForeground = Color.Black; + + private readonly string _text; + private readonly bool _selected; + private readonly Color _foreground; + private readonly bool _bold; + private readonly int _valueStart; + private readonly Color _valueForeground; + + private ConfigSelectionRow(string text, bool selected, Color foreground, bool bold, int valueStart, Color valueForeground) + { + _text = text ?? string.Empty; + _selected = selected; + _foreground = foreground; + _bold = bold; + _valueStart = valueStart; + _valueForeground = valueForeground; + WidthConstraint = SizeConstraint.FillRemaining(); + HeightConstraint = SizeConstraint.AutoSize(); + } + + /// + /// Build a selectable row. When is true the row + /// renders as a full-width teal bar; otherwise it renders as plain text in + /// (defaults to white). + /// + internal static ConfigSelectionRow Create(string text, bool selected, Color? foreground = null, bool bold = false) + => new(text, selected, foreground ?? Color.White, bold, valueStart: -1, valueForeground: Color.White); + + /// + /// Build a form-field row whose trailing segment renders + /// in its own colour when the row is not selected — e.g. a dim placeholder/example + /// that must read as a prompt rather than an entered value, while the bright + /// stays legible. When selected the whole row uses the + /// teal bar so the focus look stays consistent with menu rows. + /// + internal static ConfigSelectionRow CreateLabeled(string label, string value, bool selected, Color valueForeground, Color? labelForeground = null) + { + label ??= string.Empty; + return new(label + (value ?? string.Empty), selected, labelForeground ?? Color.White, bold: false, valueStart: label.Length, valueForeground: valueForeground); + } + + public override Size Measure(Size available) + { + var width = WidthConstraint.Compute(available.Width, _text.Length, available.Width); + return new Size(width, 1); + } + + public override void Render(IRenderContext context, Rect bounds) + { + if (!bounds.HasArea) + return; + + var ctx = context.CreateSubContext(bounds); + if (_selected) + { + ctx.SetBackground(BarBackground); + ctx.Fill(0, 0, bounds.Width, 1); + ctx.SetForeground(BarForeground); + if (_bold) + ctx.SetDecoration(TextDecoration.Bold); + ctx.WriteAt(0, 0, Clip(_text, bounds.Width)); + if (_bold) + ctx.SetDecoration(TextDecoration.None); + } + else + { + if (_bold) + ctx.SetDecoration(TextDecoration.Bold); + + var clipped = Clip(_text, bounds.Width); + if (_valueStart >= 0 && _valueStart <= clipped.Length) + { + // Two-tone: bright label, then the value segment in its own colour + // (a dim placeholder reads as a prompt; a real value reads as bright). + ctx.SetForeground(_foreground); + ctx.WriteAt(0, 0, clipped[.._valueStart]); + ctx.SetForeground(_valueForeground); + ctx.WriteAt(_valueStart, 0, clipped[_valueStart..]); + } + else + { + ctx.SetForeground(_foreground); + ctx.WriteAt(0, 0, clipped); + } + + if (_bold) + ctx.SetDecoration(TextDecoration.None); + } + + ctx.ResetColors(); + } + + private static string Clip(string text, int width) + => text.Length > width ? text[..width] : text; +} diff --git a/src/Netclaw.Cli/Tui/Config/DeploymentPostureReader.cs b/src/Netclaw.Cli/Tui/Config/DeploymentPostureReader.cs new file mode 100644 index 000000000..5732d8f4d --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/DeploymentPostureReader.cs @@ -0,0 +1,41 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Config; + +/// +/// Single source of truth for reading Security.DeploymentPosture from config. A MISSING key is +/// the normal "not yet configured" state and defaults to Personal. A PRESENT but unrecognized value +/// (renamed enum member, stale numeric, hand-edited typo) is a misconfiguration: it fails CLOSED to +/// Public — the most restrictive posture, matching the daemon's +/// fallback — and reports the raw value via . Both the Security and +/// Channels editors read posture through here so the same corrupt value degrades consistently instead +/// of failing closed on one page and throwing into the constructor of the other. +/// +internal static class DeploymentPostureReader +{ + public static bool TryRead(Dictionary config, out DeploymentPosture posture, out string? invalidValue) + { + invalidValue = null; + if (!ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var value)) + { + posture = DeploymentPosture.Personal; + return true; + } + + if (value is string text && Enum.TryParse(text, ignoreCase: true, out var parsed)) + { + posture = parsed; + return true; + } + + posture = DeploymentPosture.Public; + invalidValue = value?.ToString() ?? "(null)"; + return false; + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs new file mode 100644 index 000000000..9984b752d --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigPage.cs @@ -0,0 +1,171 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Workflow; +using Netclaw.Configuration; +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ExposureModeConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _helpTextNode; + private readonly CompositeDisposable _stepSubs = []; + + protected override void OnBound() + { + base.OnBound(); + + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.OnStepContentChanged = () => + { + _stepSubs.Clear(); + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + }; + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Exposure Mode", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(BuildHelpText()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + { + var modeLabel = FormatModeLabel(ViewModel.Step.SelectedMode); + return WorkflowViewComponents.BuildSavedScreen( + $"{modeLabel} exposure mode saved.", + "Press Esc to review exposure modes or Enter to return to Security & Access."); + } + + ViewModel.StepView.ClearFocusState(); + return ViewModel.StepView.BuildContent(ViewModel.Step, CreateCallbacks()); + }); + + return _contentNode; + } + + private LayoutNode BuildHelpText() + { + _helpTextNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + return (ILayoutNode)new TextNode(" Saved state is local to this editor; Esc returns to the mode list first.").WithForeground(Color.Gray); + + return (ILayoutNode)new TextNode(ViewModel.Step.GetHelpText()).WithForeground(Color.Gray); + }); + + return _helpTextNode.Height(2); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Context.StatusMessage + .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) + ? Layouts.Empty() + : new TextNode($" {msg}").WithForeground(Color.Green))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => ViewModel.IsSaved + .Select(saved => (ILayoutNode)new TextNode(saved + ? " [Enter] Security & Access [Esc] Review modes [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Enter] Next/Save [Esc] Back [Ctrl+Q] Quit") + .WithForeground(Color.BrightBlack)) + .AsLayout() + .Height(1); + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (base.HandlePageInput(keyInfo)) + return true; + + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return true; + } + + return false; + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + if (ViewModel.IsSaved.Value && keyInfo.Key == ConsoleKey.Enter) + { + ViewModel.GoNext(); + return; + } + + ViewModel.StepView.HandleKeyPress(key); + ViewModel.RequestRedraw(); + } + + private void HandlePaste(PasteEvent paste) + { + ViewModel.StepView.HandlePaste(paste); + ViewModel.RequestRedraw(); + } + + private StepViewCallbacks CreateCallbacks() + => new() + { + Subscriptions = _stepSubs, + InvalidateContent = () => _contentNode?.Invalidate(), + InvalidateHelp = () => _helpTextNode?.Invalidate(), + AdvanceStep = ViewModel.GoNext, + RequestRedraw = ViewModel.RequestRedraw, + }; + + private static string FormatModeLabel(ExposureMode mode) + => mode switch + { + ExposureMode.Local => "Local", + ExposureMode.ReverseProxy => "Reverse Proxy", + ExposureMode.TailscaleServe => "Tailscale Serve", + ExposureMode.TailscaleFunnel => "Tailscale Funnel", + ExposureMode.CloudflareTunnel => "Cloudflare Tunnel", + _ => mode.ToString() + }; + + public override void Dispose() + { + _stepSubs.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs new file mode 100644 index 000000000..21bbc7613 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/ExposureModeConfigViewModel.cs @@ -0,0 +1,136 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class ExposureModeConfigViewModel : ReactiveViewModel +{ + private readonly WizardContext _context; + private readonly WizardOrchestrator _orchestrator; + private readonly ExposureModeStepViewModel _step; + + public ExposureModeConfigViewModel(NetclawPaths paths) + { + _step = new ExposureModeStepViewModel(includeWebhookToggle: false); + // Degrade to "no existing config" on a malformed/unreadable netclaw.json rather than throwing + // from the constructor (which would make the Exposure page permanently inaccessible). + var existingConfig = ConfigFileHelper.TryLoadJsonDictOrNull(paths.NetclawConfigPath, out var loadError); + _context = new WizardContext + { + Paths = paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = RequestRedraw, + ExistingConfig = existingConfig + }; + if (loadError is not null) + _context.StatusMessage.Value = loadError; + _orchestrator = new WizardOrchestrator([_step], _context, singleStepMode: true); + } + + internal Action? RouteRequested { get; set; } + public WizardContext Context => _context; + public WizardOrchestrator Orchestrator => _orchestrator; + public ExposureModeStepViewModel Step => _step; + public ExposureModeStepView StepView { get; } = new(); + public ReactiveProperty IsSaved { get; } = new(false); + public Action? OnStepContentChanged { get; set; } + + public void GoNext() + { + if (IsSaved.Value) + { + BackToSecurityAccess(); + return; + } + + if (_orchestrator.GoNext()) + { + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + if (_step.GetStructuralValidationError() is { } validationError) + { + _context.StatusMessage.Value = validationError; + NotifyContentChanged(); + return; + } + + try + { + _orchestrator.WriteConfig(); + + // Keep the configuring client authenticated after switching to a non-local mode. WriteConfig + // already auto-pairs a fully fresh install (the wizard bootstrap path); this also covers + // leftover/partial pairing state so `netclaw config` never locks the operator out of chat. + _step.EnsureCurrentClientPaired(_context.Paths); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A disk-full / permission-denied / atomic-rename failure here must surface to the operator, + // not escalate as an unhandled exception that tears down the Termina event loop. Leave + // IsSaved false so the UI never claims a save that did not fully complete. + _context.StatusMessage.Value = $"Failed to save exposure mode: {ex.Message}"; + NotifyContentChanged(); + return; + } + + IsSaved.Value = true; + _context.StatusMessage.Value = "Exposure mode saved."; + NotifyContentChanged(); + } + + public void GoBack() + { + if (IsSaved.Value) + { + IsSaved.Value = false; + _step.ReturnToModeSelection(); + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + if (_orchestrator.GoBack()) + { + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + BackToSecurityAccess(); + } + + public void RequestQuit() => Shutdown(); + + private void BackToSecurityAccess() + { + RouteRequested?.Invoke("/security"); + Navigate?.Invoke("/security"); + } + + private void NotifyContentChanged() + { + OnStepContentChanged?.Invoke(); + RequestRedraw(); + } + + public override void Dispose() + { + IsSaved.Dispose(); + _orchestrator.Dispose(); + _context.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigPage.cs b/src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigPage.cs new file mode 100644 index 000000000..18f5945e4 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigPage.cs @@ -0,0 +1,160 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class InboundWebhooksConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private readonly TextInputNode _pasteBuffer = new(); + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.Enabled.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.TimeoutDraft.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedRow.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.RouteSummary.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Inbound Webhooks", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + var routes = ViewModel.RouteSummary.Value; + var layout = Layouts.Vertical() + .WithChild(Header(" Inbound Webhooks")) + .WithChild(Hint(" Global webhook enablement lives here. Route files stay owned by `netclaw webhooks`.")) + .WithChild(Layouts.Empty().Height(1)); + + layout = layout.WithChild(Row(0, + $"Enabled [{Check(ViewModel.Enabled.Value)}]", + "Toggle global webhook endpoint registration.")); + layout = layout.WithChild(Row(1, + $"Execution timeout {ViewModel.TimeoutDraft.Value} seconds", + "Maximum autonomous webhook run time before failure.")); + layout = layout.WithChild(Row(2, + "Route authoring netclaw webhooks", + "Use `netclaw webhooks set|list|validate`; this editor never creates dummy routes.")); + + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" Routes: total={routes.Total}, enabled={routes.Enabled}, disabled={routes.Disabled}, invalid={routes.Invalid}")); + + if (ViewModel.Enabled.Value && routes.Enabled == 0) + { + layout = layout.WithChild(Text( + " Diagnostic: enabled with no valid routes will fail closed. Add a route before saving enabled state.", + Color.Yellow)); + } + + return layout; + }); + + return _contentNode; + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Space] Toggle/Save [Type/Paste] Edit timeout [Enter] Apply [Esc] Settings Areas [Ctrl+Q] Quit"); + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + return; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + return; + case ConsoleKey.Spacebar when ViewModel.SelectedRow.Value == 0: + ViewModel.ToggleEnabled(); + return; + case ConsoleKey.Enter: + ViewModel.Save(); + return; + case ConsoleKey.Backspace: + ViewModel.BackspaceTimeout(); + return; + } + + if (!char.IsControl(keyInfo.KeyChar)) + ViewModel.AppendTimeoutText(keyInfo.KeyChar.ToString()); + } + + private void HandlePaste(PasteEvent paste) + { + _pasteBuffer.Text = string.Empty; + _pasteBuffer.HandlePaste(paste); + ViewModel.AppendTimeoutText(_pasteBuffer.Text); + } + + private ILayoutNode Row(int index, string label, string description) + { + var focused = index == ViewModel.SelectedRow.Value; + return ConfigSelectionRow.Create($" {label,-40} {description}", focused); + } + + private static string Check(bool value) => value ? "x" : " "; + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.Gray); + private static TextNode Text(string text, Color color) => new TextNode(text).WithForeground(color); + + private static Color ToColor(ConfigStatusTone tone) + => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.Gray + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigViewModel.cs new file mode 100644 index 000000000..2caf17012 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/InboundWebhooksConfigViewModel.cs @@ -0,0 +1,258 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed record InboundWebhookRouteSummary(int Total, int Enabled, int Disabled, int Invalid) +{ + public int Valid => Total - Invalid; +} + +internal sealed class InboundWebhooksConfigViewModel : ReactiveViewModel +{ + private readonly NetclawPaths _paths; + private readonly WebhookRouteStore _routeStore; + private string _acceptedTimeoutText; + + public InboundWebhooksConfigViewModel(NetclawPaths paths) + { + _paths = paths; + _routeStore = new WebhookRouteStore(paths); + var config = LoadConfig(); + Enabled = new ReactiveProperty(config.Enabled); + TimeoutDraft = new ReactiveProperty(config.ExecutionTimeoutSeconds.ToString()); + _acceptedTimeoutText = TimeoutDraft.Value; + SelectedRow = new ReactiveProperty(0); + Status = new ReactiveProperty(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + IsSaved = new ReactiveProperty(false); + RouteSummary = new ReactiveProperty(ReadRouteSummary()); + } + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ReactiveProperty Enabled { get; } + public ReactiveProperty TimeoutDraft { get; } + public ReactiveProperty SelectedRow { get; } + public ReactiveProperty Status { get; } + public ReactiveProperty IsSaved { get; } + public ReactiveProperty RouteSummary { get; } + + public IReadOnlyList Rows { get; } = + [ + "Enabled", + "Execution timeout", + "Route authoring" + ]; + + public void MoveSelection(int delta) + { + var next = Math.Clamp(SelectedRow.Value + delta, 0, Rows.Count - 1); + if (next != SelectedRow.Value) + SelectedRow.Value = next; + } + + public bool ToggleEnabled() + { + var previous = Enabled.Value; + Enabled.Value = !Enabled.Value; + if (AutosaveCompletedAction("Inbound Webhooks enabled state saved.")) + return true; + + Enabled.Value = previous; + IsSaved.Value = false; + RequestRedraw(); + return false; + } + + public void AppendTimeoutText(string text) + { + if (SelectedRow.Value != 1) + return; + + if (TimeoutDraft.Value == _acceptedTimeoutText) + TimeoutDraft.Value = string.Empty; + + TimeoutDraft.Value += text; + IsSaved.Value = false; + ClearStatus(); + RequestRedraw(); + } + + public void BackspaceTimeout() + { + if (SelectedRow.Value != 1 || TimeoutDraft.Value.Length == 0) + return; + + TimeoutDraft.Value = TimeoutDraft.Value[..^1]; + IsSaved.Value = false; + ClearStatus(); + RequestRedraw(); + } + + public bool Save() + => ConfigAutosave.Run( + () => Save("Inbound Webhooks settings saved."), + Status, + "Inbound Webhooks save failed", + RequestRedraw); + + private bool Save(string successMessage) + { + RouteSummary.Value = ReadRouteSummary(); + if (!TryParseTimeout(TimeoutDraft.Value, out var timeoutSeconds, out var timeoutError)) + { + Status.Value = new ConfigStatusMessage(timeoutError, ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + ConfigFileHelper.SetPathValue(config, "Webhooks.Enabled", Enabled.Value); + ConfigFileHelper.SetPathValue(config, "Webhooks.ExecutionTimeoutSeconds", timeoutSeconds); + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); + + _acceptedTimeoutText = timeoutSeconds.ToString(); + TimeoutDraft.Value = _acceptedTimeoutText; + IsSaved.Value = true; + + // Enabling the gateway before any routes exist is the intended setup order: + // `Webhooks.Enabled` is only the feature toggle (inbound-webhooks spec), and with + // no routes every inbound request fails closed at 404 — the gateway stays inert + // until routes are authored via `netclaw webhooks set`. So persist the toggle and + // advise the next step rather than blocking the save. + Status.Value = Enabled.Value && RouteSummary.Value.Enabled == 0 + ? new ConfigStatusMessage( + "Inbound webhooks enabled. Add at least one route with `netclaw webhooks set` to start receiving deliveries.", + ConfigStatusTone.Warning) + : new ConfigStatusMessage(successMessage, ConfigStatusTone.Success); + RequestRedraw(); + return true; + } + + private bool AutosaveCompletedAction(string successMessage) + => ConfigAutosave.Run( + () => Save(successMessage), + Status, + "Inbound Webhooks autosave failed", + RequestRedraw); + + public void GoBack() + { + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + Enabled.Dispose(); + TimeoutDraft.Dispose(); + SelectedRow.Dispose(); + Status.Dispose(); + IsSaved.Dispose(); + RouteSummary.Dispose(); + base.Dispose(); + } + + private WebhooksConfig LoadConfig() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return new WebhooksConfig + { + Enabled = ConfigFileHelper.TryGetPathValue(config, "Webhooks.Enabled", out var enabled) + && enabled is bool enabledFlag + && enabledFlag, + ExecutionTimeoutSeconds = ConfigFileHelper.TryGetPathValue(config, "Webhooks.ExecutionTimeoutSeconds", out var timeout) + && TryConvertInt(timeout, out var timeoutValue) + ? timeoutValue + : 300 + }; + } + + private InboundWebhookRouteSummary ReadRouteSummary() + { + int total = 0, enabled = 0, disabled = 0, invalid = 0; + foreach (var route in _routeStore.ListRouteFiles()) + { + total++; + if (route.Definition is null) + { + invalid++; + continue; + } + + var errors = WebhookRouteValidator.Validate(route.RouteName, route.Definition); + if (errors.Count > 0) + { + invalid++; + continue; + } + + if (route.Definition.Enabled) + enabled++; + else + disabled++; + } + + return new InboundWebhookRouteSummary(total, enabled, disabled, invalid); + } + + private static bool TryParseTimeout(string value, out int timeoutSeconds, out string error) + { + timeoutSeconds = 0; + error = string.Empty; + if (!int.TryParse(value.Trim(), out var parsed)) + { + error = "Execution timeout must be a whole number of seconds."; + return false; + } + + if (parsed is < 1 or > 3600) + { + error = "Execution timeout must be between 1 and 3600 seconds."; + return false; + } + + timeoutSeconds = parsed; + return true; + } + + private static bool TryConvertInt(object? value, out int result) + { + switch (value) + { + case int i: + result = i; + return true; + case long l when l is >= int.MinValue and <= int.MaxValue: + result = (int)l; + return true; + case string text when int.TryParse(text, out var parsed): + result = parsed; + return true; + default: + result = 0; + return false; + } + } + + private void ClearStatus() + { + if (!string.IsNullOrWhiteSpace(Status.Value.Text)) + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs b/src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs new file mode 100644 index 000000000..3e8d19b13 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SchemaDrivenConfigInfrastructure.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Cli.Tui.Config; + +internal enum ConfigFieldStorage +{ + ConfigFile, + SecretsFile, +} + +internal enum ConfigFieldWidget +{ + EnumSelection, + TextInput, + PasswordInput, +} + +internal enum ConfigFieldValueKind +{ + String, + Boolean, +} + +internal enum ConfigValidationSeverity +{ + Error, + Warning, +} + +internal enum ConfigStatusTone +{ + Neutral, + Success, + Warning, + Error, +} + +internal sealed record ConfigStatusMessage(string Text, ConfigStatusTone Tone); + +internal sealed record ConfigValidationIssue(string? Path, ConfigValidationSeverity Severity, string Message); + +internal sealed record ConfigValidationSummary(IReadOnlyList Issues) +{ + public static readonly ConfigValidationSummary Empty = new([]); + + public bool HasErrors => Issues.Any(static i => i.Severity == ConfigValidationSeverity.Error); + + public bool HasWarnings => Issues.Any(static i => i.Severity == ConfigValidationSeverity.Warning); + + public bool HasIssues => Issues.Count > 0; + + public IReadOnlyList IssuesFor(string path) + => [.. Issues.Where(i => string.Equals(i.Path, path, StringComparison.Ordinal))]; +} + +internal sealed record ConfigEnumOption(string Value, string Label); + +internal sealed record ProjectedConfigField( + string Path, + string PropertyName, + string Label, + string? Description, + ConfigFieldValueKind ValueKind, + ConfigFieldStorage Storage, + ConfigFieldWidget Widget, + bool Nullable, + object? DefaultValue, + bool TrimDefaultOnSave, + bool PreserveBlankSecret, + string? Placeholder, + string? Hint, + string? ApplicableWhenPath, + string? ApplicableWhenEquals, + string? InactiveText, + IReadOnlyList EnumOptions); diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs new file mode 100644 index 000000000..8de713595 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorPage.cs @@ -0,0 +1,388 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Workflow; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class SearchConfigEditorPage : ReactivePage +{ + private SelectionListNode? _dialogList; + private TextInputNode? _textInput; + private string? _textInputFieldPath; + private DynamicLayoutNode? _contentNode; + private readonly CompositeDisposable _contentSubscriptions = []; + private ActiveSelectionList? _providerList; + private bool _providerSelectionSynced; + + public override void OnNavigatedTo() + { + base.OnNavigatedTo(); + + ViewModel.ActiveDialog.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.CurrentScreen.Subscribe(screen => + { + if (screen == SearchConfigEditorScreen.ProviderSelection) + _providerSelectionSynced = false; + + if (screen != SearchConfigEditorScreen.Entry) + ResetEntryInput(); + + _contentNode?.Invalidate(); + }) + .DisposeWith(Subscriptions); + ViewModel.Status.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.ValidationSummary.Subscribe(_ => _contentNode?.Invalidate()) + .DisposeWith(Subscriptions); + } + + protected override void OnBound() + { + base.OnBound(); + + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Search", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + _contentSubscriptions.Clear(); + _dialogList = null; + + if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) + return BuildProbeWarningDialog(); + + return ViewModel.CurrentScreen.Value switch + { + SearchConfigEditorScreen.ProviderSelection => BuildProviderSelectionScreen(), + SearchConfigEditorScreen.Entry => BuildEntryScreen(), + SearchConfigEditorScreen.Validating => BuildValidatingScreen(), + SearchConfigEditorScreen.Saved => BuildSavedScreen(), + _ => Layouts.Empty(), + }; + }); + + return _contentNode; + } + + private ILayoutNode BuildProviderSelectionScreen() + { + if (!_providerSelectionSynced) + { + SyncProviderIndexToCurrentBackend(); + _providerSelectionSynced = true; + } + + return WorkflowViewComponents.BuildSelectionScreen( + heading: "Choose the backend Netclaw uses for web search.", + selector: EnsureProviderList().AsLayout(), + legend: ActiveSelectionList.BuildLegend("active backend", "backend has saved setup"), + supportText: ViewModel.GetProviderDescription(EnsureProviderList().FocusedOption.Value)); + } + + private ILayoutNode BuildEntryScreen() + { + var field = ViewModel.CurrentProviderField; + + if (field is null) + { + return WorkflowViewComponents.BuildSelectionScreen( + heading: "DuckDuckGo works without setup, but may hit bot detection.", + selector: Layouts.Empty(), + supportText: "Press Enter to validate and save this provider selection."); + } + + var textInput = EnsureEditingTextInput(field); + textInput.OnFocused(); + + return WorkflowViewComponents.BuildEntryScreen( + title: ViewModel.GetEntryTitle(field), + fieldLabel: field.Label, + input: textInput, + hint: ViewModel.GetEntryHint(field)); + } + + private ILayoutNode BuildValidatingScreen() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode(" Validating Search configuration...").WithForeground(Color.White)) + .WithChild(SpinnerViews.Labeled(ViewModel.GetValidatingMessage(), Color.Yellow)) + .WithChild(new TextNode(" This may take a few seconds.").WithForeground(Color.Gray)); + + private ILayoutNode BuildSavedScreen() + => WorkflowViewComponents.BuildSavedScreen( + successText: ViewModel.GetSavedMessage(), + nextStepText: ViewModel.GetSavedNextStepText()); + + private ActiveSelectionList EnsureProviderList() + => _providerList ??= new ActiveSelectionList( + ViewModel.BackendOptions, + static option => option.Label, + option => string.Equals(option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase), + option => IsConfigured(option.Value) ? "✓" : " ", + SelectProviderForEditing, + () => + { + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + }, + labelPadWidth: 20); + + private ILayoutNode BuildProbeWarningDialog() + { + _dialogList = NetclawValidationDialogViews.BuildActionList(); + + _dialogList.SelectionConfirmed + .Subscribe(async selected => + { + if (selected.Count == 0) + return; + + switch (NetclawValidationDialogViews.ParseAction(selected[0])) + { + case NetclawValidationDialogAction.SaveAnyway: + ViewModel.SaveWithoutProbeOverride(); + break; + case NetclawValidationDialogAction.RetryValidation: + ViewModel.DismissDialog(); + await ViewModel.SubmitCurrentConfigurationAsync(); + break; + default: + ViewModel.DismissDialog(); + break; + } + }) + .DisposeWith(_contentSubscriptions); + + var message = ViewModel.LastProbeResult?.Message ?? "Search validation failed."; + return NetclawValidationDialogViews.BuildWarningPanel( + new NetclawValidationDialogModel( + "Search Validation Warning", + "Netclaw could not complete a live search using this configuration.", + message), + _dialogList); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => Observable.CombineLatest( + ViewModel.CurrentScreen, + ViewModel.ActiveDialog, + (screen, dialog) => + { + var text = dialog == SearchConfigEditorDialog.ProbeWarning + ? " [↑/↓] Navigate [Enter] Select [Esc] Back to edit [Ctrl+Q] Quit" + : screen switch + { + SearchConfigEditorScreen.ProviderSelection => " [↑/↓] Navigate [Enter] Continue [Esc] Back [Ctrl+Q] Quit", + SearchConfigEditorScreen.Entry => " [Enter] Continue [Esc] Back [Ctrl+Q] Quit", + SearchConfigEditorScreen.Validating => " [Ctrl+Q] Quit", + SearchConfigEditorScreen.Saved => " [Enter] Settings Areas [Esc] Review backends [Ctrl+Q] Quit", + _ => " [Ctrl+Q] Quit", + }; + + return (ILayoutNode)NetclawTuiChrome.BuildKeyHintLine(text); + }) + .AsLayout() + .Height(1); + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return true; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + if (ViewModel.ActiveDialog.Value != SearchConfigEditorDialog.None) + { + ViewModel.DismissDialog(); + _contentNode?.Invalidate(); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Entry) + { + BeginProviderSelection(); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) + { + BeginProviderSelection(); + return true; + } + + ViewModel.NavigateBack(); + return true; + } + + if (base.HandlePageInput(keyInfo)) + return true; + + if (ViewModel.ActiveDialog.Value == SearchConfigEditorDialog.ProbeWarning) + { + _dialogList?.HandleInput(keyInfo); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.ProviderSelection) + { + EnsureProviderList().HandleInput(keyInfo); + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Saved) + { + if (keyInfo.Key == ConsoleKey.Enter) + ViewModel.NavigateBack(); + + return true; + } + + if (ViewModel.CurrentScreen.Value == SearchConfigEditorScreen.Entry) + { + if (keyInfo.Key == ConsoleKey.Enter) + { + StageActiveInput(); + _ = ViewModel.SubmitCurrentConfigurationFromInputAsync(); + return true; + } + + if (_textInput is not null) + { + _textInput.HandleInput(keyInfo); + ViewModel.StageFieldValue(_textInputFieldPath!, _textInput.Text); + } + + ViewModel.RequestRedraw(); + return true; + } + + return true; + } + + private void HandlePaste(PasteEvent paste) + { + if (ViewModel.CurrentScreen.Value != SearchConfigEditorScreen.Entry + || _textInput is null + || _textInputFieldPath is null) + { + return; + } + + _textInput.HandlePaste(paste); + ViewModel.StageFieldValue(_textInputFieldPath, _textInput.Text); + ViewModel.RequestRedraw(); + } + + private void BeginProviderSelection() + { + _providerSelectionSynced = false; + ViewModel.BeginBackendSelection(); + ResetEntryInput(); + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void StageActiveInput() + { + if (_textInputFieldPath is not null && _textInput is not null) + ViewModel.StageFieldValue(_textInputFieldPath, _textInput.Text); + } + + private void SyncProviderIndexToCurrentBackend() + { + var index = ViewModel.BackendOptions + .Select((option, idx) => (option, idx)) + .FirstOrDefault(entry => string.Equals(entry.option.Value, ViewModel.CurrentBackendValue, StringComparison.OrdinalIgnoreCase)) + .idx; + + EnsureProviderList().SetFocusedIndex(index, notify: false); + } + + private void SelectProviderForEditing(ConfigEnumOption option) + { + ViewModel.SelectBackendForEditing(option.Value); + ResetEntryInput(); + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void ResetEntryInput() + { + _textInput = null; + _textInputFieldPath = null; + } + + private TextInputNode EnsureEditingTextInput(ProjectedConfigField field) + { + if (_textInput is not null && string.Equals(_textInputFieldPath, field.Path, StringComparison.Ordinal)) + return _textInput; + + _textInput = new TextInputNode(); + _textInputFieldPath = field.Path; + + if (field.Widget == ConfigFieldWidget.PasswordInput) + _textInput.AsPassword(); + if (!string.IsNullOrWhiteSpace(field.Placeholder)) + _textInput.WithPlaceholder(field.Placeholder); + + _textInput.Text = ViewModel.GetEditorSeed(field); + if (!string.IsNullOrEmpty(_textInput.Text)) + _textInput.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + + return _textInput; + } + + private bool IsConfigured(string backend) + => backend switch + { + "brave" => !string.IsNullOrWhiteSpace(ViewModel.FieldValues["Search.BraveApiKey"].Value) + || ViewModel.HasPersistedSecret("Search.BraveApiKey"), + "searxng" => !string.IsNullOrWhiteSpace(ViewModel.FieldValues["Search.SearXngEndpoint"].Value), + _ => true, + }; + + private static Color ToColor(ConfigStatusTone tone) => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.White, + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs new file mode 100644 index 000000000..47b138d2b --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchConfigEditorViewModel.cs @@ -0,0 +1,599 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using Netclaw.Search; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +internal enum SearchConfigEditorDialog +{ + None, + ProbeWarning, +} + +internal enum SearchConfigEditorScreen +{ + ProviderSelection, + Entry, + Validating, + Saved, +} + +internal sealed record SearchProbeResult(bool Success, string Message, ConfigStatusTone Tone); + +internal sealed record SearchFieldCommitResult(bool Success, IReadOnlyList Issues) +{ + public static readonly SearchFieldCommitResult Ok = new(true, []); + + public static SearchFieldCommitResult Invalid(IReadOnlyList issues) + => new(false, issues); +} + +internal sealed class SearchConfigEditorViewModel : ReactiveViewModel +{ + private readonly SearchSectionSpec _spec; + private readonly NetclawPaths _paths; + private readonly SearchEditorPersistenceMapper _mapper; + private readonly SearchEditorValidationAdapter _validator; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly TimeProvider _timeProvider; + private SearchEditorModel _model; + private SearchEditorValidationResult _validation = SearchEditorValidationResult.Empty; + private SearchProbeResult? _lastProbeResult; + // Owned lifetime for the in-flight reachability probe so a navigation/dispose can cancel it and + // its stale result cannot overwrite the reloaded state (the page passed CancellationToken.None). + private CancellationTokenSource? _probeCts; + + public IReadOnlyList Fields => _spec.Fields; + + public Dictionary> FieldValues { get; } = new(StringComparer.Ordinal); + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public SearchConfigEditorViewModel( + NetclawPaths paths, + IHttpClientFactory? httpClientFactory = null, + TimeProvider? timeProvider = null) + { + _spec = new SearchSectionSpec(); + _paths = paths; + _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider ?? TimeProvider.System; + _mapper = new SearchEditorPersistenceMapper(); + _validator = new SearchEditorValidationAdapter(); + _model = _mapper.Load(paths); + + foreach (var field in Fields) + FieldValues[field.Path] = new ReactiveProperty(GetCurrentFieldValue(field.Path)); + + Status = new ReactiveProperty(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + ValidationSummary = new ReactiveProperty(ConfigValidationSummary.Empty); + ActiveDialog = new ReactiveProperty(SearchConfigEditorDialog.None); + CurrentScreen = new ReactiveProperty(SearchConfigEditorScreen.ProviderSelection); + Revalidate(); + } + + public ReactiveProperty Status { get; } + public ReactiveProperty ValidationSummary { get; } + public ReactiveProperty ActiveDialog { get; } + public ReactiveProperty CurrentScreen { get; } + + public bool IsDirty => ComputeIsDirty(); + public SearchProbeResult? LastProbeResult => _lastProbeResult; + public string CurrentBackendValue => _model.Backend.ToWireValue(); + public string CurrentBackendLabel => _spec.GetBackendLabel(_model.Backend); + + public IReadOnlyList BackendOptions { get; } = + [ + new("duckduckgo", "DuckDuckGo"), + new("brave", "Brave"), + new("searxng", "SearXng (self-hosted)") + ]; + + public ProjectedConfigField? CurrentProviderField => _spec.GetProviderField(_model); + + public bool IsCurrentBackendConfigured => _model.Backend switch + { + SearchBackend.Brave => HasEffectiveBraveKey(), + SearchBackend.SearXng => !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint), + _ => true, + }; + + public string GetProviderDescription(string backend) => _spec.GetProviderDescription(backend); + + public string GetEntryTitle(ProjectedConfigField field) => _spec.GetEntryTitle(field); + + public string GetEntryHint(ProjectedConfigField field) => _spec.GetEntryHint(field, _model); + + public string GetValidatingMessage() => _spec.GetValidatingMessage(_model); + + public string GetSavedMessage() => _spec.GetSavedMessage(_model); + + public string GetSavedNextStepText() => _spec.GetSavedNextStepText(); + + public override void Dispose() + { + _probeCts?.Cancel(); + _probeCts?.Dispose(); + + foreach (var value in FieldValues.Values) + value.Dispose(); + + Status.Dispose(); + ValidationSummary.Dispose(); + ActiveDialog.Dispose(); + CurrentScreen.Dispose(); + base.Dispose(); + } + + public SearchFieldCommitResult CommitField(string path, string? value) + { + StageFieldValue(path, value); + var candidate = CloneModel(_model); + ApplyFieldValue(candidate, path, value); + + var candidateValidation = _validator.Validate(candidate); + var issues = candidateValidation.IssuesFor(path); + + if (issues.Count > 0) + { + Status.Value = new ConfigStatusMessage(issues[0].Message, ConfigStatusTone.Error); + RequestRedraw(); + return SearchFieldCommitResult.Invalid(issues); + } + + _model = candidate; + SyncFieldValue(path); + ClearTransientProbeState(); + Revalidate(); + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + return SearchFieldCommitResult.Ok; + } + + public string GetDisplayValue(ProjectedConfigField field) + => field.Path switch + { + "Search.BraveApiKey" when !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft) => "(new secret entered)", + "Search.BraveApiKey" when _model.Brave.HasPersistedApiKey => "(stored secret preserved)", + "Search.SearXngEndpoint" when !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) => _model.SearXng.Endpoint!, + _ => field.InactiveText ?? string.Empty, + }; + + public string GetEditorSeed(ProjectedConfigField field) + => FieldValues.TryGetValue(field.Path, out var property) + ? property.Value + : GetCurrentFieldValue(field.Path); + + public IReadOnlyList GetIssues(ProjectedConfigField field) + => ValidationSummary.Value.IssuesFor(field.Path); + + public IReadOnlyList GetCurrentProviderIssues() + => CurrentProviderField is { } field ? ValidationSummary.Value.IssuesFor(field.Path) : []; + + public string GetSummaryStateText() + => _model.Backend switch + { + SearchBackend.Brave => HasEffectiveBraveKey() ? "API key configured." : "API key required.", + SearchBackend.SearXng => !string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) ? "Endpoint configured." : "Endpoint required.", + _ => "No additional setup required." + }; + + public ConfigStatusTone GetSummaryStateTone() + => _model.Backend switch + { + SearchBackend.Brave when !HasEffectiveBraveKey() => ConfigStatusTone.Warning, + SearchBackend.SearXng when string.IsNullOrWhiteSpace(_model.SearXng.Endpoint) => ConfigStatusTone.Warning, + _ => ConfigStatusTone.Neutral, + }; + + public string? GetCurrentProviderSupportText() + => _model.Backend switch + { + SearchBackend.Brave when _model.Brave.HasPersistedApiKey => "Existing key is configured. Leave blank to keep it.", + SearchBackend.SearXng => "Enter the base URL of your SearXNG instance.", + _ => null, + }; + + public bool HasPersistedSecret(string path) + => string.Equals(path, "Search.BraveApiKey", StringComparison.Ordinal) && _model.Brave.HasPersistedApiKey; + + public void SetFieldValue(string path, string? value) + => CommitField(path, value); + + public void StageFieldValue(string path, string? value) + { + if (!FieldValues.TryGetValue(path, out var property)) + throw new InvalidOperationException($"Unknown search config field '{path}'."); + + property.Value = value ?? string.Empty; + } + + public void CommitFieldValue(string path) + { + if (!FieldValues.TryGetValue(path, out var property)) + throw new InvalidOperationException($"Unknown search config field '{path}'."); + + CommitField(path, property.Value); + } + + public void CommitCurrentProviderDraft() + { + if (CurrentProviderField is { } field) + CommitFieldValue(field.Path); + } + + public void BeginBackendSelection() + { + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.ProviderSelection; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + } + + public void SelectBackendForEditing(string backend) + { + CommitField("Search.Backend", backend); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + } + + public void ReturnToSummary() + { + BeginBackendSelection(); + RequestRedraw(); + } + + public void DismissDialog() + { + ActiveDialog.Value = SearchConfigEditorDialog.None; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + } + + public async Task SubmitCurrentConfigurationAsync(CancellationToken ct = default) + { + if (CurrentProviderField is { } field) + { + var result = CommitField(field.Path, FieldValues[field.Path].Value); + if (!result.Success) + { + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + return false; + } + } + else + { + ClearTransientProbeState(); + Revalidate(); + if (_validation.HasErrors) + { + Status.Value = BuildValidationErrorStatus("Fix structural validation errors before continuing."); + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + return false; + } + } + + return await RunDynamicValidationAsync(persistOnSuccess: true, ct); + } + + public async Task TestCurrentConfigurationAsync(CancellationToken ct = default) + { + Revalidate(); + if (_validation.HasErrors) + { + Status.Value = BuildValidationErrorStatus( + "Fix structural validation errors before testing this search configuration."); + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + RequestRedraw(); + return; + } + + await RunDynamicValidationAsync(persistOnSuccess: false, ct); + } + + public async Task SaveAsync(CancellationToken ct = default) + => await SubmitCurrentConfigurationAsync(ct); + + // Guards against a second Enter (or Enter while a probe is still running) launching an overlapping + // submit. The dispatch is fire-and-forget from the synchronous key handler, so without this two + // rapid submits would race the same network probe and disk write (the same hazard Channels solved + // with its config-write chain). The in-flight task is read/written only on the loop thread (the + // synchronous prefix before the first await), so it needs no synchronization. Exposed as + // PendingSubmit so tests can await completion deterministically. + private Task? _pendingSubmit; + + internal Task? PendingSubmit => _pendingSubmit; + + internal Task SubmitCurrentConfigurationFromInputAsync(CancellationToken ct = default) + { + if (_pendingSubmit is { IsCompleted: false }) + return _pendingSubmit; + + _pendingSubmit = RunAsync(); + return _pendingSubmit; + + async Task RunAsync() + { + try + { + await SubmitCurrentConfigurationAsync(ct); + } + catch (Exception ex) + { + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + Status.Value = new ConfigStatusMessage($"Search settings save failed: {ex.Message}", ConfigStatusTone.Error); + RequestRedraw(); + } + } + } + + public void SaveWithoutProbeOverride() + { + Revalidate(); + if (_validation.HasErrors) + { + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + Status.Value = BuildValidationErrorStatus("Fix structural validation errors before saving this search configuration."); + RequestRedraw(); + return; + } + + _mapper.Save(_paths, _model); + if (!ReloadPersistedDraft()) + return; + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Saved; + Status.Value = new ConfigStatusMessage("Saved Search settings.", ConfigStatusTone.Success); + RequestRedraw(); + } + + public void ResetDraft() + { + if (!ReloadPersistedDraft()) + return; + Status.Value = new ConfigStatusMessage("Reverted unsaved Search edits.", ConfigStatusTone.Neutral); + RequestRedraw(); + } + + public void NavigateBack() + { + if (!ReloadPersistedDraft()) + return; + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.ProviderSelection; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + private void Revalidate() + { + _validation = _validator.Validate(_model); + ValidationSummary.Value = new ConfigValidationSummary( + _validation.Issues.Select(static issue => new ConfigValidationIssue(issue.FieldId, issue.Severity, issue.Message)).ToList()); + } + + private void ClearTransientProbeState() + { + _lastProbeResult = null; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } + + private static SearchEditorModel CloneModel(SearchEditorModel source) + { + var clone = new SearchEditorModel + { + Backend = source.Backend, + }; + + clone.Brave.ApiKeyDraft = source.Brave.ApiKeyDraft; + clone.Brave.HasPersistedApiKey = source.Brave.HasPersistedApiKey; + clone.SearXng.Endpoint = source.SearXng.Endpoint; + return clone; + } + + private static void ApplyFieldValue(SearchEditorModel model, string path, string? value) + { + switch (path) + { + case "Search.Backend": + model.Backend = ParseBackend(value); + break; + case "Search.BraveApiKey": + model.Brave.ApiKeyDraft = Normalize(value); + break; + case "Search.SearXngEndpoint": + model.SearXng.Endpoint = Normalize(value); + break; + default: + throw new InvalidOperationException($"Unknown search config field '{path}'."); + } + } + + private string GetCurrentFieldValue(string path) + => path switch + { + "Search.Backend" => _model.Backend.ToWireValue(), + "Search.BraveApiKey" => _model.Brave.ApiKeyDraft ?? string.Empty, + "Search.SearXngEndpoint" => _model.SearXng.Endpoint ?? string.Empty, + _ => string.Empty, + }; + + private void SyncFieldValue(string path) + { + if (FieldValues.TryGetValue(path, out var property)) + property.Value = GetCurrentFieldValue(path); + } + + private void SyncAllFieldValues() + { + foreach (var field in Fields) + SyncFieldValue(field.Path); + } + + // Returns false when the reload failed, so callers do not navigate / clear status / claim success + // over a faulted state. + private bool ReloadPersistedDraft() + { + // Abandon any in-flight validation probe so its stale result cannot overwrite the reloaded state. + _probeCts?.Cancel(); + + try + { + _model = _mapper.Load(_paths); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or CryptographicException) + { + // A malformed config or an unavailable/rotated DataProtection key ring (the mapper + // decrypts the persisted Brave key) must not crash nav-back or the Save-Anyway path. Keep + // the prior in-memory model and surface the error so the operator can repair it. + Status.Value = new ConfigStatusMessage($"Could not reload search config: {ex.Message}", ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + SyncAllFieldValues(); + _lastProbeResult = null; + Revalidate(); + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.ProviderSelection; + return true; + } + + private async Task RunDynamicValidationAsync(bool persistOnSuccess, CancellationToken ct) + { + _probeCts?.Cancel(); + _probeCts?.Dispose(); + _probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + var probeToken = _probeCts.Token; + + ActiveDialog.Value = SearchConfigEditorDialog.None; + CurrentScreen.Value = SearchConfigEditorScreen.Validating; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + + var probeResult = await ProbeAsync(probeToken); + + // If a navigation/dispose cancelled this run while the probe was in flight, abandon it rather + // than overwriting the now-current screen/model state with a stale result. + if (probeToken.IsCancellationRequested) + return false; + + _lastProbeResult = probeResult; + if (!_lastProbeResult.Success) + { + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + ActiveDialog.Value = SearchConfigEditorDialog.ProbeWarning; + RequestRedraw(); + return false; + } + + if (persistOnSuccess) + { + SaveWithoutProbeOverride(); + return true; + } + + CurrentScreen.Value = SearchConfigEditorScreen.Entry; + Status.Value = new ConfigStatusMessage(_lastProbeResult.Message, _lastProbeResult.Tone); + RequestRedraw(); + return true; + } + + private async Task ProbeAsync(CancellationToken ct) + { + try + { + ISearchBackend searchBackend = _model.Backend switch + { + SearchBackend.Brave => new BraveSearchBackend( + GetEffectiveBraveApiKey(), + CreateHttpClient(), + _timeProvider), + SearchBackend.SearXng => new SearXngBackend( + _model.SearXng.Endpoint ?? string.Empty, + CreateHttpClient(), + _timeProvider), + _ => new DuckDuckGoBackend(CreateHttpClient(), _timeProvider), + }; + + var result = await searchBackend.SearchAsync("netclaw", 1, ct); + return result switch + { + SearchBackendResult.Success => new SearchProbeResult(true, "Search backend test succeeded.", ConfigStatusTone.Success), + SearchBackendResult.Error error => new SearchProbeResult(false, error.Message, ConfigStatusTone.Warning), + _ => new SearchProbeResult(false, "Search backend test failed.", ConfigStatusTone.Warning), + }; + } + catch (Exception ex) + { + return new SearchProbeResult(false, $"Search backend test failed: {ex.Message}", ConfigStatusTone.Warning); + } + } + + private HttpClient CreateHttpClient() + => _httpClientFactory?.CreateClient(string.Empty) ?? new HttpClient(); + + private string GetEffectiveBraveApiKey() + { + if (!string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft)) + return _model.Brave.ApiKeyDraft; + + return ConfigFileHelper.ReadDecryptedSecret(_paths, "Search.BraveApiKey") ?? string.Empty; + } + + private bool HasEffectiveBraveKey() + => !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft) || _model.Brave.HasPersistedApiKey; + + private bool ComputeIsDirty() + { + var persisted = _mapper.Load(_paths); + return persisted.Backend != _model.Backend + || !string.Equals(persisted.SearXng.Endpoint, _model.SearXng.Endpoint, StringComparison.Ordinal) + || !string.IsNullOrWhiteSpace(_model.Brave.ApiKeyDraft); + } + + private ConfigStatusMessage BuildValidationErrorStatus(string fallbackMessage) + { + var issue = GetCurrentProviderIssues().FirstOrDefault() + ?? ValidationSummary.Value.Issues.FirstOrDefault(); + return issue is null + ? new ConfigStatusMessage(fallbackMessage, ConfigStatusTone.Error) + : new ConfigStatusMessage(issue.Message, ConfigStatusTone.Error); + } + + private static SearchBackend ParseBackend(string? value) + => value?.Trim().ToLowerInvariant() switch + { + "brave" => SearchBackend.Brave, + "searxng" => SearchBackend.SearXng, + _ => SearchBackend.DuckDuckGo, + }; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs new file mode 100644 index 000000000..932d46a37 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchEditorModel.cs @@ -0,0 +1,180 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Options; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class SearchEditorModel +{ + public SearchBackend Backend { get; set; } = SearchBackend.DuckDuckGo; + + public BraveSearchEditorModel Brave { get; } = new(); + + public SearXngSearchEditorModel SearXng { get; } = new(); +} + +internal sealed class BraveSearchEditorModel +{ + public string? ApiKeyDraft { get; set; } + + public bool HasPersistedApiKey { get; set; } +} + +internal sealed class SearXngSearchEditorModel +{ + public string? Endpoint { get; set; } +} + +internal sealed class SearchEditorValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, SearchEditorModel options) + { + var errors = new List(); + + if (options.Backend == SearchBackend.Brave + && string.IsNullOrWhiteSpace(options.Brave.ApiKeyDraft) + && !options.Brave.HasPersistedApiKey) + { + errors.Add("Brave requires an API key."); + } + + if (options.Backend == SearchBackend.SearXng) + { + if (string.IsNullOrWhiteSpace(options.SearXng.Endpoint)) + { + errors.Add("SearXNG requires an endpoint URL."); + } + else if (!ChannelsEditorValidator.IsHttpUrl(options.SearXng.Endpoint)) + { + errors.Add("SearXNG endpoint must be an absolute http:// or https:// URL."); + } + } + + return errors.Count > 0 + ? ValidateOptionsResult.Fail(errors) + : ValidateOptionsResult.Success; + } + +} + +internal sealed class SearchEditorPersistenceMapper +{ + internal SearchEditorModel Load(NetclawPaths paths) + { + var config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + + var backend = ConfigFileHelper.TryGetPathValue(config, "Search.Backend", out var backendRaw) + ? ParseBackend(backendRaw?.ToString()) + : SearchBackend.DuckDuckGo; + + var endpoint = ConfigFileHelper.TryGetPathValue(config, "Search.SearXngEndpoint", out var endpointRaw) + ? endpointRaw?.ToString() + : null; + + var persistedBraveKey = ConfigFileHelper.ReadDecryptedSecret(paths, "Search.BraveApiKey"); + + return new SearchEditorModel + { + Backend = backend, + Brave = + { + HasPersistedApiKey = !string.IsNullOrWhiteSpace(persistedBraveKey), + }, + SearXng = + { + Endpoint = Normalize(endpoint), + } + }; + } + + internal void Save(NetclawPaths paths, SearchEditorModel model) + { + var session = new ConfigEditorSession(paths); + session.Apply(BuildContribution(model)); + session.Save(); + } + + internal SectionContribution BuildContribution(SearchEditorModel model) + { + var fieldActions = new List + { + new("Search.Backend", SectionFieldActionKind.Set, model.Backend.ToWireValue()) + }; + + var endpoint = Normalize(model.SearXng.Endpoint); + if (!string.IsNullOrWhiteSpace(endpoint)) + fieldActions.Add(new SectionFieldAction("Search.SearXngEndpoint", SectionFieldActionKind.Set, endpoint)); + + var secretActions = new List(); + if (model.Backend == SearchBackend.Brave) + { + var apiKey = Normalize(model.Brave.ApiKeyDraft); + if (!string.IsNullOrWhiteSpace(apiKey)) + secretActions.Add(new SectionSecretAction( + "Search.BraveApiKey", + SectionSecretActionKind.Set, + new SensitiveString(apiKey))); + else if (model.Brave.HasPersistedApiKey) + secretActions.Add(new SectionSecretAction("Search.BraveApiKey", SectionSecretActionKind.Preserve)); + } + + return new SectionContribution(fieldActions, secretActions); + } + + private static SearchBackend ParseBackend(string? value) + => value?.Trim().ToLowerInvariant() switch + { + "brave" => SearchBackend.Brave, + "searxng" => SearchBackend.SearXng, + _ => SearchBackend.DuckDuckGo, + }; + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal sealed record SearchEditorValidationIssue(string? FieldId, string Message, ConfigValidationSeverity Severity); + +internal sealed record SearchEditorValidationResult(IReadOnlyList Issues) +{ + public static readonly SearchEditorValidationResult Empty = new([]); + + public bool HasErrors => Issues.Any(static issue => issue.Severity == ConfigValidationSeverity.Error); + + public IReadOnlyList IssuesFor(string fieldId) + => [.. Issues.Where(issue => string.Equals(issue.FieldId, fieldId, StringComparison.Ordinal))]; +} + +internal sealed class SearchEditorValidationAdapter +{ + private readonly SearchEditorValidator _validator = new(); + + internal SearchEditorValidationResult Validate(SearchEditorModel model) + { + var result = _validator.Validate(name: null, model); + if (result.Succeeded) + return SearchEditorValidationResult.Empty; + + var failures = result.Failures ?? []; + var issues = new List(); + foreach (var failure in failures) + { + issues.Add(failure switch + { + var message when message.Contains("API key", StringComparison.OrdinalIgnoreCase) + => new SearchEditorValidationIssue("Search.BraveApiKey", message, ConfigValidationSeverity.Error), + var message when message.Contains("endpoint", StringComparison.OrdinalIgnoreCase) + => new SearchEditorValidationIssue("Search.SearXngEndpoint", message, ConfigValidationSeverity.Error), + _ => new SearchEditorValidationIssue(null, failure, ConfigValidationSeverity.Error), + }); + } + + return new SearchEditorValidationResult(issues); + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs new file mode 100644 index 000000000..3c8f34564 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SearchSectionSpec.cs @@ -0,0 +1,143 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Config; + +/// +/// Authoritative editor contract for the Search workflow. This is intentionally limited to +/// editor semantics and persisted-file behavior; runtime config loading continues to bind from +/// IConfiguration using the existing netclaw.json + secrets.json + environment overlay. +/// +internal sealed class SearchSectionSpec +{ + private static readonly string BackendPath = ConfigValueMetadataProvider.Get(nameof(SearchConfig.Backend)).Key; + private static readonly string BraveApiKeyPath = ConfigValueMetadataProvider.Get(nameof(SearchConfig.BraveApiKey)).Key; + private static readonly string SearXngEndpointPath = ConfigValueMetadataProvider.Get(nameof(SearchConfig.SearXngEndpoint)).Key; + + internal IReadOnlyList Fields { get; } = + [ + new( + Path: BackendPath, + PropertyName: nameof(SearchConfig.Backend), + Label: "Backend", + Description: "Search backend identifier.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.ConfigFile, + Widget: ConfigFieldWidget.EnumSelection, + Nullable: false, + DefaultValue: SearchBackend.DuckDuckGo.ToWireValue(), + TrimDefaultOnSave: true, + PreserveBlankSecret: false, + Placeholder: null, + Hint: "Choose your web search provider.", + ApplicableWhenPath: null, + ApplicableWhenEquals: null, + InactiveText: null, + EnumOptions: + [ + new("duckduckgo", "DuckDuckGo"), + new("brave", "Brave"), + new("searxng", "SearXng (self-hosted)") + ]), + new( + Path: BraveApiKeyPath, + PropertyName: nameof(SearchConfig.BraveApiKey), + Label: "Brave API key", + Description: "Brave Search API key. Required when Backend is Brave. Stored in secrets.json.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.SecretsFile, + Widget: ConfigFieldWidget.PasswordInput, + Nullable: true, + DefaultValue: null, + TrimDefaultOnSave: false, + PreserveBlankSecret: true, + Placeholder: "Enter Brave Search API key...", + Hint: "Stored in secrets.json. Leave blank to keep the existing key.", + ApplicableWhenPath: BackendPath, + ApplicableWhenEquals: "brave", + InactiveText: "(not configured)", + EnumOptions: []), + new( + Path: SearXngEndpointPath, + PropertyName: nameof(SearchConfig.SearXngEndpoint), + Label: "SearXng instance URL", + Description: "SearXNG instance base URL. Required when Backend is SearXng.", + ValueKind: ConfigFieldValueKind.String, + Storage: ConfigFieldStorage.ConfigFile, + Widget: ConfigFieldWidget.TextInput, + Nullable: true, + DefaultValue: null, + TrimDefaultOnSave: true, + PreserveBlankSecret: false, + Placeholder: "https://search.example.com", + Hint: "Enter the base URL of your SearXNG instance.", + ApplicableWhenPath: BackendPath, + ApplicableWhenEquals: "searxng", + InactiveText: "(not configured)", + EnumOptions: []) + ]; + + internal ProjectedConfigField? GetProviderField(SearchEditorModel model) + => model.Backend switch + { + SearchBackend.Brave => GetField(BraveApiKeyPath), + SearchBackend.SearXng => GetField(SearXngEndpointPath), + _ => null, + }; + + internal string GetProviderDescription(string backend) + => backend switch + { + "brave" => "Brave Search requires an API key and is usually more reliable than DuckDuckGo.", + "searxng" => "SearXNG uses your own endpoint URL and supports self-hosted search.", + _ => "DuckDuckGo works without setup, but may hit bot detection.", + }; + + internal string GetEntryTitle(ProjectedConfigField field) + => field.Path switch + { + var path when path == BraveApiKeyPath + => "Brave Search requires an API key.", + _ => "Enter the base URL of your SearXNG instance.", + }; + + internal string GetEntryHint(ProjectedConfigField field, SearchEditorModel model) + => field.Path switch + { + var path when path == BraveApiKeyPath + && model.Brave.HasPersistedApiKey + => "Stored in secrets.json. Leave blank to keep the existing key. Press Enter to validate and save.", + var path when path == BraveApiKeyPath + => "Stored in secrets.json. Press Enter to validate and save.", + _ => "Netclaw will validate the URL and probe it on Enter.", + }; + + internal string GetValidatingMessage(SearchEditorModel model) + => model.Backend switch + { + SearchBackend.Brave => "Probing Brave Search", + SearchBackend.SearXng => "Probing SearXNG instance", + _ => "Validating DuckDuckGo configuration", + }; + + internal string GetSavedMessage(SearchEditorModel model) + => $"✔ {GetBackendLabel(model.Backend)} validated and saved."; + + internal string GetSavedNextStepText() + => "Press Enter to return to Settings Areas or Esc to review Search backends."; + + internal string GetBackendLabel(SearchBackend backend) + => backend switch + { + SearchBackend.Brave => "Brave", + SearchBackend.SearXng => "SearXng (self-hosted)", + _ => "DuckDuckGo", + }; + + private ProjectedConfigField GetField(string path) + => Fields.First(field => string.Equals(field.Path, path, StringComparison.Ordinal)); +} diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs new file mode 100644 index 000000000..5f8334fee --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -0,0 +1,402 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +public sealed class SecurityAccessPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _keyBindingsNode; + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Mode.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.SelectedIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedPostureIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedCascadeIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedFeatureIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedAudienceIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.SelectedAudienceRowIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Security & Access", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private ILayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => ViewModel.Mode.Value switch + { + SecurityAccessEditorMode.Posture => BuildPostureEditor(), + SecurityAccessEditorMode.PostureCascade => BuildPostureCascade(), + SecurityAccessEditorMode.Features => BuildFeatureToggles(), + SecurityAccessEditorMode.AudienceList => BuildAudienceList(), + SecurityAccessEditorMode.AudienceProfile => BuildAudienceProfile(), + _ => BuildSecurityMenu() + }); + + return _contentNode; + } + + private ILayoutNode BuildSecurityMenu() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Security & Access")); + + var items = ViewModel.Items; + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + layout = layout.WithChild(Row( + $"{FocusPrefix(i == ViewModel.SelectedIndex.Value)}{item.Label,-20} {item.Summary,-20} {item.Description}", + i == ViewModel.SelectedIndex.Value)); + } + + return layout; + } + + private ILayoutNode BuildPostureEditor() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Security Posture")) + .WithChild(Hint($" Current posture: {ViewModel.CurrentPosture}")) + .WithChild(Layouts.Empty().Height(1)); + + var options = ViewModel.PostureOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedPostureIndex.Value; + var active = option.Value == ViewModel.CurrentPosture; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}[{Check(active)}] {option.Label,-10} {option.Description}", + focused, + active)); + } + + return layout; + } + + private ILayoutNode BuildPostureCascade() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Posture change affects Audience Profiles")) + .WithChild(Hint(" You have customized Audience Profiles. Changing posture can overwrite them.")) + .WithChild(Layouts.Empty().Height(1)); + + var options = ViewModel.PostureCascadeOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedCascadeIndex.Value; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{option.Label,-42} {option.Description}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildFeatureToggles() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Enabled Features")) + .WithChild(Hint(" Toggle global runtime features. Audience exposure is configured separately.")) + .WithChild(Layouts.Empty().Height(1)); + + var names = ViewModel.FeatureNames; + var descriptions = ViewModel.FeatureDescriptions; + for (var i = 0; i < names.Count; i++) + { + var focused = i == ViewModel.SelectedFeatureIndex.Value; + var enabled = ViewModel.IsFeatureEnabled(i); + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}[{Check(enabled)}] {names[i],-12} {descriptions[i]}", + focused, + enabled)); + } + + return layout; + } + + private ILayoutNode BuildAudienceList() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Audience Profiles")) + .WithChild(Hint($" System default posture: {ViewModel.CurrentPosture}")) + .WithChild(Hint(" Customize audience/channel access when it should differ.")) + .WithChild(Legend(" * global default audience Customized = custom overrides")) + .WithChild(Layouts.Empty().Height(1)); + + var options = ViewModel.AudienceOptions; + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + var focused = i == ViewModel.SelectedAudienceIndex.Value; + var marker = ViewModel.AudienceOverrideMarker(option.Value); + var defaultMarker = ViewModel.IsSystemDefaultAudience(option.Value) ? "*" : " "; + layout = layout.WithChild(Row( + $"{FocusPrefix(focused)}{defaultMarker} {option.Label,-9} {option.Description,-34} {marker}", + focused)); + } + + return layout; + } + + private ILayoutNode BuildAudienceProfile() + { + var audience = ViewModel.AudienceOptions[ViewModel.SelectedAudienceIndex.Value]; + var layout = Layouts.Vertical() + .WithChild(Header($" Audience Profile: {audience.Label}")) + .WithChild(Hint($" System default posture: {ViewModel.CurrentPosture}")) + .WithChild(Hint($" Profile: {ViewModel.SelectedAudienceOverrideStatus}")) + .WithChild(Layouts.Empty().Height(1)); + + var rows = ViewModel.ProfileRows; + for (var i = 0; i < rows.Count; i++) + { + var row = rows[i]; + var focused = i == ViewModel.SelectedAudienceRowIndex.Value; + if (row.Kind == AudienceProfileRowKind.FileTools) + layout = layout.WithChild(Section(" Tools")); + if (row.Kind == AudienceProfileRowKind.FileAccess) + layout = layout.WithChild(Layouts.Empty().Height(1)).WithChild(Section(" Access")); + if (row.Kind == AudienceProfileRowKind.ResetToDefault) + layout = layout.WithChild(Layouts.Empty().Height(1)).WithChild(Section(" Actions")); + + var line = row.Kind switch + { + AudienceProfileRowKind.FileAccess or AudienceProfileRowKind.IncomingAttachments => + $"{FocusPrefix(focused)}{row.Label,-14} {CycleValue(ViewModel.AudienceValue(row.Kind))}", + AudienceProfileRowKind.McpPermissions => + $"{FocusPrefix(focused)}{row.Label,-14} [Open] {ViewModel.AudienceValue(row.Kind)}", + AudienceProfileRowKind.ResetToDefault => + $"{FocusPrefix(focused)}{row.Label,-14} [Reset]", + _ => + $"{FocusPrefix(focused)}[{Check(ViewModel.IsAudienceToggleEnabled(row.Kind))}] {row.Label}" + }; + + var enabled = row.Kind switch + { + AudienceProfileRowKind.FileAccess or AudienceProfileRowKind.IncomingAttachments or AudienceProfileRowKind.McpPermissions or AudienceProfileRowKind.ResetToDefault => true, + _ => ViewModel.IsAudienceToggleEnabled(row.Kind) + }; + layout = layout.WithChild(Row(line, focused, enabled)); + } + + var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value]; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}")); + + return layout; + } + + private LayoutNode BuildStatusBar() + => ViewModel.StatusMessage + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + { + _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine(ViewModel.Mode.Value switch + { + SecurityAccessEditorMode.Posture => " [↑/↓] Navigate [Enter] Apply [Esc] Security & Access [Ctrl+Q] Quit", + SecurityAccessEditorMode.PostureCascade => " [↑/↓] Navigate [Enter] Apply [Esc] Back [Ctrl+Q] Quit", + SecurityAccessEditorMode.Features => " [↑/↓] Navigate [Space/Enter] Toggle/Save [Esc] Security & Access [Ctrl+Q] Quit", + SecurityAccessEditorMode.AudienceList => " [↑/↓] Navigate [Enter] Edit Audience [Esc] Security & Access [Ctrl+Q] Quit", + SecurityAccessEditorMode.AudienceProfile => " [↑/↓] Navigate [←/→] Change [Space/Enter] Toggle/Apply [Esc] Audiences [Ctrl+Q] Quit", + _ => " [↑/↓] Navigate [Enter] Open [Esc] Back [Ctrl+Q] Quit" + })); + + return _keyBindingsNode.Height(1); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + switch (ViewModel.Mode.Value) + { + case SecurityAccessEditorMode.Menu: + HandleMenuKey(keyInfo); + break; + case SecurityAccessEditorMode.Posture: + HandlePostureKey(keyInfo); + break; + case SecurityAccessEditorMode.PostureCascade: + HandleCascadeKey(keyInfo); + break; + case SecurityAccessEditorMode.Features: + HandleFeatureKey(keyInfo); + break; + case SecurityAccessEditorMode.AudienceList: + HandleAudienceListKey(keyInfo); + break; + case SecurityAccessEditorMode.AudienceProfile: + HandleAudienceProfileKey(keyInfo); + break; + } + + _contentNode?.Invalidate(); + ViewModel.RequestRedraw(); + } + + private void HandleMenuKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + break; + } + } + + private void HandlePostureKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MovePostureSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MovePostureSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplySelectedPosture(); + break; + } + } + + private void HandleCascadeKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveCascadeSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveCascadeSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.ApplySelectedCascadeOption(); + break; + } + } + + private void HandleFeatureKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveFeatureSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveFeatureSelection(1); + break; + case ConsoleKey.Spacebar: + case ConsoleKey.Enter: + ViewModel.ToggleSelectedFeature(); + break; + } + } + + private void HandleAudienceListKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveAudienceSelection(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveAudienceSelection(1); + break; + case ConsoleKey.Enter: + ViewModel.OpenSelectedAudienceProfile(); + break; + } + } + + private void HandleAudienceProfileKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveAudienceRow(-1); + break; + case ConsoleKey.DownArrow: + ViewModel.MoveAudienceRow(1); + break; + case ConsoleKey.LeftArrow: + ViewModel.ChangeSelectedAudienceProfileRow(-1); + break; + case ConsoleKey.RightArrow: + ViewModel.ChangeSelectedAudienceProfileRow(1); + break; + case ConsoleKey.Spacebar: + case ConsoleKey.Enter: + ViewModel.ActivateSelectedAudienceProfileRow(); + break; + } + } + + private void InvalidateAll() + { + _contentNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + } + + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Section(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Legend(string text) => new TextNode(text).WithForeground(Color.White); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.BrightBlack); + + // Constant indent so non-selected rows keep the same content column the + // focused full-width bar uses (the bar replaces the old ▶ marker). + private static string FocusPrefix(bool focused) => " "; + private static string Check(bool enabled) => enabled ? "✓" : " "; + private static string CycleValue(string value) => $"[◀ {value,-17} ▶]"; + + private static ILayoutNode Row(string line, bool focused, bool enabled = true) + => ConfigSelectionRow.Create(line, focused, enabled ? Color.White : Color.BrightBlack); +} diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs new file mode 100644 index 000000000..bca0e3044 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -0,0 +1,975 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Cli.Json; +using Netclaw.Cli.Mcp; +using Netclaw.Cli.Tui.Sections; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Media; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +public sealed record SecurityAccessItem(string Label, string Summary, string Description, string? Route = null); + +public enum SecurityAccessEditorMode +{ + Menu, + Posture, + PostureCascade, + Features, + AudienceList, + AudienceProfile +} + +public sealed record SecurityPostureOption(DeploymentPosture Value, string Label, string Description); +public sealed record SecurityAudienceOption(TrustAudience Value, string Label, string Description); +public sealed record SecurityCascadeOption(string Label, string Description); +public sealed record AudienceProfileRow(AudienceProfileRowKind Kind, string Label, string Description); + +public enum AudienceProfileRowKind +{ + FileTools, + WebAccess, + Skills, + Scheduling, + ChangeWorkingDirectory, + FileAccess, + IncomingAttachments, + McpPermissions, + ResetToDefault +} + +public sealed class SecurityAccessViewModel : ReactiveViewModel +{ + private static readonly string[] FeatureConfigPaths = + [ + "Memory.Enabled", + "Search.Enabled", + "SkillSync.Enabled", + "Scheduling.Enabled", + "SubAgents.Enabled", + "Webhooks.Enabled" + ]; + + private static readonly SecurityPostureOption[] Postures = + [ + new(DeploymentPosture.Personal, "Personal", "Just me. Local-only by default. Tools have wide access."), + new(DeploymentPosture.Team, "Team", "Small team via Slack/Discord. Audience-restricted tools."), + new(DeploymentPosture.Public, "Public", "Open to untrusted users. Strict defaults and access controls.") + ]; + + private static readonly SecurityAudienceOption[] Audiences = + [ + new(TrustAudience.Personal, "Personal", "Operator/local sessions."), + new(TrustAudience.Team, "Team", "Trusted internal channels."), + new(TrustAudience.Public, "Public", "Untrusted external users.") + ]; + + private static readonly SecurityCascadeOption[] CascadeOptions = + [ + new("Cancel - keep current posture", "Do not change posture or audience profiles."), + new("Apply new posture, overwrite profiles", "Reset all audience profiles to posture defaults."), + new("Apply new posture, keep custom profiles", "Only change deployment posture and shell defaults.") + ]; + + private static readonly AudienceProfileRow[] AudienceRows = + [ + new(AudienceProfileRowKind.FileTools, "File tools", "Read, attach, write, and edit files."), + new(AudienceProfileRowKind.WebAccess, "Web", "web_search and web_fetch."), + new(AudienceProfileRowKind.Skills, "Skills", "Skill management tools."), + new(AudienceProfileRowKind.Scheduling, "Scheduling", "Reminder tools."), + new(AudienceProfileRowKind.ChangeWorkingDirectory, "Change workspace", "Allow workspace switching."), + new(AudienceProfileRowKind.FileAccess, "File scope", "Filesystem scope for file tools."), + new(AudienceProfileRowKind.IncomingAttachments, "Attachments", "Accepted channel attachment types."), + new(AudienceProfileRowKind.McpPermissions, "MCP grants", "Managed separately."), + new(AudienceProfileRowKind.ResetToDefault, "Reset overrides", "Restore this audience to the current posture baseline.") + ]; + + private static IReadOnlyList FileTools => ToolAudienceProfileToolCatalog.FileTools; + private static IReadOnlyList WebTools => ToolAudienceProfileToolCatalog.WebTools; + private static IReadOnlyList SkillTools => ToolAudienceProfileToolCatalog.SkillTools; + private static IReadOnlyList SchedulingTools => ToolAudienceProfileToolCatalog.SchedulingTools; + private static IReadOnlyList WorkingDirectoryTools => ToolAudienceProfileToolCatalog.WorkingDirectoryTools; + + private readonly NetclawPaths _paths; + private readonly McpToolPermissionsNavigationState? _mcpNavigationState; + private readonly bool[] _enabledFeatures = new bool[FeatureConfigPaths.Length]; + private DeploymentPosture? _pendingPosture; + + public SecurityAccessViewModel( + NetclawPaths paths, + McpToolPermissionsNavigationState? mcpNavigationState = null) + { + _paths = paths; + _mcpNavigationState = mcpNavigationState; + LoadEnabledFeatures(); + } + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ReactiveProperty StatusMessage { get; } = new(""); + public ReactiveProperty Mode { get; } = new(SecurityAccessEditorMode.Menu); + public ReactiveProperty SelectedIndex { get; } = new(0); + public ReactiveProperty SelectedPostureIndex { get; } = new(0); + public ReactiveProperty SelectedCascadeIndex { get; } = new(0); + public ReactiveProperty SelectedFeatureIndex { get; } = new(0); + public ReactiveProperty SelectedAudienceIndex { get; } = new(0); + public ReactiveProperty SelectedAudienceRowIndex { get; } = new(0); + + public IReadOnlyList Items => BuildItems(); + public IReadOnlyList PostureOptions => Postures; + public IReadOnlyList PostureCascadeOptions => CascadeOptions; + public IReadOnlyList AudienceOptions => Audiences; + public IReadOnlyList ProfileRows => AudienceRows; + public IReadOnlyList FeatureNames => FeatureSelectionStepViewModel.FeatureNames; + public IReadOnlyList FeatureDescriptions => FeatureSelectionStepViewModel.FeatureDescriptions; + public TrustAudience SelectedAudience => Audiences[SelectedAudienceIndex.Value].Value; + public DeploymentPosture CurrentPosture => ReadPosture(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath)); + + /// + /// Non-null when Security.DeploymentPosture holds an unrecognized value. The editor fails + /// closed ( reports ) and + /// surfaces this so the operator sees the config is corrupt instead of the editor silently + /// assuming a posture. + /// + public string? PostureConfigWarning => + TryReadPosture(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath), out _, out var invalid) + ? null + : $"Unknown deployment posture '{invalid}' in config — treating as Public (most restrictive). Fix Security.DeploymentPosture."; + public string SelectedAudienceOverrideStatus => AudienceHasOverrides(SelectedAudience) ? "Customized overrides" : "No custom overrides"; + + public void MoveSelection(int delta) + { + var items = Items; + if (items.Count == 0) + return; + + var next = Math.Clamp(SelectedIndex.Value + delta, 0, items.Count - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length); + public void MoveCascadeSelection(int delta) => Move(SelectedCascadeIndex, delta, CascadeOptions.Length); + public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureConfigPaths.Length); + public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length); + public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length); + + public void ActivateSelected() + { + switch (Mode.Value) + { + case SecurityAccessEditorMode.Menu: + var items = Items; + if (items.Count > 0) + Activate(items[SelectedIndex.Value]); + break; + case SecurityAccessEditorMode.Posture: + ApplySelectedPosture(); + break; + case SecurityAccessEditorMode.PostureCascade: + ApplySelectedCascadeOption(); + break; + case SecurityAccessEditorMode.Features: + ToggleSelectedFeature(); + break; + case SecurityAccessEditorMode.AudienceList: + OpenSelectedAudienceProfile(); + break; + case SecurityAccessEditorMode.AudienceProfile: + ActivateSelectedAudienceProfileRow(); + break; + } + } + + internal void Activate(SecurityAccessItem item) + { + switch (item.Label) + { + case "Security Posture": + OpenPostureEditor(); + return; + case "Enabled Features": + OpenFeatureEditor(); + return; + case "Audience Profiles": + OpenAudienceList(); + return; + } + + if (item.Route is not null) + { + RouteRequested?.Invoke(item.Route); + Navigate?.Invoke(item.Route); + return; + } + + StatusMessage.Value = $"{item.Label} is not implemented yet in `netclaw config`."; + RequestRedraw(); + } + + public void GoBack() + { + switch (Mode.Value) + { + case SecurityAccessEditorMode.AudienceProfile: + Mode.Value = SecurityAccessEditorMode.AudienceList; + StatusMessage.Value = ""; + RequestRedraw(); + return; + case SecurityAccessEditorMode.PostureCascade: + Mode.Value = SecurityAccessEditorMode.Posture; + _pendingPosture = null; + StatusMessage.Value = ""; + RequestRedraw(); + return; + case SecurityAccessEditorMode.Posture: + case SecurityAccessEditorMode.Features: + case SecurityAccessEditorMode.AudienceList: + Mode.Value = SecurityAccessEditorMode.Menu; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void OpenPostureEditor() + { + var current = CurrentPosture; + var index = Array.FindIndex(Postures, option => option.Value == current); + SelectedPostureIndex.Value = index < 0 ? 0 : index; + Mode.Value = SecurityAccessEditorMode.Posture; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void ApplySelectedPosture() + { + var posture = Postures[SelectedPostureIndex.Value].Value; + if (posture == CurrentPosture) + { + StatusMessage.Value = $"{posture} posture is already active."; + RequestRedraw(); + return; + } + + _pendingPosture = posture; + if (AudienceProfilesCustomized()) + { + SelectedCascadeIndex.Value = 0; + Mode.Value = SecurityAccessEditorMode.PostureCascade; + StatusMessage.Value = ""; + RequestRedraw(); + return; + } + + SavePosture(posture, overwriteProfiles: true); + } + + public void ApplySelectedCascadeOption() + { + if (_pendingPosture is not { } posture) + { + Mode.Value = SecurityAccessEditorMode.Posture; + return; + } + + switch (SelectedCascadeIndex.Value) + { + case 0: + _pendingPosture = null; + Mode.Value = SecurityAccessEditorMode.Posture; + StatusMessage.Value = "Posture change cancelled."; + RequestRedraw(); + break; + case 1: + SavePosture(posture, overwriteProfiles: true); + break; + case 2: + SavePosture(posture, overwriteProfiles: false); + break; + } + } + + public void OpenFeatureEditor() + { + LoadEnabledFeatures(); + Mode.Value = SecurityAccessEditorMode.Features; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public bool IsFeatureEnabled(int index) => _enabledFeatures[index]; + + public void ToggleSelectedFeature() + { + var index = SelectedFeatureIndex.Value; + _enabledFeatures[index] = !_enabledFeatures[index]; + + if (!TryApplyAndSave(BuildFeatureContribution(), "enabled features")) + { + // Roll the in-memory flip back so a failed save leaves the toggle state and disk in + // agreement — BuildFeatureContribution serializes the whole array, so a toggle that + // never reached disk must not "stick" in memory. + _enabledFeatures[index] = !_enabledFeatures[index]; + return; + } + + var state = _enabledFeatures[index] ? "enabled" : "disabled"; + StatusMessage.Value = $"{FeatureNames[index]} {state}. Saved."; + RequestRedraw(); + } + + public void OpenAudienceList() + { + Mode.Value = SecurityAccessEditorMode.AudienceList; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void OpenSelectedAudienceProfile() + { + SelectedAudienceRowIndex.Value = 0; + Mode.Value = SecurityAccessEditorMode.AudienceProfile; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public bool IsSystemDefaultAudience(TrustAudience audience) + => audience switch + { + TrustAudience.Personal => CurrentPosture == DeploymentPosture.Personal, + TrustAudience.Team => CurrentPosture == DeploymentPosture.Team, + TrustAudience.Public => CurrentPosture == DeploymentPosture.Public, + _ => false + }; + + public string AudienceOverrideMarker(TrustAudience audience) => AudienceHasOverrides(audience) ? "Customized" : ""; + + public bool IsAudienceToggleEnabled(AudienceProfileRowKind kind) + { + var profile = GetSelectedProfile(); + return kind switch + { + AudienceProfileRowKind.FileTools => ToolGroupEnabled(profile, FileTools), + AudienceProfileRowKind.WebAccess => ToolGroupEnabled(profile, WebTools), + AudienceProfileRowKind.Skills => ToolGroupEnabled(profile, SkillTools), + AudienceProfileRowKind.Scheduling => ToolGroupEnabled(profile, SchedulingTools), + AudienceProfileRowKind.ChangeWorkingDirectory => ToolGroupEnabled(profile, WorkingDirectoryTools), + _ => false + }; + } + + public string AudienceValue(AudienceProfileRowKind kind) + { + var profile = GetSelectedProfile(); + return kind switch + { + AudienceProfileRowKind.FileAccess => DescribeFilesystem(profile), + AudienceProfileRowKind.IncomingAttachments => DescribeAttachments(profile.ChannelAttachments), + AudienceProfileRowKind.McpPermissions => "netclaw mcp permissions", + AudienceProfileRowKind.ResetToDefault => "", + _ => IsAudienceToggleEnabled(kind) ? "Enabled" : "Disabled" + }; + } + + public void ActivateSelectedAudienceProfileRow() + { + var row = AudienceRows[SelectedAudienceRowIndex.Value]; + switch (row.Kind) + { + case AudienceProfileRowKind.FileTools: + ToggleToolGroup(row.Kind, FileTools); + return; + case AudienceProfileRowKind.WebAccess: + ToggleToolGroup(row.Kind, WebTools); + return; + case AudienceProfileRowKind.Skills: + ToggleToolGroup(row.Kind, SkillTools); + return; + case AudienceProfileRowKind.Scheduling: + ToggleToolGroup(row.Kind, SchedulingTools); + return; + case AudienceProfileRowKind.ChangeWorkingDirectory: + ToggleToolGroup(row.Kind, WorkingDirectoryTools); + return; + case AudienceProfileRowKind.FileAccess: + CycleFileAccess(1); + return; + case AudienceProfileRowKind.IncomingAttachments: + CycleIncomingAttachments(1); + return; + case AudienceProfileRowKind.McpPermissions: + _mcpNavigationState?.RequestInitialAudience(SelectedAudience); + RouteRequested?.Invoke("/mcp-tools"); + Navigate?.Invoke("/mcp-tools"); + return; + case AudienceProfileRowKind.ResetToDefault: + ResetSelectedAudienceProfile(); + return; + } + } + + public void ChangeSelectedAudienceProfileRow(int direction) + { + var row = AudienceRows[SelectedAudienceRowIndex.Value]; + switch (row.Kind) + { + case AudienceProfileRowKind.FileAccess: + CycleFileAccess(direction); + return; + case AudienceProfileRowKind.IncomingAttachments: + CycleIncomingAttachments(direction); + return; + } + } + + public string AudienceRowHelp(AudienceProfileRowKind kind) + { + var profile = GetSelectedProfile(); + return kind switch + { + AudienceProfileRowKind.FileTools => "File tools grant read/list/attach/write/edit; File scope below limits where they can operate.", + AudienceProfileRowKind.WebAccess => "Web grants web_search and web_fetch for this audience.", + AudienceProfileRowKind.Skills => "Skills grants skill management and loading tools for this audience.", + AudienceProfileRowKind.Scheduling => "Scheduling grants reminder create/list/cancel/history tools.", + AudienceProfileRowKind.ChangeWorkingDirectory => "Change workspace lets sessions switch workspace roots.", + AudienceProfileRowKind.FileAccess => DescribeFilesystemHelp(profile), + AudienceProfileRowKind.IncomingAttachments => DescribeAttachmentHelp(profile.ChannelAttachments), + AudienceProfileRowKind.McpPermissions => "MCP server and per-tool grants are managed in the dedicated MCP permissions editor.", + AudienceProfileRowKind.ResetToDefault => "Reset overrides restores this audience to the current global posture baseline, including hidden MCP and approval settings.", + _ => string.Empty + }; + } + + public void ResetSelectedAudienceProfile() + { + var profiles = BuildPostureProfiles(CurrentPosture); + SaveAudienceProfile(GetProfile(profiles, SelectedAudience)); + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} overrides reset to the {CurrentPosture} posture baseline."; + RequestRedraw(); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + StatusMessage.Dispose(); + Mode.Dispose(); + SelectedIndex.Dispose(); + SelectedPostureIndex.Dispose(); + SelectedCascadeIndex.Dispose(); + SelectedFeatureIndex.Dispose(); + SelectedAudienceIndex.Dispose(); + SelectedAudienceRowIndex.Dispose(); + base.Dispose(); + } + + private void SavePosture(DeploymentPosture posture, bool overwriteProfiles) + { + var shellMode = posture == DeploymentPosture.Personal + ? ShellExecutionMode.HostAllowed + : ShellExecutionMode.Off; + + var fieldActions = new List + { + new("Security.DeploymentPosture", SectionFieldActionKind.Set, posture.ToString()), + new("Security.ShellExecutionMode", SectionFieldActionKind.Set, shellMode.ToString()), + new("Security.StrictDefaults", SectionFieldActionKind.Set, true), + new("Tools.ShellMode", SectionFieldActionKind.Set, shellMode.ToString()) + }; + + if (overwriteProfiles) + fieldActions.Add(new SectionFieldAction("Tools.AudienceProfiles", SectionFieldActionKind.Set, BuildPostureProfiles(posture))); + + if (!TryApplyAndSave(new SectionContribution(fieldActions), "security posture")) + return; + + _pendingPosture = null; + StatusMessage.Value = overwriteProfiles + ? $"{posture} posture saved and audience profiles reset." + : $"{posture} posture saved; custom audience profiles preserved."; + Mode.Value = posture == DeploymentPosture.Personal + ? SecurityAccessEditorMode.Menu + : SecurityAccessEditorMode.Features; + LoadEnabledFeatures(); + RequestRedraw(); + } + + private void ToggleToolGroup(AudienceProfileRowKind kind, IReadOnlyList tools) + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var enabled = ToolGroupEnabled(profile, tools); + EnsureAllowlist(profile); + if (enabled) + profile.AllowedTools.RemoveAll(tool => tools.Contains(tool, StringComparer.Ordinal)); + else + AddTools(profile.AllowedTools, tools); + + if (!SaveAudienceProfile(profile)) + return; + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} {AudienceRows.Single(row => row.Kind == kind).Label} {(enabled ? "disabled" : "enabled")}. Saved."; + RequestRedraw(); + } + + private void CycleFileAccess(int direction) + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var next = CycleValue(CurrentFilesystemLevel(profile), FilesystemLevelsFor(SelectedAudience), direction); + + ApplyFilesystemLevel(profile, next); + if (!SaveAudienceProfile(profile)) + return; + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} file access set to {DescribeFilesystem(profile)}. Saved."; + RequestRedraw(); + } + + private void CycleIncomingAttachments(int direction) + { + var profiles = LoadAudienceProfiles(); + var profile = GetProfile(profiles, SelectedAudience); + var next = CycleValue(CurrentAttachmentLevel(profile.ChannelAttachments), AttachmentLevels, direction); + + profile.ChannelAttachments = BuildAttachmentPolicy(next); + if (!SaveAudienceProfile(profile)) + return; + StatusMessage.Value = $"{AudienceLabel(SelectedAudience)} attachments set to {DescribeAttachments(profile.ChannelAttachments)}. Saved."; + RequestRedraw(); + } + + private bool SaveAudienceProfile(ToolAudienceProfile profile) + => TryApplyAndSave( + new SectionContribution( + [ + new SectionFieldAction($"Tools.AudienceProfiles.{AudienceConfigName(SelectedAudience)}", SectionFieldActionKind.Set, profile) + ]), + "audience profile"); + + // All ConfigEditorSession writes in this view-model funnel through here so a disk-full / + // permission-denied / atomic-rename / malformed-config failure surfaces to the operator instead + // of escalating as an unhandled exception that tears down the Termina event loop. Callers MUST + // NOT advance their "Saved." status (or commit in-memory state) when this returns false. + private bool TryApplyAndSave(SectionContribution contribution, string failureContext) + { + try + { + var session = new ConfigEditorSession(_paths); + session.Apply(contribution); + session.Save(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + StatusMessage.Value = $"Failed to save {failureContext}: {ex.Message}"; + RequestRedraw(); + return false; + } + } + + private ToolAudienceProfile GetSelectedProfile() + => GetProfile(LoadAudienceProfiles(), SelectedAudience); + + private ToolAudienceProfiles LoadAudienceProfiles() => LoadAudienceProfiles(out _); + + // Reads stored audience profiles, falling back to the posture baseline when the stored JSON is + // malformed (e.g. a migration changed the shape) so a corrupt Tools.AudienceProfiles cannot throw + // into the render path or the per-keystroke mutation handlers. `malformed` is true on a fallback. + private ToolAudienceProfiles LoadAudienceProfiles(out bool malformed) + { + malformed = false; + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) + return BuildPostureProfiles(ReadPosture(config)); + + try + { + return ConvertConfigObject(value, "Tools.AudienceProfiles"); + } + catch (InvalidOperationException) + { + malformed = true; + return BuildPostureProfiles(ReadPosture(config)); + } + } + + private bool AudienceProfilesCustomized() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) + return false; + + ToolAudienceProfiles existing; + try + { + existing = ConvertConfigObject(value, "Tools.AudienceProfiles"); + } + catch (InvalidOperationException) + { + // Unreadable stored profiles: treat as uncustomised rather than throwing on render. + return false; + } + + var defaults = BuildPostureProfiles(ReadPosture(config)); + return !JsonEquivalent(existing, defaults); + } + + private void LoadEnabledFeatures() + { + Array.Fill(_enabledFeatures, true); + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + for (var i = 0; i < FeatureConfigPaths.Length; i++) + { + if (ConfigFileHelper.TryGetPathValue(config, FeatureConfigPaths[i], out var value) && value is bool enabled) + _enabledFeatures[i] = enabled; + } + } + + private SectionContribution BuildFeatureContribution() + => new(FeatureConfigPaths + .Select((path, index) => new SectionFieldAction(path, SectionFieldActionKind.Set, _enabledFeatures[index])) + .ToArray()); + + private IReadOnlyList BuildItems() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + TryReadPosture(config, out var posture, out var invalidPosture); + return + [ + new("Security Posture", + invalidPosture is null ? posture.ToString() : $"Unknown ('{invalidPosture}') — using Public", + "Deployment trust stance."), + new("Enabled Features", ReadEnabledFeaturesSummary(config), "Deployment-wide runtime feature gates."), + new("Audience Profiles", ReadAudienceProfilesSummary(config), "Curated per-audience access rules."), + new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode") + ]; + } + + private static string ReadEnabledFeaturesSummary(Dictionary config) + { + var enabled = 0; + foreach (var path in FeatureConfigPaths) + { + var flag = true; + if (ConfigFileHelper.TryGetPathValue(config, path, out var value) && value is bool configuredFlag) + flag = configuredFlag; + + if (flag) + enabled++; + } + + return $"{enabled}/{FeatureConfigPaths.Length} enabled"; + } + + private static string ReadAudienceProfilesSummary(Dictionary config) + { + if (!ConfigFileHelper.TryGetPathValue(config, "Tools.AudienceProfiles", out var value) || value is null) + return "No overrides"; + + ToolAudienceProfiles existing; + try + { + existing = ConvertConfigObject(value, "Tools.AudienceProfiles"); + } + catch (InvalidOperationException) + { + // Malformed stored profiles (e.g. a migration changed the shape) must not crash the render. + return "Unreadable — re-save to repair"; + } + + var defaults = BuildPostureProfiles(ReadPosture(config)); + return JsonEquivalent(existing, defaults) ? "No overrides" : "Customized"; + } + + private bool AudienceHasOverrides(TrustAudience audience) + { + var profiles = LoadAudienceProfiles(); + var current = GetProfile(profiles, audience); + var defaults = GetProfile(BuildPostureProfiles(CurrentPosture), audience); + return !JsonEquivalent(current, defaults); + } + + // Posture reads route through the shared DeploymentPostureReader (fail-closed-to-Public on a + // present-but-unparseable value) so the Security and Channels editors treat the same stored value + // identically — see that type for the fail-closed rationale. + private static bool TryReadPosture(Dictionary config, out DeploymentPosture posture, out string? invalidValue) + => DeploymentPostureReader.TryRead(config, out posture, out invalidValue); + + private static DeploymentPosture ReadPosture(Dictionary config) + { + TryReadPosture(config, out var posture, out _); + return posture; + } + + private static string ReadExposureModeSummary(Dictionary config) + { + if (!ConfigFileHelper.TryGetPathValue(config, "Daemon.ExposureMode", out var value)) + return "Local"; + + ExposureMode mode; + try + { + mode = DaemonConfig.ParseExposureMode(value?.ToString()); + } + catch (InvalidOperationException) + { + // ParseExposureMode throws on an unrecognized string. The Items property is read on every + // render frame, so a hand-edited/migrated ExposureMode must degrade to the raw value here + // rather than crashing the Security & Access page permanently. + return $"Unknown ('{value}')"; + } + + return mode switch + { + ExposureMode.Local => "Local", + ExposureMode.ReverseProxy => "Reverse Proxy", + ExposureMode.TailscaleServe => "Tailscale Serve", + ExposureMode.TailscaleFunnel => "Tailscale Funnel", + ExposureMode.CloudflareTunnel => "Cloudflare Tunnel", + _ => mode.ToString() + }; + } + + private static ToolAudienceProfiles BuildPostureProfiles(DeploymentPosture posture) + { + var profiles = ToolAudienceProfileDefaults.CreateProfiles(); + if (posture == DeploymentPosture.Personal) + { + profiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(StringComparer.Ordinal) + { + [ToolAudienceProfileToolCatalog.ShellExecute] = ToolApprovalMode.Approval + } + }; + } + + return profiles; + } + + private static ToolAudienceProfile GetProfile(ToolAudienceProfiles profiles, TrustAudience audience) + => audience switch + { + TrustAudience.Personal => profiles.Personal, + TrustAudience.Team => profiles.Team, + TrustAudience.Public => profiles.Public, + _ => profiles.Public + }; + + private static string AudienceLabel(TrustAudience audience) + => audience switch + { + TrustAudience.Personal => "Personal", + TrustAudience.Team => "Team", + TrustAudience.Public => "Public", + _ => audience.ToString() + }; + + private static string AudienceConfigName(TrustAudience audience) => AudienceLabel(audience); + + private static bool ToolGroupEnabled(ToolAudienceProfile profile, IReadOnlyList tools) + => profile.ToolsMode == ToolProfileMode.All + || tools.All(tool => profile.AllowedTools.Contains(tool, StringComparer.Ordinal)); + + private static void EnsureAllowlist(ToolAudienceProfile profile) + { + if (profile.ToolsMode == ToolProfileMode.Allowlist) + return; + + profile.ToolsMode = ToolProfileMode.Allowlist; + profile.AllowedTools = [.. ToolAudienceProfileToolCatalog.ProfileManagedTools]; + } + + private static void AddTools(List target, IReadOnlyList tools) + { + foreach (var tool in tools) + { + if (!target.Contains(tool, StringComparer.Ordinal)) + target.Add(tool); + } + } + + private static FilesystemLevel CurrentFilesystemLevel(ToolAudienceProfile profile) + { + var modes = new[] { profile.ReadFiles.Mode, profile.WriteFiles.Mode, profile.AttachFiles.Mode }; + if (modes.All(static mode => mode == ToolFilesystemMode.All)) + return FilesystemLevel.AllFiles; + if (modes.All(static mode => mode == ToolFilesystemMode.None)) + return FilesystemLevel.Off; + return FilesystemLevel.SessionOnly; + } + + private static void ApplyFilesystemLevel(ToolAudienceProfile profile, FilesystemLevel level) + { + profile.ReadFiles = BuildFilesystemAccess(level); + profile.WriteFiles = BuildFilesystemAccess(level); + profile.AttachFiles = BuildFilesystemAccess(level); + } + + private static ToolFilesystemAccessProfile BuildFilesystemAccess(FilesystemLevel level) + => level switch + { + FilesystemLevel.Off => new ToolFilesystemAccessProfile { Mode = ToolFilesystemMode.None, Roots = [] }, + FilesystemLevel.AllFiles => new ToolFilesystemAccessProfile { Mode = ToolFilesystemMode.All, Roots = [] }, + _ => ToolAudienceProfileDefaults.CreateSessionScopedFilesystemAccess() + }; + + private static string DescribeFilesystem(ToolAudienceProfile profile) + => CurrentFilesystemLevel(profile) switch + { + FilesystemLevel.Off => "Off", + FilesystemLevel.AllFiles => "All files", + _ => "Session only" + }; + + private static string DescribeFilesystemHelp(ToolAudienceProfile profile) + => CurrentFilesystemLevel(profile) switch + { + FilesystemLevel.Off => "Off: file tools stay granted, but no filesystem paths are available.", + FilesystemLevel.AllFiles => "All files: unrestricted filesystem scope; intended only for Personal audiences.", + _ => "Session only: file tools stay inside the current session workspace." + }; + + private static AttachmentLevel CurrentAttachmentLevel(ChannelAttachmentPolicy? policy) + { + if (policy is null || policy.AllowedCategories.Count == 0) + return AttachmentLevel.None; + + var categories = policy.AllowedCategories; + if (Enum.GetValues().All(category => categories.Contains(category))) + return AttachmentLevel.All; + if (categories.Count == 1 && categories.Contains(AttachmentCategory.Image)) + return AttachmentLevel.Images; + return AttachmentLevel.CommonWorkFiles; + } + + private static ChannelAttachmentPolicy BuildAttachmentPolicy(AttachmentLevel level) + => level switch + { + AttachmentLevel.None => ChannelAttachmentPolicy.Empty, + AttachmentLevel.Images => ToolAudienceProfileDefaults.CreatePublicChannelAttachments(), + AttachmentLevel.All => ToolAudienceProfileDefaults.CreatePersonalChannelAttachments(), + _ => ToolAudienceProfileDefaults.CreateTeamChannelAttachments() + }; + + private static string DescribeAttachments(ChannelAttachmentPolicy? policy) + => CurrentAttachmentLevel(policy) switch + { + AttachmentLevel.None => "None", + AttachmentLevel.Images => "Images", + AttachmentLevel.All => "All attachments", + _ => "Common work files" + }; + + private static string DescribeAttachmentHelp(ChannelAttachmentPolicy? policy) + => CurrentAttachmentLevel(policy) switch + { + AttachmentLevel.None => "None: inbound channel attachments are rejected.", + AttachmentLevel.Images => "Images: allows image uploads only.", + AttachmentLevel.All => "All attachments: images, PDFs, documents, archives, media, and unknown file types.", + _ => "Common work files: images, PDFs, documents, archives, and media; excludes unknown file types." + }; + + private static readonly FilesystemLevel[] PersonalFilesystemLevels = + [ + FilesystemLevel.Off, + FilesystemLevel.SessionOnly, + FilesystemLevel.AllFiles + ]; + + private static readonly FilesystemLevel[] RestrictedFilesystemLevels = + [ + FilesystemLevel.Off, + FilesystemLevel.SessionOnly + ]; + + private static readonly AttachmentLevel[] AttachmentLevels = + [ + AttachmentLevel.None, + AttachmentLevel.Images, + AttachmentLevel.CommonWorkFiles, + AttachmentLevel.All + ]; + + private static IReadOnlyList FilesystemLevelsFor(TrustAudience audience) + => audience == TrustAudience.Personal ? PersonalFilesystemLevels : RestrictedFilesystemLevels; + + private static T CycleValue(T current, IReadOnlyList values, int direction) + { + if (values.Count == 0) + return current; + + var index = -1; + for (var i = 0; i < values.Count; i++) + { + if (EqualityComparer.Default.Equals(values[i], current)) + { + index = i; + break; + } + } + + if (index < 0) + index = 0; + + var next = (index + Math.Sign(direction) + values.Count) % values.Count; + return values[next]; + } + + private static bool JsonEquivalent(T left, T right) + => JsonSerializer.Serialize(left, JsonDefaults.ConfigFile) == JsonSerializer.Serialize(right, JsonDefaults.ConfigFile); + + private static T ConvertConfigObject(object value, string path) + { + try + { + return ConfigFileHelper.DeserializeSection(value) + ?? throw new InvalidOperationException($"{path} was empty."); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + throw new InvalidOperationException($"Unable to read {path} from config.", ex); + } + } + + private static void Move(ReactiveProperty index, int delta, int count) + { + if (count == 0) + return; + + var next = Math.Clamp(index.Value + delta, 0, count - 1); + if (next != index.Value) + index.Value = next; + } + + private enum FilesystemLevel + { + Off, + SessionOnly, + AllFiles + } + + private enum AttachmentLevel + { + None, + Images, + CommonWorkFiles, + All + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigPage.cs b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigPage.cs new file mode 100644 index 000000000..5417b2bb3 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigPage.cs @@ -0,0 +1,727 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class SkillSourcesConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private SelectionListNode? _validationDialogList; + private readonly CompositeDisposable _contentSubscriptions = []; + private TextInputNode? _textInput; + private SkillSourcesScreen? _textInputScreen; + // Created once on entering AddLocalPath and reused across renders so the picker keeps its + // navigation state; rebuilding it every render would reset it to the start path. + private FilePickerNode? _directoryPicker; + private readonly CompositeDisposable _pickerSubscriptions = []; + // Inline "new folder" naming overlay — the picker itself cannot create directories. + private bool _namingNewFolder; + private string _newFolderParent = string.Empty; + private TextInputNode? _newFolderInput; + + protected override void OnBound() + { + base.OnBound(); + _pickerSubscriptions.DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.Screen.Subscribe(screen => + { + // Drop the active text input whenever we leave the screen that owns it so the + // next text screen re-seeds from the view model draft. + if (_textInputScreen is { } owner && owner != screen) + ResetTextInput(); + + SyncDirectoryPicker(screen); + _contentNode?.Invalidate(); + }).DisposeWith(Subscriptions); + ViewModel.SelectedRow.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.Draft.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.Version.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.ActiveValidationDialog.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame(ViewModel.CurrentTitle, BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + _contentSubscriptions.Clear(); + _validationDialogList = null; + + if (ViewModel.ActiveValidationDialog.Value is { } dialog) + return BuildValidationDialog(dialog); + + return ViewModel.Screen.Value switch + { + SkillSourcesScreen.Inventory => BuildInventory(), + SkillSourcesScreen.SourceDetail => BuildSourceDetail(), + SkillSourcesScreen.AddLocalPath => BuildDirectoryPicker(), + SkillSourcesScreen.AddLocalSymlinks => BuildChoice( + "Allow symlinks inside this folder?", + "Symlinks can make a source scan files outside the folder.", + ["No - stricter security", "Yes - this folder intentionally uses symlinks"]), + SkillSourcesScreen.AddLocalName => BuildTextDraft( + "Review local folder source.", + "Source name", + "Enter adds the source and autosaves."), + SkillSourcesScreen.AddRemoteUrl => BuildTextDraft( + "Add a remote skill server.", + "Server URL", + "Netclaw probes /.well-known/agent-skills/index.json before save.", + "What is a skill server?", + [ + "A skill server is a Netclaw skill-server instance that publishes", + "agent skills over HTTP for a team or organization.", + "Project: https://github.com/netclaw-dev/skill-server" + ]), + SkillSourcesScreen.AddRemoteToken => BuildTextDraft( + "Enter the bearer token for this skill server.", + "Bearer token", + "Blank tokens are not saved. Existing tokens are removed only through Remove token.", + isPassword: true), + SkillSourcesScreen.AddRemoteName => BuildTextDraft( + "Review remote skill server source.", + "Source name", + "Enter adds the source and autosaves."), + SkillSourcesScreen.RenameSource => BuildTextDraft( + "Rename this skill source.", + "Source name", + "Enter validates and autosaves the new name."), + SkillSourcesScreen.ChangeLocation => BuildTextDraft( + "Change this source location.", + "Location", + "Enter validates and autosaves the new path or URL."), + SkillSourcesScreen.RemoveConfirm => BuildChoice( + "Remove this skill source from Netclaw config?", + "This does not delete remote skills or local files.", + ["Cancel", "Remove source"]), + _ => Layouts.Empty(), + }; + }); + + return _contentNode; + } + + private ILayoutNode BuildValidationDialog(NetclawValidationDialogModel dialog) + { + _validationDialogList = NetclawValidationDialogViews.BuildActionList(); + _validationDialogList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count > 0) + HandleValidationDialogAction(NetclawValidationDialogViews.ParseAction(selected[0])); + }) + .DisposeWith(_contentSubscriptions); + + return NetclawValidationDialogViews.BuildWarningPanel(dialog, _validationDialogList); + } + + private ILayoutNode BuildInventory() + { + var layout = Layouts.Vertical() + .WithChild(Header(" Skill Sources")) + .WithChild(Hint(" Places Netclaw loads skills from. Skill enablement stays in Security & Access.")) + .WithChild(Layouts.Empty().Height(1)); + + var sources = ViewModel.Sources; + var hasLocal = sources.Any(static source => source.Kind == SkillSourceKind.LocalFolder); + var hasRemote = sources.Any(static source => source.Kind == SkillSourceKind.RemoteSkillServer); + + if (sources.Count == 0) + { + layout = layout.WithChild(Hint(" No skill sources configured yet.")); + } + else + { + if (hasLocal) + { + layout = layout.WithChild(Text(" Local folders", Color.White)); + foreach (var row in ViewModel.InventoryRows.Where(static row => row.SourceKind == SkillSourceKind.LocalFolder)) + layout = layout.WithChild(InventoryRow(row)); + layout = layout.WithChild(Layouts.Empty().Height(1)); + } + + if (hasRemote) + { + layout = layout.WithChild(Text(" Remote skill servers", Color.White)); + foreach (var row in ViewModel.InventoryRows.Where(static row => row.SourceKind == SkillSourceKind.RemoteSkillServer)) + layout = layout.WithChild(InventoryRow(row)); + layout = layout.WithChild(Layouts.Empty().Height(1)); + } + } + + foreach (var row in ViewModel.InventoryRows.Where(static row => row.SourceKind is null)) + layout = layout.WithChild(InventoryRow(row)); + + return layout; + } + + private ILayoutNode BuildSourceDetail() + { + var source = ViewModel.SelectedSource; + if (source is null) + return Layouts.Vertical() + .WithChild(Header(" Skill Source")) + .WithChild(Hint(" Source no longer exists. Press Esc to return to Skill Sources.")); + + var type = source.Kind == SkillSourceKind.LocalFolder ? "Local folder" : "Remote skill server"; + var layout = Layouts.Vertical() + .WithChild(Header($" {source.Name}")) + .WithChild(Text($" Type: {type}", Color.White)) + .WithChild(Text($" Status: {source.StatusText}", ToColor(source.StatusTone))) + .WithChild(Layouts.Empty().Height(1)); + + foreach (var row in ViewModel.DetailRows) + layout = layout.WithChild(DetailRow(row)); + + return layout; + } + + private ILayoutNode BuildTextDraft( + string title, + string fieldLabel, + string hint, + string? calloutTitle = null, + IReadOnlyList? calloutLines = null, + bool isPassword = false) + { + var input = EnsureTextInput(isPassword); + input.OnFocused(); + + var layout = Layouts.Vertical() + .WithChild(Header($" {title}")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(NetclawTuiChrome.BuildTextInputPanel(input, fieldLabel)) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" {hint}")); + + if (calloutTitle is not null && calloutLines is { Count: > 0 }) + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(BuildCallout(calloutTitle, calloutLines)); + + return layout; + } + + private static ILayoutNode BuildCallout(string title, IReadOnlyList lines) + { + var content = Layouts.Vertical(); + foreach (var line in lines) + content = content.WithChild(Text($" {line}", Color.Yellow)); + + return NetclawTuiChrome.BuildPanel(title, content, Color.Yellow); + } + + private ILayoutNode BuildChoice(string title, string hint, IReadOnlyList choices) + { + var layout = Layouts.Vertical() + .WithChild(Header($" {title}")) + .WithChild(Hint($" {hint}")) + .WithChild(Layouts.Empty().Height(1)); + + for (var i = 0; i < choices.Count; i++) + { + var focused = i == ViewModel.SelectedRow.Value; + layout = layout.WithChild(ConfigSelectionRow.Create($" {choices[i]}", focused)); + } + + return layout; + } + + private ILayoutNode InventoryRow(SkillSourcesInventoryRow row) + { + var rows = ViewModel.InventoryRows; + var index = IndexOf(rows, row); + var focused = index == ViewModel.SelectedRow.Value; + if (row.SourceKind is not null) + { + // Selected highlight covers the primary label line; the indented + // detail line keeps its tone color (warning vs. neutral). + var detailColor = row.Tone == ConfigStatusTone.Warning ? Color.Yellow : Color.Gray; + return Layouts.Vertical() + .WithChild(ConfigSelectionRow.Create($" {row.Label}", focused)) + .WithChild(Text($" {row.Detail}", detailColor)); + } + + return ConfigSelectionRow.Create($" {row.Label,-28} {row.Detail}", focused); + } + + private ILayoutNode DetailRow(SkillSourceDetailRow row) + { + var rows = ViewModel.DetailRows; + var index = IndexOf(rows, row); + var focused = index == ViewModel.SelectedRow.Value; + return ConfigSelectionRow.Create($" {row.Label,-44} {row.Detail}", focused, ToColor(row.Tone)); + } + + private static int IndexOf(IReadOnlyList rows, T row) + { + for (var i = 0; i < rows.Count; i++) + { + if (EqualityComparer.Default.Equals(rows[i], row)) + return i; + } + + return -1; + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => Observable.CombineLatest( + ViewModel.Screen, + ViewModel.ActiveValidationDialog, + (screen, dialog) => (ILayoutNode)NetclawTuiChrome.BuildKeyHintLine( + dialog is not null + ? " [↑/↓] Navigate [Enter] Select [Esc] Back to edit [Ctrl+Q] Quit" + : KeyHints(screen))) + .AsLayout() + .Height(1); + + private static string KeyHints(SkillSourcesScreen screen) + => screen switch + { + SkillSourcesScreen.Inventory => " [↑/↓] Navigate [Enter] Open/Add [Space] Toggle [Delete] Remove [Esc] Settings Areas [Ctrl+Q] Quit", + SkillSourcesScreen.SourceDetail => " [↑/↓] Navigate [Enter/Space] Activate [Delete] Remove [Esc] Skill Sources [Ctrl+Q] Quit", + // The directory picker renders its own key-hint footer; keep this strip empty so the + // two do not stack. App-specific keys (Ctrl+N / Ctrl+Q) are shown above the picker. + SkillSourcesScreen.AddLocalPath => string.Empty, + SkillSourcesScreen.AddLocalSymlinks or SkillSourcesScreen.RemoveConfirm => + " [↑/↓] Navigate [Enter] Select [Esc] Back [Ctrl+Q] Quit", + _ => " [Type/Paste] Edit [Backspace] Delete [Enter] Apply [Esc] Back [Ctrl+Q] Quit", + }; + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + // On the AddLocalPath screen the directory picker (or its inline new-folder prompt) owns + // every key; its events advance or back out of the flow. + if (ViewModel.Screen.Value == SkillSourcesScreen.AddLocalPath + && ViewModel.ActiveValidationDialog.Value is null) + { + if (_namingNewFolder) + { + HandleNewFolderKey(keyInfo); + return; + } + + if (keyInfo.Key == ConsoleKey.N && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + BeginNewFolder(); + return; + } + + if (_directoryPicker is not null) + { + _directoryPicker.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + return; + } + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + if (ViewModel.ActiveValidationDialog.Value is not null) + { + ViewModel.ReturnToValidationEdit(); + return; + } + + ViewModel.GoBack(); + return; + } + + if (ViewModel.ActiveValidationDialog.Value is not null) + { + _validationDialogList?.HandleInput(keyInfo); + return; + } + + if (TryHandleTextInput(keyInfo)) + return; + + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + return; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + return; + case ConsoleKey.Enter: + if (TryCommitCurrentAction(ConsoleKey.Enter)) + return; + + ViewModel.ActivateSelected(); + return; + case ConsoleKey.Spacebar: + if (TryCommitCurrentAction(ConsoleKey.Spacebar)) + return; + + ViewModel.ToggleSelected(); + return; + case ConsoleKey.Delete: + ViewModel.DeleteSelected(); + return; + case ConsoleKey.Backspace: + return; + } + } + + private void HandlePaste(PasteEvent paste) + { + if (_namingNewFolder && _newFolderInput is not null) + { + _newFolderInput.HandlePaste(paste); + _contentNode?.Invalidate(); + return; + } + + if (!ViewModel.IsTextEntryActive || _textInput is null) + return; + + _textInput.HandlePaste(paste); + ViewModel.ReplaceDraft(_textInput.Text); + ViewModel.RequestRedraw(); + } + + private ILayoutNode BuildDirectoryPicker() + { + if (_namingNewFolder && _newFolderInput is not null) + { + return Layouts.Vertical() + .WithChild(Text(" New folder", Color.White)) + .WithChild(Text($" Created inside: {_newFolderParent}", Color.Gray)) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(NetclawTuiChrome.BuildTextInputPanel(_newFolderInput, "Folder name")); + } + + if (_directoryPicker is null) + return Layouts.Empty(); + + // The picker draws its own (unsuppressable) key-hint footer; keep only the app-specific + // keys up here so there is a single strip, not two competing ones. + return Layouts.Vertical() + .WithChild(Text(" Add a local skill folder.", Color.White)) + .WithChild(Text(" [Ctrl+N] new folder [Ctrl+Q] quit", Color.Gray)) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(_directoryPicker); + } + + // Creates the directory picker exactly once on entering AddLocalPath and tears it down on + // leaving, so the picker survives the per-render rebuilds without losing navigation state. + private void SyncDirectoryPicker(SkillSourcesScreen screen) + { + if (screen == SkillSourcesScreen.AddLocalPath) + { + if (_directoryPicker is not null) + return; + + _pickerSubscriptions.Clear(); + _directoryPicker = DirectoryPickerFactory.Build( + ViewModel.BrowseStartPath, + ViewModel.FileSystemProvider, + _pickerSubscriptions, + ViewModel.CommitAddLocalPath, + ViewModel.GoBack); + } + else if (_directoryPicker is not null) + { + _pickerSubscriptions.Clear(); + _directoryPicker = null; + _namingNewFolder = false; + _newFolderInput = null; + } + } + + private void BeginNewFolder() + { + _newFolderParent = _directoryPicker?.CurrentPath ?? ViewModel.BrowseStartPath; + _newFolderInput = new TextInputNode().WithPlaceholder("my-skills"); + _newFolderInput.OnFocused(); + _namingNewFolder = true; + _contentNode?.Invalidate(); + } + + private void HandleNewFolderKey(ConsoleKeyInfo keyInfo) + { + if (_newFolderInput is null) + return; + + switch (keyInfo.Key) + { + case ConsoleKey.Escape: + _namingNewFolder = false; + _newFolderInput = null; + _contentNode?.Invalidate(); + return; + case ConsoleKey.Enter: + // Success creates the folder and advances the flow (which disposes the picker via + // SyncDirectoryPicker); failure surfaces a status error and keeps the picker. + _namingNewFolder = false; + var name = _newFolderInput.Text; + _newFolderInput = null; + ViewModel.CreateAndSelectFolder(_newFolderParent, name); + _contentNode?.Invalidate(); + return; + default: + _newFolderInput.HandleInput(keyInfo); + _contentNode?.Invalidate(); + return; + } + } + + private bool TryHandleTextInput(ConsoleKeyInfo keyInfo) + { + if (!ViewModel.IsTextEntryActive) + return false; + + if (keyInfo.Key == ConsoleKey.Enter) + { + CommitCurrentTextScreen(); + return true; + } + + var input = EnsureTextInputForCurrentScreen(); + input.HandleInput(keyInfo); + ViewModel.ReplaceDraft(input.Text); + ViewModel.RequestRedraw(); + return true; + } + + private void CommitCurrentTextScreen() + { + // Bracketed paste is auto-routed to the focused input by Termina, which bypasses + // the per-keystroke draft sync. Stage the live input text before committing so a + // paste immediately followed by Enter commits the full value, not a stale draft. + // Only re-stage when the text actually differs: ReplaceDraft marks the draft dirty + // (which clears the save-anyway fingerprint), so an unchanged re-stage on a repeated + // Enter would defeat "press Enter again to save anyway" for an unreachable feed. + if (_textInput is not null && _textInputScreen == ViewModel.Screen.Value + && _textInput.Text != ViewModel.Draft.Value) + ViewModel.ReplaceDraft(_textInput.Text); + + var draft = ViewModel.Draft.Value; + switch (ViewModel.Screen.Value) + { + case SkillSourcesScreen.AddLocalPath: + ViewModel.CommitAddLocalPath(draft); + break; + case SkillSourcesScreen.AddLocalName: + ViewModel.CommitAddLocalName(draft); + break; + case SkillSourcesScreen.AddRemoteUrl: + ViewModel.CommitAddRemoteUrl(draft); + break; + case SkillSourcesScreen.AddRemoteToken: + ViewModel.CommitAddRemoteToken(draft); + break; + case SkillSourcesScreen.AddRemoteName: + ViewModel.CommitAddRemoteName(draft); + break; + case SkillSourcesScreen.RenameSource: + ViewModel.CommitRenameSource(draft); + break; + case SkillSourcesScreen.ChangeLocation: + ViewModel.CommitChangeLocation(draft); + break; + } + } + + private bool TryCommitCurrentAction(ConsoleKey key) + { + // Choice/picker screens commit on Enter through the view model's structural-then-probe + // commit methods so a failing probe raises the override dialog (the former picker path). + if (key == ConsoleKey.Enter) + { + switch (ViewModel.Screen.Value) + { + case SkillSourcesScreen.AddLocalSymlinks: + ViewModel.CommitAddLocalSymlinks(ViewModel.SelectedRow.Value == 1); + return true; + } + } + + if (ViewModel.Screen.Value == SkillSourcesScreen.Inventory && key == ConsoleKey.Spacebar) + { + var row = ViewModel.CurrentInventoryRow; + if (row?.Action == SkillSourcesInventoryAction.OpenSource) + { + ViewModel.CommitToggleEnabledAction(); + return true; + } + } + + if (ViewModel.Screen.Value == SkillSourcesScreen.SourceDetail) + { + var row = ViewModel.CurrentDetailRow; + if (row is null) + return false; + + switch (row.Action) + { + case SkillSourceDetailAction.ToggleEnabled when key is ConsoleKey.Enter or ConsoleKey.Spacebar: + ViewModel.CommitToggleEnabledAction(); + return true; + case SkillSourceDetailAction.ToggleSymlinks when key is ConsoleKey.Enter or ConsoleKey.Spacebar: + ViewModel.CommitToggleLocalSymlinksAction(); + return true; + case SkillSourceDetailAction.SyncInterval when key == ConsoleKey.Enter: + ViewModel.CommitCycleRemoteSyncIntervalAction(); + return true; + case SkillSourceDetailAction.RemoveToken when key == ConsoleKey.Enter: + ViewModel.CommitRemoveRemoteTokenAction(); + return true; + default: + return false; + } + } + + if (ViewModel.Screen.Value == SkillSourcesScreen.RemoveConfirm && key == ConsoleKey.Enter && ViewModel.SelectedRow.Value == 1) + { + ViewModel.CommitRemoveSourceAction(); + return true; + } + + return false; + } + + private void HandleValidationDialogAction(NetclawValidationDialogAction action) + { + switch (action) + { + case NetclawValidationDialogAction.RetryValidation: + ViewModel.DismissValidationDialog(); + RetryCurrentCommit(); + break; + case NetclawValidationDialogAction.BackToEdit: + ViewModel.ReturnToValidationEdit(); + break; + case NetclawValidationDialogAction.SaveAnyway: + ViewModel.SaveCurrentDraftAnyway(); + break; + } + } + + private void RetryCurrentCommit() + { + // Re-run the same commit that raised the override dialog so the probe fires again. + // The dialog is only raised over text screens (token / location), so retrying the + // current text-screen commit re-fires the reachability probe. + CommitCurrentTextScreen(); + } + + private TextInputNode EnsureTextInputForCurrentScreen() + => EnsureTextInput(ViewModel.Screen.Value == SkillSourcesScreen.AddRemoteToken); + + private TextInputNode EnsureTextInput(bool isPassword) + { + var screen = ViewModel.Screen.Value; + if (_textInput is not null && _textInputScreen == screen) + return _textInput; + + var input = new TextInputNode().WithPlaceholder(isPassword ? "(empty)" : "Type here..."); + if (isPassword) + input.AsPassword(); + + input.Text = ViewModel.Draft.Value; + if (!string.IsNullOrEmpty(input.Text)) + input.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + + _textInput = input; + _textInputScreen = screen; + return _textInput; + } + + private void ResetTextInput() + { + _textInput = null; + _textInputScreen = null; + } + + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.Gray); + private static TextNode Text(string text, Color color) => new TextNode(text).WithForeground(color); + + private static Color ToColor(ConfigStatusTone tone) + => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.Gray, + }; +} + +/// +/// Builds the single-selection directory shared by the Skill Sources +/// and Workspaces config pages: identical mode/selection/fill/provider/focus wiring, with each page +/// supplying the start path and the confirm/cancel callbacks. Centralizing it keeps the two pickers +/// behaviorally identical — a change to picker configuration lands in both at once. +/// +internal static class DirectoryPickerFactory +{ + internal static FilePickerNode Build( + string startPath, + IFileSystemProvider fileSystemProvider, + CompositeDisposable subscriptions, + Action onConfirm, + Action onCancel) + { + var picker = Layouts.FilePicker(startPath) + .WithMode(FilePickerMode.Directories) + .WithSelectionMode(FilePickerSelectionMode.Single) + .WithFillHeight(true) + .WithFileSystemProvider(fileSystemProvider); + picker.OnFocused(); + picker.SelectionConfirmed + .Subscribe(paths => + { + if (paths.Count > 0) + onConfirm(paths[0]); + }) + .DisposeWith(subscriptions); + picker.Cancelled + .Subscribe(_ => onCancel()) + .DisposeWith(subscriptions); + return picker; + } +} diff --git a/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs new file mode 100644 index 000000000..8c8e3a619 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/SkillSourcesConfigViewModel.cs @@ -0,0 +1,2425 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json; +using Netclaw.Actors.Skills; +using Netclaw.Cli.Config; +using Netclaw.Cli.Json; +using Netclaw.Configuration; +using Netclaw.Configuration.Secrets; +using R3; +using Termina.Layout; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed record SkillFeedReachabilityResult(bool Success, string Message, bool RequiresAuth = false); + +internal interface ISkillFeedReachabilityProbe +{ + Task ProbeAsync(string baseUrl, string? apiKey, int timeoutSeconds, CancellationToken ct = default); +} + +internal sealed class SkillFeedReachabilityProbe : ISkillFeedReachabilityProbe +{ + public async Task ProbeAsync( + string baseUrl, + string? apiKey, + int timeoutSeconds, + CancellationToken ct = default) + { + try + { + var timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 10)); + // Link the caller's token to the per-probe timeout so a superseded/abandoned probe + // (caller cancels via ct) and a slow server (timeout) both unwind the same way. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + using var client = new HttpClient { Timeout = timeout }; + var root = baseUrl.EndsWith("/", StringComparison.Ordinal) ? baseUrl : baseUrl + "/"; + using var request = new HttpRequestMessage( + HttpMethod.Get, + new Uri(new Uri(root), ".well-known/agent-skills/index.json")); + + if (!string.IsNullOrWhiteSpace(apiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + using var response = await client.SendAsync(request, cts.Token); + if (response.IsSuccessStatusCode) + return new SkillFeedReachabilityResult(true, "Skill feed discovery endpoint is reachable."); + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + return new SkillFeedReachabilityResult(false, $"Skill feed authentication failed with HTTP {(int)response.StatusCode}.", RequiresAuth: true); + + return new SkillFeedReachabilityResult(false, $"Skill feed probe returned HTTP {(int)response.StatusCode}."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + // The CALLER cancelled (probe superseded or abandoned). Surface this to RunProbeAsync as + // a cancellation so it drops the result quietly, rather than masquerading as a failure. + throw; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or UriFormatException or InvalidOperationException) + { + // Network/parse/timeout error (NOT caller cancellation): a real, reportable failure. + return new SkillFeedReachabilityResult(false, $"Skill feed probe failed: {ex.Message}"); + } + } +} + +internal enum SkillSourceKind +{ + LocalFolder, + RemoteSkillServer, +} + +internal enum SkillSourcesScreen +{ + Inventory, + SourceDetail, + AddLocalPath, + AddLocalSymlinks, + AddLocalName, + AddRemoteUrl, + AddRemoteToken, + AddRemoteName, + RenameSource, + ChangeLocation, + RemoveConfirm, +} + +internal enum SkillSourcesInventoryAction +{ + OpenSource, + AddLocalFolder, + AddSkillServer, + RescanAll, + Done, +} + +internal enum SkillSourceDetailAction +{ + ToggleEnabled, + Location, + ToggleSymlinks, + Rescan, + Rename, + ChangeLocation, + Authentication, + SyncInterval, + TestConnection, + RotateToken, + RemoveToken, + RemoveSource, + Done, +} + +internal enum SkillSourceAuthMode +{ + None, + BearerToken, +} + +internal sealed record SkillSourceDisplay( + SkillSourceKind Kind, + string Name, + string Location, + bool Enabled, + bool IsWellKnown, + bool AllowSymlinks, + bool HasApiKey, + int TimeoutSeconds, + string StatusText, + ConfigStatusTone StatusTone, + // True when a remote feed's stored token is present but NOT ENC:-encrypted (a hand-edited or + // migrated config). The editor surfaces this as a warning rather than silently using the + // unprotected credential — CLAUDE.md forbids silent fallbacks on security paths. + bool ApiKeyIsPlaintext = false); + +internal sealed record SkillSourcesInventoryRow( + SkillSourcesInventoryAction Action, + SkillSourceKind? SourceKind, + string? SourceName, + string Label, + string Detail, + ConfigStatusTone Tone); + +internal sealed record SkillSourceDetailRow( + SkillSourceDetailAction Action, + string Label, + string Detail, + ConfigStatusTone Tone); + +internal sealed record SkillSourceActionTarget(SkillSourceKind Kind, string Name); + +internal sealed record LocalSkillScanDisplay(int Count, string? Warning); + +/// +/// Lightweight result of a Skill Sources field commit attempt. Mirrors the inline +/// validation pattern used by the Search config editor: structural failures carry an +/// error tone, reachability-probe failures carry a warning tone that the page surfaces +/// as a "save anyway" override dialog. +/// +internal sealed record SkillSourceCommitResult(bool Success, string Message, ConfigStatusTone Tone) +{ + public static SkillSourceCommitResult Ok(string message = "") + => new(true, message, ConfigStatusTone.Success); + + public static SkillSourceCommitResult Failed(string message) + => new(false, message, ConfigStatusTone.Error); + + public static SkillSourceCommitResult Warning(string message) + => new(false, message, ConfigStatusTone.Warning); +} + +internal sealed class SkillSourcesConfigViewModel : ReactiveViewModel +{ + private const int DefaultFeedTimeoutSeconds = 30; + + private readonly NetclawPaths _paths; + private readonly ISkillFeedReachabilityProbe _probe; + private readonly StringComparer _nameComparer = StringComparer.OrdinalIgnoreCase; + private CancellationTokenSource? _probeCts; + private Task? _probeTask; + private SkillFeedReachabilityResult? _pendingRemoteProbeResult; + private string? _lastProbeFingerprint; + private List _sources = []; + private SkillSourceKind? _selectedKind; + private string? _selectedName; + private string? _pendingLocalPath; + private bool _pendingLocalAllowSymlinks; + private string? _pendingRemoteUrl; + private SkillSourceAuthMode _pendingRemoteAuthMode; + private string? _pendingRemoteApiKey; + private string? _pendingRemoteProbeMessage; + private int _pendingRemoteTimeoutSeconds = DefaultFeedTimeoutSeconds; + private SkillSourceDetailAction? _editingAction; + private SkillSourcesScreen? _validationEditScreen; + private string? _validationEditDraft; + + public SkillSourcesConfigViewModel( + NetclawPaths paths, + ISkillFeedReachabilityProbe? probe = null, + IFileSystemProvider? fileSystemProvider = null) + { + _paths = paths; + _probe = probe ?? new SkillFeedReachabilityProbe(); + FileSystemProvider = fileSystemProvider ?? new DefaultFileSystemProvider(); + Screen = new ReactiveProperty(SkillSourcesScreen.Inventory); + SelectedRow = new ReactiveProperty(0); + Draft = new ReactiveProperty(string.Empty); + Version = new ReactiveProperty(0); + Status = new ReactiveProperty(new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral)); + ActiveValidationDialog = new ReactiveProperty(null); + IsSaved = new ReactiveProperty(false); + ReloadSources(); + } + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + /// Filesystem access for the "add local folder" directory picker (fakeable in tests). + public IFileSystemProvider FileSystemProvider { get; } + + /// + /// Directory the "add local folder" picker opens at — the netclaw user's home directory. The + /// picker can navigate up to the filesystem root and back down, so this is only an anchor. + /// + public string BrowseStartPath => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + /// + /// Creates as a new directory under and + /// commits it as the chosen local skill folder. Surfaces a status error on bad input/IO and + /// leaves the picker open. This is the inline "new folder" affordance the picker itself lacks. + /// + public void CreateAndSelectFolder(string parentPath, string name) + { + var trimmed = name.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + SetStatus("Enter a valid folder name (no path separators).", ConfigStatusTone.Error); + return; + } + + string created; + try + { + created = Path.Combine(parentPath, trimmed); + Directory.CreateDirectory(created); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + SetStatus($"Could not create folder: {ex.Message}", ConfigStatusTone.Error); + return; + } + + CommitAddLocalPath(created); + } + + public ReactiveProperty Screen { get; } + public ReactiveProperty SelectedRow { get; } + public ReactiveProperty Draft { get; } + public ReactiveProperty Version { get; } + public ReactiveProperty Status { get; } + public ReactiveProperty ActiveValidationDialog { get; } + public ReactiveProperty IsSaved { get; } + + public IReadOnlyList Sources => _sources; + + public SkillSourceDisplay? SelectedSource => _selectedKind is { } kind && _selectedName is { Length: > 0 } name + ? _sources.FirstOrDefault(s => s.Kind == kind && _nameComparer.Equals(s.Name, name)) + : null; + + public IReadOnlyList InventoryRows => BuildInventoryRows(); + + public IReadOnlyList DetailRows => SelectedSource is { } source + ? BuildDetailRows(source) + : []; + + internal SkillSourcesInventoryRow? CurrentInventoryRow => GetInventoryRowOrNull(); + + internal SkillSourceDetailRow? CurrentDetailRow => GetDetailRowOrNull(); + + public bool IsTextEntryActive => IsTextEntryScreen(Screen.Value); + + public string CurrentTitle => Screen.Value switch + { + SkillSourcesScreen.Inventory => "Skill Sources", + SkillSourcesScreen.SourceDetail when SelectedSource is { } source => $"Skill Sources > {source.Name}", + SkillSourcesScreen.AddLocalPath => "Add Local Skill Folder", + SkillSourcesScreen.AddLocalSymlinks => "Local Folder Security", + SkillSourcesScreen.AddLocalName => "Review Local Folder", + SkillSourcesScreen.AddRemoteUrl => "Add Skill Server", + SkillSourcesScreen.AddRemoteToken => "Skill Server Token", + SkillSourcesScreen.AddRemoteName => "Review Skill Server", + SkillSourcesScreen.RenameSource => "Rename Skill Source", + SkillSourcesScreen.ChangeLocation when SelectedSource?.Kind == SkillSourceKind.RemoteSkillServer => "Change Skill Server URL", + SkillSourcesScreen.ChangeLocation => "Change Local Folder Path", + SkillSourcesScreen.RemoveConfirm => "Remove Skill Source?", + _ => "Skill Sources", + }; + + public void MoveSelection(int delta) + { + var count = RowCountForCurrentScreen(); + if (count == 0) + return; + + var next = Math.Clamp(SelectedRow.Value + delta, 0, count - 1); + if (next != SelectedRow.Value) + SelectedRow.Value = next; + } + + public void AppendText(string text) + { + if (!IsTextEntryScreen(Screen.Value)) + return; + + Draft.Value += text; + MarkDirty(); + } + + internal void ReplaceDraft(string value) + { + if (!IsTextEntryScreen(Screen.Value)) + return; + + Draft.Value = value; + MarkDirty(); + } + + public void Backspace() + { + if (!IsTextEntryScreen(Screen.Value) || Draft.Value.Length == 0) + return; + + Draft.Value = Draft.Value[..^1]; + MarkDirty(); + } + + public void ActivateSelected() + { + switch (Screen.Value) + { + case SkillSourcesScreen.Inventory: + ActivateInventoryRow(); + break; + case SkillSourcesScreen.SourceDetail: + ActivateDetailRow(); + break; + case SkillSourcesScreen.AddLocalPath: + ContinueAddLocalPath(); + break; + case SkillSourcesScreen.AddLocalSymlinks: + ContinueAddLocalSymlinks(); + break; + case SkillSourcesScreen.AddLocalName: + SaveNewLocalSource(); + break; + case SkillSourcesScreen.AddRemoteUrl: + ContinueAddRemoteUrl(); + break; + case SkillSourcesScreen.AddRemoteToken: + ContinueAddRemoteToken(); + break; + case SkillSourcesScreen.AddRemoteName: + SaveNewRemoteSource(); + break; + case SkillSourcesScreen.RenameSource: + SaveRename(); + break; + case SkillSourcesScreen.ChangeLocation: + SaveLocationChange(); + break; + case SkillSourcesScreen.RemoveConfirm: + ActivateRemoveConfirm(); + break; + } + } + + public void ToggleSelected() + { + if (Screen.Value == SkillSourcesScreen.Inventory) + { + var row = GetInventoryRowOrNull(); + if (row?.Action == SkillSourcesInventoryAction.OpenSource && row.SourceKind is { } kind && row.SourceName is { } name) + ToggleEnabled(kind, name); + return; + } + + if (Screen.Value == SkillSourcesScreen.SourceDetail) + { + var row = GetDetailRowOrNull(); + if (row?.Action is SkillSourceDetailAction.ToggleEnabled or SkillSourceDetailAction.ToggleSymlinks) + ActivateDetailRow(); + } + } + + internal SkillSourceActionTarget? ReadCurrentSourceActionTarget() + { + if (Screen.Value == SkillSourcesScreen.Inventory) + { + var row = GetInventoryRowOrNull(); + return row?.Action == SkillSourcesInventoryAction.OpenSource && row.SourceKind is { } kind && row.SourceName is { } name + ? new SkillSourceActionTarget(kind, name) + : null; + } + + if (SelectedSource is { } source) + return new SkillSourceActionTarget(source.Kind, source.Name); + + return _selectedKind is { } selectedKind && _selectedName is { Length: > 0 } selectedName + ? new SkillSourceActionTarget(selectedKind, selectedName) + : null; + } + + internal SkillSourceCommitResult ValidateSourceActionTarget(SkillSourceActionTarget? target) + { + if (target is null) + return SkillSourceCommitResult.Failed("A skill source must be selected before changing it."); + + return _sources.Any(source => source.Kind == target.Kind && _nameComparer.Equals(source.Name, target.Name)) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed($"Skill source '{target.Name}' no longer exists in config."); + } + + internal SkillSourceCommitResult ValidateLocalSourceActionTarget(SkillSourceActionTarget? target) + { + var validation = ValidateSourceActionTarget(target); + if (!validation.Success) + return validation; + + return target!.Kind == SkillSourceKind.LocalFolder + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed("A local skill folder must be selected before changing symlink policy."); + } + + internal SkillSourceCommitResult ValidateRemoteSourceActionTarget(SkillSourceActionTarget? target) + { + var validation = ValidateSourceActionTarget(target); + if (!validation.Success) + return validation; + + return target!.Kind == SkillSourceKind.RemoteSkillServer + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed("A remote skill server must be selected before changing remote settings."); + } + + internal void CommitToggleEnabled(SkillSourceActionTarget? target) + { + if (target is null) + { + SetStatus("A skill source must be selected before changing it.", ConfigStatusTone.Error); + return; + } + + ToggleEnabled(target.Kind, target.Name); + } + + internal void CommitToggleLocalSymlinks(SkillSourceActionTarget? target) + { + if (target is null) + { + SetStatus("A local skill folder must be selected before changing symlink policy.", ConfigStatusTone.Error); + return; + } + + ToggleLocalSymlinks(target.Name); + } + + internal void CommitCycleRemoteSyncInterval(SkillSourceActionTarget? target) + { + if (target is null) + { + SetStatus("A remote skill server must be selected before changing timeout.", ConfigStatusTone.Error); + return; + } + + CycleRemoteSyncInterval(target.Name); + } + + internal void CommitRemoveRemoteToken(SkillSourceActionTarget? target) + { + if (target is null) + { + SetStatus("A remote skill server must be selected before removing a token.", ConfigStatusTone.Error); + return; + } + + RemoveRemoteToken(target.Name); + } + + internal void CommitRemoveSource(SkillSourceActionTarget? target) + { + if (target is null) + { + SetStatus("A skill source must be selected before removing it.", ConfigStatusTone.Error); + return; + } + + RemoveSource(target.Kind, target.Name); + } + + public void DeleteSelected() + { + if (Screen.Value == SkillSourcesScreen.Inventory) + { + var row = GetInventoryRowOrNull(); + if (row?.Action == SkillSourcesInventoryAction.OpenSource && row.SourceKind is { } kind && row.SourceName is { } name) + BeginRemove(kind, name); + return; + } + + if (Screen.Value == SkillSourcesScreen.SourceDetail && SelectedSource is { } source) + BeginRemove(source.Kind, source.Name); + } + + public void GoBack() + { + switch (Screen.Value) + { + case SkillSourcesScreen.Inventory: + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + break; + case SkillSourcesScreen.SourceDetail: + ShowInventory(); + break; + case SkillSourcesScreen.AddLocalSymlinks: + ShowTextScreen(SkillSourcesScreen.AddLocalPath, _pendingLocalPath ?? string.Empty); + break; + case SkillSourcesScreen.AddLocalName: + ShowChoiceScreen(SkillSourcesScreen.AddLocalSymlinks, _pendingLocalAllowSymlinks ? 1 : 0); + break; + case SkillSourcesScreen.AddRemoteToken: + if (_editingAction == SkillSourceDetailAction.RotateToken) + { + ShowDetail(); + break; + } + + // Probe-driven flow: the token field was reached from the URL probe, so + // Back returns to the URL entry (there is no separate auth-choice screen). + ShowTextScreen(SkillSourcesScreen.AddRemoteUrl, _pendingRemoteUrl ?? string.Empty); + break; + case SkillSourcesScreen.AddRemoteName: + if (_pendingRemoteAuthMode == SkillSourceAuthMode.BearerToken) + ShowTextScreen(SkillSourcesScreen.AddRemoteToken, _pendingRemoteApiKey ?? string.Empty); + else + ShowTextScreen(SkillSourcesScreen.AddRemoteUrl, _pendingRemoteUrl ?? string.Empty); + break; + case SkillSourcesScreen.RenameSource: + case SkillSourcesScreen.ChangeLocation: + case SkillSourcesScreen.RemoveConfirm: + ShowDetail(); + break; + default: + ClearPendingFlow(); + ShowInventory(); + break; + } + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + // Cancel (but do not block-await) any in-flight off-loop probe: RunProbeAsync swallows the + // resulting OperationCanceledException, so there is no unobserved exception to worry about. + _probeCts?.Cancel(); + _probeCts?.Dispose(); + Screen.Dispose(); + SelectedRow.Dispose(); + Draft.Dispose(); + Version.Dispose(); + Status.Dispose(); + ActiveValidationDialog.Dispose(); + IsSaved.Dispose(); + base.Dispose(); + } + + // Exposes the in-flight off-loop reachability probe so tests can await it deterministically + // (no Task.Delay) instead of racing the thread-pool continuation. + internal Task? PendingProbe => _probeTask; + + // Kicks off a reachability probe OFF the single-threaded TUI loop so a slow/unreachable feed + // never freezes input. The synchronous setup (status + redraw) runs on the loop thread; the + // continuation in RunProbeAsync resumes on the thread pool (no SyncContext here) and may ONLY + // mutate Status/probe-result fields and RequestRedraw — never navigate. + private void StartBackgroundProbe( + string url, + string? apiKey, + int timeoutSeconds, + string testingMessage, + Action onResult) + { + _probeCts?.Cancel(); + _probeCts?.Dispose(); + _probeCts = new CancellationTokenSource(); + SetStatus(testingMessage, ConfigStatusTone.Neutral); + RequestRedraw(); + _probeTask = RunProbeAsync(url, apiKey, timeoutSeconds, _probeCts.Token, onResult); + } + + private async Task RunProbeAsync( + string url, + string? apiKey, + int timeoutSeconds, + CancellationToken ct, + Action onResult) + { + SkillFeedReachabilityResult result; + try + { + result = await _probe.ProbeAsync(url, apiKey, timeoutSeconds, ct); + } + catch (OperationCanceledException) + { + return; // superseded or abandoned probe — drop quietly + } + + if (ct.IsCancellationRequested) + return; + + onResult(result); // MUST be status-only (Status/fields), never navigation + RequestRedraw(); + } + + private void ActivateInventoryRow() + { + var row = GetInventoryRowOrNull(); + if (row is null) + return; + + switch (row.Action) + { + case SkillSourcesInventoryAction.OpenSource when row.SourceKind is { } kind && row.SourceName is { } name: + _selectedKind = kind; + _selectedName = name; + ShowDetail(); + break; + case SkillSourcesInventoryAction.AddLocalFolder: + BeginAddLocalFolder(); + break; + case SkillSourcesInventoryAction.AddSkillServer: + BeginAddRemoteServer(); + break; + case SkillSourcesInventoryAction.RescanAll: + RescanAll(); + break; + case SkillSourcesInventoryAction.Done: + GoBack(); + break; + } + } + + private void ActivateDetailRow() + { + if (SelectedSource is not { } source) + { + ShowInventory(); + return; + } + + var row = GetDetailRowOrNull(); + if (row is null) + return; + + switch (row.Action) + { + case SkillSourceDetailAction.ToggleEnabled: + ToggleEnabled(source.Kind, source.Name); + break; + case SkillSourceDetailAction.ToggleSymlinks: + ToggleLocalSymlinks(source.Name); + break; + case SkillSourceDetailAction.Rescan: + case SkillSourceDetailAction.TestConnection: + TestSource(source); + break; + case SkillSourceDetailAction.Location: + if (source.Kind == SkillSourceKind.LocalFolder && source.IsWellKnown) + { + SetStatus("Well-known source paths are managed automatically.", ConfigStatusTone.Neutral); + break; + } + + BeginChangeLocation(source); + break; + case SkillSourceDetailAction.Rename: + _editingAction = SkillSourceDetailAction.Rename; + ShowTextScreen(SkillSourcesScreen.RenameSource, source.Name); + break; + case SkillSourceDetailAction.ChangeLocation: + BeginChangeLocation(source); + break; + case SkillSourceDetailAction.SyncInterval: + CycleRemoteSyncInterval(source.Name); + break; + case SkillSourceDetailAction.RotateToken: + _editingAction = SkillSourceDetailAction.RotateToken; + ShowTextScreen(SkillSourcesScreen.AddRemoteToken, string.Empty); + break; + case SkillSourceDetailAction.RemoveToken: + RemoveRemoteToken(source.Name); + break; + case SkillSourceDetailAction.RemoveSource: + BeginRemove(source.Kind, source.Name); + break; + case SkillSourceDetailAction.Done: + ShowInventory(); + break; + } + } + + private void BeginAddLocalFolder() + { + ClearPendingFlow(); + ShowTextScreen(SkillSourcesScreen.AddLocalPath, string.Empty); + } + + private void ContinueAddLocalPath() + { + if (!TryNormalizeExternalDirectory(Draft.Value.Trim(), out var fullPath, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + _pendingLocalPath = fullPath; + _pendingLocalAllowSymlinks = false; + ShowChoiceScreen(SkillSourcesScreen.AddLocalSymlinks, 0); + } + + internal SkillSourceCommitResult ValidateAddLocalPathDraft(string value) + => TryNormalizeExternalDirectory(value.Trim(), out _, out var error) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(error); + + internal void CommitAddLocalPathDraft(string value) + { + Draft.Value = value; + ContinueAddLocalPath(); + } + + /// + /// Applies a commit result coming from a page-driven field commit. Structural errors + /// surface as a status line; a reachability-probe warning raises the "save anyway" + /// override dialog, mirroring the prior validated-commit pipeline behavior. + /// + internal void ApplyCommitResult(SkillSourceCommitResult result) + { + if (result.Success) + return; + + if (result.Tone == ConfigStatusTone.Warning) + { + CaptureValidationEditTarget(); + ActiveValidationDialog.Value = new NetclawValidationDialogModel( + "Skill Server Validation Warning", + "Netclaw could not complete skill server discovery using this configuration.", + result.Message); + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + return; + } + + SetStatus(result.Message, result.Tone); + } + + // --------------------------------------------------------------------- + // Page-driven field commits. + // + // Each method below replaces a former validated-UI commit: read the staged + // draft, run structural validation, optionally run the reachability probe, + // then persist. Structural failures and probe warnings flow through + // ApplyCommitResult (status line / override dialog). These are the entry + // points the page calls on Enter / Space / Delete for each screen. + // --------------------------------------------------------------------- + + internal void CommitAddLocalPath(string draft) + => CommitStructural(draft, ValidateAddLocalPathDraft, CommitAddLocalPathDraft); + + internal void CommitAddLocalSymlinks(bool allowSymlinks) + { + var result = ValidateAddLocalSymlinksDraft(allowSymlinks); + if (!result.Success) + { + ApplyCommitResult(result); + return; + } + + CommitAddLocalSymlinksDraft(allowSymlinks); + } + + internal void CommitAddLocalName(string draft) + => CommitStructural(draft, ValidateAddLocalNameDraft, CommitAddLocalNameDraft); + + internal void CommitAddRemoteUrl(string draft) + => CommitStructural(draft, ValidateAddRemoteUrlDraft, CommitAddRemoteUrlDraft); + + internal void CommitAddRemoteToken(string draft) + { + var structural = ValidateAddRemoteTokenDraft(draft); + if (!structural.Success) + { + ApplyCommitResult(structural); + return; + } + + ReplaceDraft(draft); + // Reachability is no longer gated here (a blocking probe froze the loop). The add-remote + // review step (ProbePendingRemoteThenReview) and the Test action validate reachability + // off-loop; advance now. + CommitAddRemoteTokenDraft(draft); + } + + internal void CommitAddRemoteName(string draft) + => CommitStructural(draft, ValidateAddRemoteNameDraft, CommitAddRemoteNameDraft); + + internal void CommitRenameSource(string draft) + => CommitStructural(draft, ValidateRenameSourceDraft, CommitRenameSourceDraft); + + internal void CommitChangeLocation(string draft) + { + var structural = ValidateChangeLocationDraft(draft); + if (!structural.Success) + { + ApplyCommitResult(structural); + return; + } + + ReplaceDraft(draft); + + // Persist now, validate reachability async: a blocking probe here froze the loop (deep-review + // finding). For a remote source the persist path (SaveRemoteUrlChange) already kicks off the + // off-loop warn-probe, so there is nothing to gate here — just commit. + CommitChangeLocationDraft(draft); + } + + internal void CommitToggleEnabledAction() + => CommitSourceAction(ValidateSourceActionTarget, CommitToggleEnabled); + + internal void CommitToggleLocalSymlinksAction() + => CommitSourceAction(ValidateLocalSourceActionTarget, CommitToggleLocalSymlinks); + + internal void CommitCycleRemoteSyncIntervalAction() + => CommitSourceAction(ValidateRemoteSourceActionTarget, CommitCycleRemoteSyncInterval); + + internal void CommitRemoveRemoteTokenAction() + => CommitSourceAction(ValidateRemoteSourceActionTarget, CommitRemoveRemoteToken); + + internal void CommitRemoveSourceAction() + => CommitSourceAction(ValidateSourceActionTarget, CommitRemoveSource); + + private void CommitStructural( + string draft, + Func validate, + Action persist) + { + var result = validate(draft); + if (!result.Success) + { + ApplyCommitResult(result); + return; + } + + persist(draft); + } + + private void CommitSourceAction( + Func validate, + Action persist) + { + var target = ReadCurrentSourceActionTarget(); + var result = validate(target); + if (!result.Success) + { + ApplyCommitResult(result); + return; + } + + persist(target); + } + + /// + /// Persists the current draft without re-running the reachability probe. Invoked when + /// the user chooses "Save anyway" from the probe-warning override dialog. Dispatches by + /// the screen the dialog was raised over so the correct section writer runs. + /// + internal void SaveCurrentDraftAnyway() + { + ActiveValidationDialog.Value = null; + switch (Screen.Value) + { + case SkillSourcesScreen.AddRemoteToken: + CommitAddRemoteTokenDraft(Draft.Value); + break; + case SkillSourcesScreen.ChangeLocation: + CommitChangeLocationDraft(Draft.Value); + break; + default: + RequestRedraw(); + break; + } + } + + internal void DismissValidationDialog() + { + ActiveValidationDialog.Value = null; + _validationEditScreen = null; + _validationEditDraft = null; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + RequestRedraw(); + } + + internal void ReturnToValidationEdit() + { + var editScreen = _validationEditScreen; + var editDraft = _validationEditDraft; + ActiveValidationDialog.Value = null; + _validationEditScreen = null; + _validationEditDraft = null; + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + + switch (editScreen) + { + case SkillSourcesScreen.AddRemoteUrl: + case SkillSourcesScreen.AddRemoteToken: + case SkillSourcesScreen.ChangeLocation: + ShowTextScreen(editScreen.Value, editDraft ?? string.Empty); + break; + default: + RequestRedraw(); + break; + } + } + + private void CaptureValidationEditTarget() + { + _validationEditScreen = Screen.Value switch + { + SkillSourcesScreen.AddRemoteToken => SkillSourcesScreen.AddRemoteToken, + SkillSourcesScreen.ChangeLocation => SkillSourcesScreen.ChangeLocation, + _ => Screen.Value, + }; + _validationEditDraft = _validationEditScreen switch + { + SkillSourcesScreen.AddRemoteUrl => _pendingRemoteUrl ?? Draft.Value, + SkillSourcesScreen.AddRemoteToken => Draft.Value, + SkillSourcesScreen.ChangeLocation => Draft.Value, + _ => Draft.Value, + }; + } + + private void ContinueAddLocalSymlinks() + { + _pendingLocalAllowSymlinks = SelectedRow.Value == 1; + var suggestedName = SuggestNameFromPath(_pendingLocalPath ?? "team-skills"); + ShowTextScreen(SkillSourcesScreen.AddLocalName, MakeUniqueName(suggestedName)); + } + + internal bool ReadAddLocalSymlinksDraft() + => SelectedRow.Value == 1; + + internal void ReplaceAddLocalSymlinksDraft(bool value) + { + if (Screen.Value != SkillSourcesScreen.AddLocalSymlinks) + return; + + var row = value ? 1 : 0; + if (SelectedRow.Value == row) + return; + + SelectedRow.Value = row; + MarkDirty(); + } + + internal SkillSourceCommitResult ValidateAddLocalSymlinksDraft(bool value) + => _pendingLocalPath is null + ? SkillSourceCommitResult.Failed("Local folder path is required before choosing symlink policy.") + : SkillSourceCommitResult.Ok(); + + internal void CommitAddLocalSymlinksDraft(bool value) + { + _pendingLocalAllowSymlinks = value; + var suggestedName = SuggestNameFromPath(_pendingLocalPath ?? "team-skills"); + ShowTextScreen(SkillSourcesScreen.AddLocalName, MakeUniqueName(suggestedName)); + } + + private void SaveNewLocalSource() + { + if (_pendingLocalPath is null) + { + SetStatus("Local folder path is required before adding a source.", ConfigStatusTone.Error); + return; + } + + var name = NormalizeSourceName(Draft.Value); + if (!ValidateNewSourceName(name, null, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + if (!TryLoadExternalConfig(out var external)) return; + external.Sources.Add(new ExternalSkillSource + { + Name = name, + Path = _pendingLocalPath, + Enabled = true, + AllowSymlinks = _pendingLocalAllowSymlinks, + }); + + if (!SaveExternalConfig(external)) + return; + ClearPendingFlow(); + ReloadSources(); + _selectedKind = SkillSourceKind.LocalFolder; + _selectedName = name; + ShowDetail($"Added local skill folder '{name}'."); + } + + internal SkillSourceCommitResult ValidateAddLocalNameDraft(string value) + { + if (_pendingLocalPath is null) + return SkillSourceCommitResult.Failed("Local folder path is required before adding a source."); + + var name = NormalizeSourceName(value); + return ValidateNewSourceName(name, null, out var error) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(error); + } + + internal void CommitAddLocalNameDraft(string value) + { + Draft.Value = value; + SaveNewLocalSource(); + } + + private void BeginAddRemoteServer() + { + ClearPendingFlow(); + ShowTextScreen(SkillSourcesScreen.AddRemoteUrl, string.Empty); + } + + private void BeginChangeLocation(SkillSourceDisplay source) + { + _editingAction = SkillSourceDetailAction.ChangeLocation; + ShowTextScreen(SkillSourcesScreen.ChangeLocation, source.Location); + } + + private void ContinueAddRemoteUrl() + { + if (!TryNormalizeFeedUrl(Draft.Value.Trim(), out var url, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + _pendingRemoteUrl = url ?? throw new InvalidOperationException("Validated skill server URL was null."); + _pendingRemoteAuthMode = SkillSourceAuthMode.None; + _pendingRemoteApiKey = null; + _pendingRemoteProbeMessage = null; + _pendingRemoteTimeoutSeconds = DefaultFeedTimeoutSeconds; + + // Probe-driven disclosure: probe with no auth first. The bearer-token field is + // revealed only when the server actually requires auth (401/403); open targets + // go straight to the name/review step and never see a secret field. + // + // Do NOT reset _lastProbeFingerprint / _pendingRemoteProbeResult here: this method runs on + // every Enter on the URL screen with the SAME URL, so the recomputed fingerprint matches the + // first probe's. Clearing them would re-arm phase 1 and defeat "press Enter again to save + // anyway" for an unreachable open server (the second Enter must act on the completed result). + ProbePendingRemoteThenReview(); + } + + internal SkillSourceCommitResult ValidateAddRemoteUrlDraft(string value) + => TryNormalizeFeedUrl(value.Trim(), out _, out var error) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(error); + + internal void CommitAddRemoteUrlDraft(string value) + { + Draft.Value = value; + ContinueAddRemoteUrl(); + } + + private void ContinueAddRemoteToken() + { + if (_editingAction == SkillSourceDetailAction.RotateToken) + { + SaveRotatedRemoteToken(); + return; + } + + var token = Draft.Value.Trim(); + if (!TryValidateApiKeyDraft(token, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + if (string.IsNullOrWhiteSpace(token)) + { + SetStatus("Bearer token is required when authentication is set to bearer token.", ConfigStatusTone.Error); + return; + } + + _pendingRemoteApiKey = token; + ProbePendingRemoteThenReview(); + } + + internal SkillSourceCommitResult ValidateAddRemoteTokenDraft(string value) + { + if (_editingAction == SkillSourceDetailAction.RotateToken) + { + if (SelectedSource is not { Kind: SkillSourceKind.RemoteSkillServer }) + return SkillSourceCommitResult.Failed("A remote skill server must be selected before rotating a token."); + } + else if (_pendingRemoteUrl is null) + { + return SkillSourceCommitResult.Failed("Skill server URL is required before adding a token."); + } + + var token = value.Trim(); + if (!TryValidateApiKeyDraft(token, out var error)) + return SkillSourceCommitResult.Failed(error); + + return string.IsNullOrWhiteSpace(token) + ? SkillSourceCommitResult.Failed(_editingAction == SkillSourceDetailAction.RotateToken + ? "New bearer token is required. Use Remove token to delete an existing token." + : "Bearer token is required when authentication is set to bearer token.") + : SkillSourceCommitResult.Ok(); + } + + internal void CommitAddRemoteTokenDraft(string value) + { + Draft.Value = value; + if (_editingAction == SkillSourceDetailAction.RotateToken) + { + SaveRotatedRemoteToken(); + return; + } + + _pendingRemoteApiKey = value.Trim(); + var suggestedName = SuggestNameFromUrl(_pendingRemoteUrl ?? "skill-server"); + ShowTextScreen(SkillSourcesScreen.AddRemoteName, MakeUniqueName(suggestedName)); + } + + // Two-phase add-remote review. The reachability probe runs OFF the loop (StartBackgroundProbe), + // so this method must never navigate from a probe continuation. Instead it acts on a completed + // probe result on the NEXT loop-thread invocation (the next Enter), where navigation is safe: + // Phase 1 (new fingerprint): kick off the probe, return. The background continuation only sets + // _pendingRemoteProbeResult + Status. + // Phase 2 (fingerprint matches, result present): ACT on the result here, on the loop thread — + // reveal the token field (RequiresAuth), advance to the name screen (success), or + // save-anyway to the name screen (a second Enter on a failure). + // Editing the URL or entering a token changes the fingerprint, which re-arms phase 1. + private void ProbePendingRemoteThenReview() + { + if (_pendingRemoteUrl is null) + { + SetStatus("Skill server URL is required before testing a source.", ConfigStatusTone.Error); + return; + } + + var apiKey = _pendingRemoteAuthMode == SkillSourceAuthMode.BearerToken ? _pendingRemoteApiKey : null; + var fingerprint = $"{_pendingRemoteUrl}|{apiKey?.Length ?? 0}"; + + if (_lastProbeFingerprint == fingerprint && _pendingRemoteProbeResult is { } done) + { + // Phase 2: a completed probe for this exact URL/token — navigate on the loop thread. + _pendingRemoteProbeResult = null; + + if (done.Success) + { + ShowTextScreen(SkillSourcesScreen.AddRemoteName, MakeUniqueName(SuggestNameFromUrl(_pendingRemoteUrl))); + return; + } + + // Probe-driven disclosure: a 401/403 with no bearer token means the server requires + // auth — reveal the token field rather than offering "save anyway". Entering a token + // changes the fingerprint, which re-arms the probe. + if (done.RequiresAuth && _pendingRemoteAuthMode != SkillSourceAuthMode.BearerToken) + { + _pendingRemoteAuthMode = SkillSourceAuthMode.BearerToken; + ShowTextScreen(SkillSourcesScreen.AddRemoteToken, string.Empty); + SetStatus($"{done.Message} Enter a bearer token to continue.", ConfigStatusTone.Warning); + return; + } + + // Save-anyway: a second Enter on a (non-auth) failure proceeds to the name screen. + ShowTextScreen(SkillSourcesScreen.AddRemoteName, MakeUniqueName(SuggestNameFromUrl(_pendingRemoteUrl))); + return; + } + + if (_lastProbeFingerprint == fingerprint && _pendingRemoteProbeResult is null) + { + // Phase 1 probe still in flight — the user pressed Enter again before it returned. + SetStatus("Still testing skill server…", ConfigStatusTone.Neutral); + return; + } + + // Phase 1: a new URL/token — kick off the off-loop probe. The continuation is status-only. + _lastProbeFingerprint = fingerprint; + _pendingRemoteProbeResult = null; + StartBackgroundProbe( + _pendingRemoteUrl, + apiKey, + _pendingRemoteTimeoutSeconds, + "Testing skill server…", + r => + { + _pendingRemoteProbeResult = r; + _pendingRemoteProbeMessage = r.Message; + SetStatus( + r.Success + ? $"{r.Message} Press Enter to continue." + : r.RequiresAuth && _pendingRemoteAuthMode != SkillSourceAuthMode.BearerToken + ? $"{r.Message} Press Enter to add a bearer token." + : $"{r.Message} Press Enter to save anyway.", + r.Success ? ConfigStatusTone.Success : ConfigStatusTone.Warning); + }); + } + + private void SaveNewRemoteSource() + { + if (_pendingRemoteUrl is null) + { + SetStatus("Skill server URL is required before adding a source.", ConfigStatusTone.Error); + return; + } + + var name = NormalizeSourceName(Draft.Value); + if (!ValidateNewSourceName(name, null, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + if (!TryLoadSkillFeeds(out var feeds)) return; + + string? protectedApiKey = null; + if (_pendingRemoteAuthMode == SkillSourceAuthMode.BearerToken + && !string.IsNullOrWhiteSpace(_pendingRemoteApiKey) + && !TryProtectApiKey(_pendingRemoteApiKey, out protectedApiKey)) + return; + + feeds.Feeds.Add(new SkillFeedConfigEntry + { + Name = name, + Url = _pendingRemoteUrl, + Enabled = true, + TimeoutSeconds = _pendingRemoteTimeoutSeconds, + ApiKey = protectedApiKey, + }); + + if (!SaveSkillFeedsConfig(feeds)) + return; + ClearPendingFlow(); + ReloadSources(); + _selectedKind = SkillSourceKind.RemoteSkillServer; + _selectedName = name; + ShowDetail($"Added skill server '{name}'."); + } + + internal SkillSourceCommitResult ValidateAddRemoteNameDraft(string value) + { + if (_pendingRemoteUrl is null) + return SkillSourceCommitResult.Failed("Skill server URL is required before adding a source."); + + var name = NormalizeSourceName(value); + return ValidateNewSourceName(name, null, out var error) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(error); + } + + internal void CommitAddRemoteNameDraft(string value) + { + Draft.Value = value; + SaveNewRemoteSource(); + } + + private void ToggleEnabled(SkillSourceKind kind, string name) + { + if (kind == SkillSourceKind.LocalFolder) + { + if (!TryLoadExternalConfig(out var external)) return; + var source = FindLocalSource(external, name); + if (source is null) + { + SetStatus($"Local skill folder '{name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + source.Enabled = !source.Enabled; + if (!SaveExternalConfig(external)) + return; + ReloadSources(); + SetStatus($"Local skill folder '{name}' {(source.Enabled ? "enabled" : "disabled")}.", ConfigStatusTone.Success); + return; + } + + if (!TryLoadSkillFeeds(out var feeds)) return; + var feed = FindRemoteSource(feeds, name); + if (feed is null) + { + SetStatus($"Skill server '{name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + feed.Enabled = !feed.Enabled; + if (!SaveSkillFeedsConfig(feeds)) + return; + ReloadSources(); + SetStatus($"Skill server '{name}' {(feed.Enabled ? "enabled" : "disabled")}.", ConfigStatusTone.Success); + } + + private void ToggleLocalSymlinks(string name) + { + if (!TryLoadExternalConfig(out var external)) return; + var source = FindLocalSource(external, name); + if (source is null) + { + SetStatus($"Local skill folder '{name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + source.AllowSymlinks = !source.AllowSymlinks; + if (!SaveExternalConfig(external)) + return; + ReloadSources(); + SetStatus($"Local skill folder '{name}' symlink policy saved.", ConfigStatusTone.Success); + } + + private void CycleRemoteSyncInterval(string name) + { + if (!TryLoadSkillFeeds(out var feeds)) return; + var feed = FindRemoteSource(feeds, name); + if (feed is null) + { + SetStatus($"Skill server '{name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + feed.TimeoutSeconds = feed.TimeoutSeconds switch + { + <= 10 => 30, + <= 30 => 60, + _ => 10, + }; + + if (!SaveSkillFeedsConfig(feeds)) + return; + ReloadSources(); + SetStatus($"Skill server '{name}' timeout saved as {feed.TimeoutSeconds}s.", ConfigStatusTone.Success); + } + + private void TestSource(SkillSourceDisplay source) + { + if (source.Kind == SkillSourceKind.LocalFolder) + { + if (Directory.Exists(source.Location)) + { + var scan = ScanLocalSkills(source.Location, source.AllowSymlinks); + if (scan.Warning is null) + { + SetStatus($"Local folder '{source.Name}' is readable ({scan.Count} skills discovered).", ConfigStatusTone.Success); + } + else + { + SetStatus($"Local folder '{source.Name}' scan warning: {scan.Warning}", ConfigStatusTone.Warning); + } + } + else + { + SetStatus($"Local folder '{source.Name}' does not exist: {source.Location}", ConfigStatusTone.Error); + } + + return; + } + + if (!TryLoadSkillFeeds(out var feeds)) return; + var feed = FindRemoteSource(feeds, source.Name); + if (feed is null) + { + SetStatus($"Skill server '{source.Name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + var apiKey = TryGetFeedApiKeyPlaintext(feed, out var plaintext, out var error) ? plaintext : null; + if (!string.IsNullOrWhiteSpace(error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + StartBackgroundProbe( + feed.Url, + apiKey, + feed.TimeoutSeconds, + $"Testing skill server '{source.Name}'…", + r => SetStatus(r.Message, r.Success ? ConfigStatusTone.Success : ConfigStatusTone.Warning)); + } + + private void SaveRename() + { + if (SelectedSource is not { } source) + { + ShowInventory(); + return; + } + + var newName = NormalizeSourceName(Draft.Value); + if (!ValidateNewSourceName(newName, source.Name, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + if (source.Kind == SkillSourceKind.LocalFolder) + { + if (!TryLoadExternalConfig(out var external)) return; + var item = FindLocalSource(external, source.Name); + if (item is null) + { + SetStatus($"Local skill folder '{source.Name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + item.Name = newName; + if (!SaveExternalConfig(external)) + return; + } + else + { + if (!TryLoadSkillFeeds(out var feeds)) return; + var item = FindRemoteSource(feeds, source.Name); + if (item is null) + { + SetStatus($"Skill server '{source.Name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + item.Name = newName; + if (!SaveSkillFeedsConfig(feeds)) + return; + } + + _selectedName = newName; + ReloadSources(); + ShowDetail($"Renamed source to '{newName}'."); + } + + internal SkillSourceCommitResult ValidateRenameSourceDraft(string value) + { + if (SelectedSource is not { } source) + return SkillSourceCommitResult.Failed("A skill source must be selected before renaming."); + + var newName = NormalizeSourceName(value); + return ValidateNewSourceName(newName, source.Name, out var error) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(error); + } + + internal void CommitRenameSourceDraft(string value) + { + Draft.Value = value; + SaveRename(); + } + + private void SaveLocationChange() + { + if (SelectedSource is not { } source) + { + ShowInventory(); + return; + } + + if (source.Kind == SkillSourceKind.LocalFolder) + { + SaveLocalPathChange(source); + return; + } + + SaveRemoteUrlChange(source); + } + + internal SkillSourceCommitResult ValidateChangeLocationDraft(string value) + { + if (SelectedSource is not { } source) + return SkillSourceCommitResult.Failed("A skill source must be selected before changing location."); + + if (source.Kind == SkillSourceKind.LocalFolder) + { + if (source.IsWellKnown) + return SkillSourceCommitResult.Failed("Well-known source paths are managed automatically."); + + return TryNormalizeExternalDirectory(value.Trim(), out _, out var error) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(error); + } + + return TryNormalizeFeedUrl(value.Trim(), out _, out var urlError) + ? SkillSourceCommitResult.Ok() + : SkillSourceCommitResult.Failed(urlError); + } + + internal void CommitChangeLocationDraft(string value) + { + Draft.Value = value; + if (SelectedSource is not { } source) + { + ShowInventory(); + return; + } + + if (source.Kind == SkillSourceKind.LocalFolder) + { + SaveLocalPathChange(source); + return; + } + + SaveRemoteUrlChange(source); + } + + private void SaveLocalPathChange(SkillSourceDisplay source) + { + if (source.IsWellKnown) + { + SetStatus("Well-known source paths are managed automatically.", ConfigStatusTone.Error); + return; + } + + if (!TryNormalizeExternalDirectory(Draft.Value.Trim(), out var fullPath, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + if (!TryLoadExternalConfig(out var external)) return; + var item = FindLocalSource(external, source.Name); + if (item is null) + { + SetStatus($"Local skill folder '{source.Name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + item.Path = fullPath; + if (!SaveExternalConfig(external)) + return; + ReloadSources(); + ShowDetail($"Local skill folder '{source.Name}' path saved."); + } + + // Persist-now, validate-async: the URL change is saved immediately (a blocking probe froze the + // loop), then an off-loop warn-probe surfaces a non-blocking warning if the new URL is unreachable. + private void SaveRemoteUrlChange(SkillSourceDisplay source) + { + if (!TryNormalizeFeedUrl(Draft.Value.Trim(), out var url, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + var normalizedUrl = url ?? throw new InvalidOperationException("Validated skill server URL was null."); + + if (!TryLoadSkillFeeds(out var feeds)) return; + var item = FindRemoteSource(feeds, source.Name); + if (item is null) + { + SetStatus($"Skill server '{source.Name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + var apiKey = TryGetFeedApiKeyPlaintext(item, out var plaintext, out var decryptError) ? plaintext : null; + if (!string.IsNullOrWhiteSpace(decryptError)) + { + SetStatus(decryptError, ConfigStatusTone.Error); + return; + } + + var timeoutSeconds = item.TimeoutSeconds; + item.Url = normalizedUrl; + if (!SaveSkillFeedsConfig(feeds)) + return; + ReloadSources(); + ShowDetail($"Skill server '{source.Name}' URL saved."); + + StartBackgroundProbe( + normalizedUrl, + apiKey, + timeoutSeconds, + "Verifying skill server…", + r => SetStatus( + r.Success + ? $"Skill server '{source.Name}' URL saved (reachable)." + : $"Saved, but the skill server is unreachable: {r.Message}", + r.Success ? ConfigStatusTone.Success : ConfigStatusTone.Warning)); + } + + // Persist-now, validate-async: the rotated token is saved immediately, then an off-loop + // warn-probe surfaces a non-blocking warning if the feed is unreachable with the new token. + private void SaveRotatedRemoteToken() + { + if (SelectedSource is not { Kind: SkillSourceKind.RemoteSkillServer } source) + { + ShowInventory(); + return; + } + + var token = Draft.Value.Trim(); + if (!TryValidateApiKeyDraft(token, out var error)) + { + SetStatus(error, ConfigStatusTone.Error); + return; + } + + if (string.IsNullOrWhiteSpace(token)) + { + SetStatus("New bearer token is required. Use Remove token to delete an existing token.", ConfigStatusTone.Error); + return; + } + + if (!TryLoadSkillFeeds(out var feeds)) return; + var feed = FindRemoteSource(feeds, source.Name); + if (feed is null) + { + SetStatus($"Skill server '{source.Name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + var feedUrl = feed.Url; + var timeoutSeconds = feed.TimeoutSeconds; + if (!TryProtectApiKey(token, out var protectedToken)) + return; + feed.ApiKey = protectedToken; + if (!SaveSkillFeedsConfig(feeds)) + return; + _editingAction = null; + ReloadSources(); + ShowDetail($"Skill server '{source.Name}' token rotated."); + + StartBackgroundProbe( + feedUrl, + token, + timeoutSeconds, + "Verifying skill server…", + r => SetStatus( + r.Success + ? $"Skill server '{source.Name}' token rotated (reachable)." + : $"Saved, but the skill server is unreachable: {r.Message}", + r.Success ? ConfigStatusTone.Success : ConfigStatusTone.Warning)); + } + + private void RemoveRemoteToken(string name) + { + if (!TryLoadSkillFeeds(out var feeds)) return; + var feed = FindRemoteSource(feeds, name); + if (feed is null) + { + SetStatus($"Skill server '{name}' no longer exists in config.", ConfigStatusTone.Error); + ReloadSources(); + return; + } + + if (string.IsNullOrWhiteSpace(feed.ApiKey)) + { + SetStatus($"Skill server '{name}' has no token to remove.", ConfigStatusTone.Neutral); + return; + } + + feed.ApiKey = null; + if (!SaveSkillFeedsConfig(feeds)) + return; + ReloadSources(); + SetStatus($"Skill server '{name}' token removed.", ConfigStatusTone.Success); + } + + private void BeginRemove(SkillSourceKind kind, string name) + { + _selectedKind = kind; + _selectedName = name; + ShowChoiceScreen(SkillSourcesScreen.RemoveConfirm, 0); + } + + private void ActivateRemoveConfirm() + { + if (SelectedRow.Value == 0) + { + ShowDetail(); + return; + } + + if (_selectedKind is not { } kind || _selectedName is not { } name) + { + ShowInventory(); + return; + } + + RemoveSource(kind, name); + } + + private void RemoveSource(SkillSourceKind kind, string name) + { + if (kind == SkillSourceKind.LocalFolder) + { + if (!TryLoadExternalConfig(out var external)) return; + external.Sources.RemoveAll(s => _nameComparer.Equals(s.Name, name)); + if (!SaveExternalConfig(external)) + return; + } + else + { + if (!TryLoadSkillFeeds(out var feeds)) return; + feeds.Feeds.RemoveAll(f => _nameComparer.Equals(f.Name, name)); + if (!SaveSkillFeedsConfig(feeds)) + return; + } + + _selectedKind = null; + _selectedName = null; + ReloadSources(); + ShowInventory($"Removed skill source '{name}'."); + } + + private void RescanAll() + { + ReloadSources(); + var localCount = _sources.Count(s => s.Kind == SkillSourceKind.LocalFolder); + var remoteCount = _sources.Count(s => s.Kind == SkillSourceKind.RemoteSkillServer); + SetStatus($"Rescanned {localCount} local folder(s) and {remoteCount} skill server(s).", ConfigStatusTone.Success); + } + + private IReadOnlyList BuildInventoryRows() + { + var rows = new List(); + foreach (var source in _sources) + { + rows.Add(new SkillSourcesInventoryRow( + SkillSourcesInventoryAction.OpenSource, + source.Kind, + source.Name, + FormatSourceLabel(source), + FormatSourceDetail(source), + source.StatusTone)); + } + + rows.Add(new SkillSourcesInventoryRow(SkillSourcesInventoryAction.AddLocalFolder, null, null, "+ Add local folder", "Scan a directory on this machine.", ConfigStatusTone.Neutral)); + rows.Add(new SkillSourcesInventoryRow(SkillSourcesInventoryAction.AddSkillServer, null, null, "+ Add skill server", "Connect to a remote skill feed.", ConfigStatusTone.Neutral)); + rows.Add(new SkillSourcesInventoryRow(SkillSourcesInventoryAction.RescanAll, null, null, "Rescan all", "Refresh local source status.", ConfigStatusTone.Neutral)); + rows.Add(new SkillSourcesInventoryRow(SkillSourcesInventoryAction.Done, null, null, "Done", "Return to Settings Areas.", ConfigStatusTone.Neutral)); + return rows; + } + + private IReadOnlyList BuildDetailRows(SkillSourceDisplay source) + { + if (source.Kind == SkillSourceKind.LocalFolder) + { + var rows = new List + { + new(SkillSourceDetailAction.ToggleEnabled, $"Enabled [{Check(source.Enabled)}]", "Autosaves source enabled state.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.Location, $"Path {source.Location}", source.IsWellKnown ? "Well-known path is managed automatically." : "Enter to change path.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.ToggleSymlinks, $"Allow symlinks [{Check(source.AllowSymlinks)}]", "Autosaves symlink policy.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.Rescan, "Rescan folder", "Check readability and discovered skill count.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.Rename, "Rename source", "Change the display/config name.", ConfigStatusTone.Neutral), + }; + + if (!source.IsWellKnown) + rows.Add(new SkillSourceDetailRow(SkillSourceDetailAction.ChangeLocation, "Change path", "Validate and save a new local directory.", ConfigStatusTone.Neutral)); + + rows.Add(new SkillSourceDetailRow(SkillSourceDetailAction.RemoveSource, "Remove source", "Stop loading skills from this folder.", ConfigStatusTone.Warning)); + rows.Add(new SkillSourceDetailRow(SkillSourceDetailAction.Done, "Done", "Return to Skill Sources.", ConfigStatusTone.Neutral)); + return rows; + } + + return + [ + new(SkillSourceDetailAction.ToggleEnabled, $"Enabled [{Check(source.Enabled)}]", "Autosaves source enabled state.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.Location, $"URL {source.Location}", "Enter to change URL and test discovery.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.Authentication, + $"Authentication {(source.HasApiKey ? source.ApiKeyIsPlaintext ? "bearer token stored as PLAINTEXT" : "bearer token configured" : "none")}", + source.ApiKeyIsPlaintext + ? "Token is stored unencrypted in config — use Rotate token to re-enter and encrypt it." + : "Use Rotate token or Remove token for credentials.", + source.ApiKeyIsPlaintext ? ConfigStatusTone.Warning : ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.SyncInterval, $"HTTP timeout {source.TimeoutSeconds}s", "Enter to cycle 10s / 30s / 60s.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.TestConnection, "Test connection", "Probe the discovery endpoint.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.Rename, "Rename source", "Change the display/config name.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.ChangeLocation, "Change URL", "Validate and save a new server URL.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.RotateToken, "Rotate token", "Replace the stored bearer token.", ConfigStatusTone.Neutral), + new(SkillSourceDetailAction.RemoveToken, "Remove token", "Delete the stored bearer token.", ConfigStatusTone.Warning), + new(SkillSourceDetailAction.RemoveSource, "Remove source", "Stop loading skills from this server.", ConfigStatusTone.Warning), + new(SkillSourceDetailAction.Done, "Done", "Return to Skill Sources.", ConfigStatusTone.Neutral), + ]; + } + + private void ReloadSources() + { + try + { + var root = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + var external = ConfigFileHelper.LoadSection(root, "ExternalSkills"); + var feeds = LoadSkillFeedsSection(root); + _sources = BuildSources(external, feeds).ToList(); + Version.Value++; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + // A malformed / unreadable netclaw.json must not crash the page or its constructor (this + // runs from both). Keep the prior _sources snapshot (empty on first load) and surface the + // error so the operator can repair the file instead of facing a dead page. + SetStatus($"Could not read skill sources config: {ex.Message}", ConfigStatusTone.Error); + } + } + + private IEnumerable BuildSources(ExternalSkillsConfig external, SkillFeedsConfigDocument feeds) + { + foreach (var source in external.Sources.OrderBy(static s => s.Name, StringComparer.OrdinalIgnoreCase)) + { + var location = ResolveLocalDisplayPath(source); + var exists = Directory.Exists(location); + var scan = exists ? ScanLocalSkills(location, source.AllowSymlinks) : null; + var hasScanWarning = scan?.Warning is not null; + yield return new SkillSourceDisplay( + SkillSourceKind.LocalFolder, + source.Name, + location, + source.Enabled, + !string.IsNullOrWhiteSpace(source.WellKnown), + source.AllowSymlinks, + false, + DefaultFeedTimeoutSeconds, + exists ? hasScanWarning ? $"scan warning ({scan!.Count} skill{Plural(scan.Count)})" : $"{scan!.Count} skill{Plural(scan.Count)}" : "missing folder", + exists && !hasScanWarning ? ConfigStatusTone.Success : ConfigStatusTone.Warning); + } + + foreach (var feed in feeds.Feeds.OrderBy(static f => f.Name, StringComparer.OrdinalIgnoreCase)) + { + yield return new SkillSourceDisplay( + SkillSourceKind.RemoteSkillServer, + feed.Name, + feed.Url, + feed.Enabled, + false, + false, + !string.IsNullOrWhiteSpace(feed.ApiKey), + feed.TimeoutSeconds, + string.IsNullOrWhiteSpace(feed.ApiKey) ? "no auth" : "token configured", + ConfigStatusTone.Neutral, + ApiKeyIsPlaintext: !string.IsNullOrWhiteSpace(feed.ApiKey) && !ISecretsProtector.IsEncrypted(feed.ApiKey)); + } + } + + private int RowCountForCurrentScreen() + => Screen.Value switch + { + SkillSourcesScreen.Inventory => InventoryRows.Count, + SkillSourcesScreen.SourceDetail => DetailRows.Count, + SkillSourcesScreen.AddLocalSymlinks => 2, + SkillSourcesScreen.RemoveConfirm => 2, + _ => 1, + }; + + private SkillSourcesInventoryRow? GetInventoryRowOrNull() + { + var rows = InventoryRows; + return SelectedRow.Value >= 0 && SelectedRow.Value < rows.Count ? rows[SelectedRow.Value] : null; + } + + private SkillSourceDetailRow? GetDetailRowOrNull() + { + var rows = DetailRows; + return SelectedRow.Value >= 0 && SelectedRow.Value < rows.Count ? rows[SelectedRow.Value] : null; + } + + private void ShowInventory(string? message = null) + { + Screen.Value = SkillSourcesScreen.Inventory; + SelectedRow.Value = Math.Clamp(SelectedRow.Value, 0, Math.Max(0, InventoryRows.Count - 1)); + Draft.Value = string.Empty; + _editingAction = null; + if (message is not null) + SetStatus(message, ConfigStatusTone.Success); + else + RequestRedraw(); + } + + private void ShowDetail(string? message = null) + { + Screen.Value = SkillSourcesScreen.SourceDetail; + SelectedRow.Value = 0; + Draft.Value = string.Empty; + _editingAction = null; + if (message is not null) + SetStatus(message, ConfigStatusTone.Success); + else + RequestRedraw(); + } + + private void ShowTextScreen(SkillSourcesScreen screen, string seed) + { + SelectedRow.Value = 0; + Draft.Value = seed; + Screen.Value = screen; + ClearStatus(); + RequestRedraw(); + } + + private void ShowChoiceScreen(SkillSourcesScreen screen, int row) + { + SelectedRow.Value = row; + Draft.Value = string.Empty; + Screen.Value = screen; + ClearStatus(); + RequestRedraw(); + } + + private void MarkDirty() + { + IsSaved.Value = false; + // Re-arm the add-remote review probe: a config edit invalidates any completed probe result. + _lastProbeFingerprint = null; + _pendingRemoteProbeResult = null; + ActiveValidationDialog.Value = null; + ClearStatus(); + RequestRedraw(); + } + + private void SetStatus(string message, ConfigStatusTone tone) + { + Status.Value = new ConfigStatusMessage(message, tone); + IsSaved.Value = tone == ConfigStatusTone.Success; + RequestRedraw(); + } + + private void ClearStatus() + { + if (!string.IsNullOrWhiteSpace(Status.Value.Text)) + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } + + private void ClearPendingFlow() + { + _pendingLocalPath = null; + _pendingLocalAllowSymlinks = false; + _pendingRemoteUrl = null; + _pendingRemoteAuthMode = SkillSourceAuthMode.None; + _pendingRemoteApiKey = null; + _pendingRemoteProbeMessage = null; + _pendingRemoteTimeoutSeconds = DefaultFeedTimeoutSeconds; + _lastProbeFingerprint = null; + _pendingRemoteProbeResult = null; + _editingAction = null; + ActiveValidationDialog.Value = null; + Draft.Value = string.Empty; + } + + private bool ValidateNewSourceName(string name, string? currentName, out string error) + { + error = string.Empty; + if (string.IsNullOrWhiteSpace(name)) + { + error = "Source name is required."; + return false; + } + + var duplicate = _sources.Any(source => !_nameComparer.Equals(source.Name, currentName) && _nameComparer.Equals(source.Name, name)); + if (duplicate) + { + error = $"A skill source named '{name}' already exists."; + return false; + } + + return true; + } + + private bool TryGetFeedApiKeyPlaintext(SkillFeedConfigEntry feed, out string? plaintext, out string error) + { + plaintext = null; + error = string.Empty; + if (string.IsNullOrWhiteSpace(feed.ApiKey)) + return true; + + if (!TryDecryptExistingApiKey(_paths, feed.ApiKey, out plaintext, out error)) + return false; + + return true; + } + + private ExternalSkillsConfig LoadExternalConfig() + => ConfigFileHelper.LoadSection(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath), "ExternalSkills"); + + // Guarded pre-save reads. Every mutation handler reads the current config (LoadJsonDict -> + // deserialize -> typed LoadSection) BEFORE handing the result to TryEditConfig for the write. + // That read sits outside TryEditConfig's guard, so a malformed / partially-written netclaw.json + // (JsonException) or a disk/permission error would escape into the Termina event loop on every + // add/toggle/rename/remove. Route those reads through these so a read failure surfaces via Status + // and the handler early-returns, exactly as the write path does. + private bool TryLoadExternalConfig(out ExternalSkillsConfig external) + { + try + { + external = LoadExternalConfig(); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + external = new ExternalSkillsConfig(); + SetStatus($"Could not read skill sources config: {ex.Message}", ConfigStatusTone.Error); + return false; + } + } + + private bool TryLoadSkillFeeds(out SkillFeedsConfigDocument feeds) + { + try + { + feeds = LoadSkillFeedsSection(ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath)); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + feeds = new SkillFeedsConfigDocument(); + SetStatus($"Could not read skill feeds config: {ex.Message}", ConfigStatusTone.Error); + return false; + } + } + + private bool SaveExternalConfig(ExternalSkillsConfig external) + => TryEditConfig(root => + { + if (external.Sources.Count == 0) + root.Remove("ExternalSkills"); + else + root["ExternalSkills"] = BuildExternalSkillsSection(external); + }); + + private bool SaveSkillFeedsConfig(SkillFeedsConfigDocument feeds) + => TryEditConfig(root => + { + if (feeds.Feeds.Count == 0) + root.Remove("SkillFeeds"); + else + root["SkillFeeds"] = BuildSkillFeedsSection(feeds); + }); + + // Reads, mutates, and writes the config root as one guarded unit, surfacing a disk-write IO + // failure (disk full, permission denied, path too long — PathTooLongException derives from + // IOException) OR a malformed existing netclaw.json (LoadJsonDict deserializes it, so a + // hand-edited file throws JsonException on the read) as an error status instead of letting it + // propagate into the Termina event loop and crash the page. The read previously sat outside the + // guard. Returns false on failure so the caller skips its success/navigation path. + private bool TryEditConfig(Action> mutate) + { + try + { + var root = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + root["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + mutate(root); + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, root); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + SetStatus($"Could not save skill sources config: {ex.Message}", ConfigStatusTone.Error); + return false; + } + } + + private ExternalSkillSource? FindLocalSource(ExternalSkillsConfig external, string name) + => external.Sources.FirstOrDefault(source => _nameComparer.Equals(source.Name, name)); + + private SkillFeedConfigEntry? FindRemoteSource(SkillFeedsConfigDocument feeds, string name) + => feeds.Feeds.FirstOrDefault(feed => _nameComparer.Equals(feed.Name, name)); + + private static bool IsTextEntryScreen(SkillSourcesScreen screen) + // AddLocalPath is intentionally excluded: it is an interactive directory picker, not a + // text field, so keystrokes/paste route to the picker rather than the draft. + => screen is SkillSourcesScreen.AddLocalName + or SkillSourcesScreen.AddRemoteUrl + or SkillSourcesScreen.AddRemoteToken + or SkillSourcesScreen.AddRemoteName + or SkillSourcesScreen.RenameSource + or SkillSourcesScreen.ChangeLocation; + + private static string FormatSourceLabel(SkillSourceDisplay source) + { + var enabled = source.Enabled ? "x" : " "; + return $"[{enabled}] {FormatDisplayName(source.Name)}"; + } + + private static string FormatSourceDetail(SkillSourceDisplay source) + { + if (source.Kind == SkillSourceKind.LocalFolder) + return $"{TruncateMiddle(source.Location, 58)} | {source.StatusText}"; + + var auth = source.HasApiKey ? "Token configured" : "No auth"; + return $"{TruncateMiddle(HostOrLocation(source.Location), 42)} | {auth}"; + } + + private static string FormatDisplayName(string value) + { + var parts = value + .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static part => !IsTopLevelDomainToken(part)) + .Select(static part => char.ToUpperInvariant(part[0]) + part[1..]) + .ToArray(); + + return parts.Length == 0 ? value : string.Join(' ', parts); + } + + private static bool IsTopLevelDomainToken(string value) + => value is "com" or "net" or "org" or "io" or "dev"; + + private static string HostOrLocation(string value) + { + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrWhiteSpace(uri.Host)) + return uri.Host; + + return value; + } + + private static string ResolveLocalDisplayPath(ExternalSkillSource source) + { + if (!string.IsNullOrWhiteSpace(source.Path)) + return source.Path; + + if (!string.IsNullOrWhiteSpace(source.WellKnown)) + return ExternalSkillsConfig.ResolveWellKnownPath(source.WellKnown) ?? source.WellKnown; + + return "(unresolved)"; + } + + private static bool TryNormalizeExternalDirectory(string value, out string? fullPath, out string error) + { + fullPath = null; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(value)) + { + error = "Local skill folder path is required."; + return false; + } + + if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !uri.IsFile) + { + error = "Local skill folder must be a local filesystem path, not a URL."; + return false; + } + + try + { + var expanded = PathExpansion.ExpandHome(value) ?? value; + fullPath = Path.GetFullPath(expanded); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + error = $"Local skill folder is not a valid path: {ex.Message}"; + return false; + } + + if (!Directory.Exists(fullPath)) + { + error = "Local skill folder must already exist so runtime skill scanning can consume it."; + return false; + } + + return true; + } + + private static bool TryNormalizeFeedUrl(string value, out string? url, out string error) + { + url = null; + error = string.Empty; + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https")) + { + error = "Skill server URL must be an absolute HTTP or HTTPS URI."; + return false; + } + + url = uri.ToString().TrimEnd('/'); + return true; + } + + private static bool TryValidateApiKeyDraft(string value, out string error) + { + error = string.Empty; + if (value.Contains('\r') || value.Contains('\n')) + { + error = "Skill server bearer token must be a single-line value."; + return false; + } + + return true; + } + + private static Dictionary BuildExternalSkillsSection(ExternalSkillsConfig config) + => new() + { + ["Sources"] = config.Sources.Select(static source => + { + var item = new Dictionary + { + ["Name"] = source.Name, + ["Enabled"] = source.Enabled, + ["AllowSymlinks"] = source.AllowSymlinks, + }; + + if (!string.IsNullOrWhiteSpace(source.WellKnown)) + item["WellKnown"] = source.WellKnown; + if (!string.IsNullOrWhiteSpace(source.Path)) + item["Path"] = source.Path; + + return (object)item; + }).ToArray(), + }; + + private static SkillFeedsConfigDocument LoadSkillFeedsSection(Dictionary root) + { + if (!root.TryGetValue("SkillFeeds", out var raw) || raw is null) + return new SkillFeedsConfigDocument(); + + var json = raw is JsonElement element + ? element.GetRawText() + : JsonSerializer.Serialize(raw, JsonDefaults.ConfigFile); + return JsonSerializer.Deserialize(json, JsonDefaults.ConfigRead) ?? new SkillFeedsConfigDocument(); + } + + private static bool TryDecryptExistingApiKey(NetclawPaths paths, string apiKey, out string? plaintext, out string error) + { + plaintext = null; + error = string.Empty; + + if (!ISecretsProtector.IsEncrypted(apiKey)) + { + plaintext = apiKey; + return true; + } + + try + { + plaintext = SecretsProtection.CreateProtector(paths).Unprotect(apiKey); + } + catch (Exception ex) when (ex is ArgumentException or System.Security.Cryptography.CryptographicException or FormatException) + { + error = $"Existing skill server token could not be decrypted: {ex.Message}"; + return false; + } + + return true; + } + + private static string ProtectApiKeyForConfig(NetclawPaths paths, string apiKey) + => SecretsProtection.CreateProtector(paths).Protect(apiKey); + + // Encrypt an API key, surfacing a DataProtection key-ring failure (unavailable/rotated keys throw + // CryptographicException; a missing/locked keys directory throws IOException) as an error status + // instead of letting it escape into the Termina event loop. The .Protect() call ran before + // TryEditConfig, so it was outside the write guard. + private bool TryProtectApiKey(string apiKey, out string? protectedApiKey) + { + try + { + protectedApiKey = ProtectApiKeyForConfig(_paths, apiKey); + return true; + } + catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException) + { + protectedApiKey = null; + SetStatus($"Could not encrypt the API key: {ex.Message}", ConfigStatusTone.Error); + return false; + } + } + + private static Dictionary BuildSkillFeedsSection(SkillFeedsConfigDocument config) + => new() + { + ["SyncIntervalMinutes"] = config.SyncIntervalMinutes, + ["Feeds"] = config.Feeds.Select(static feed => + { + var item = new Dictionary + { + ["Name"] = feed.Name, + ["Url"] = feed.Url, + ["Enabled"] = feed.Enabled, + ["TimeoutSeconds"] = feed.TimeoutSeconds, + }; + + if (!string.IsNullOrWhiteSpace(feed.ApiKey)) + item["ApiKey"] = feed.ApiKey; + + return (object)item; + }).ToArray(), + }; + + private static LocalSkillScanDisplay ScanLocalSkills(string directory, bool allowSymlinks) + { + try + { + var result = SkillScanner.Scan(directory, allowSymlinks, strictNameMatch: false); + if (result.Issues.Count == 0) + return new LocalSkillScanDisplay(result.AcceptedSkills.Count, null); + + var firstIssue = result.Issues[0]; + return new LocalSkillScanDisplay(result.AcceptedSkills.Count, $"{firstIssue.Kind}: {firstIssue.Message}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or DirectoryNotFoundException) + { + return new LocalSkillScanDisplay(0, ex.Message); + } + } + + private static string SuggestNameFromPath(string path) + { + var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var name = Path.GetFileName(trimmed); + return NormalizeSourceName(string.IsNullOrWhiteSpace(name) ? "local-skills" : name); + } + + private static string SuggestNameFromUrl(string url) + { + try + { + var uri = new Uri(url); + return NormalizeSourceName(uri.Host); + } + catch + { + return "custom-feed"; + } + } + + private string MakeUniqueName(string seed) + { + var baseName = NormalizeSourceName(seed); + if (!_sources.Any(source => _nameComparer.Equals(source.Name, baseName))) + return baseName; + + for (var i = 2; i < 100; i++) + { + var candidate = $"{baseName}-{i}"; + if (!_sources.Any(source => _nameComparer.Equals(source.Name, candidate))) + return candidate; + } + + return $"{baseName}-{Guid.NewGuid():N}"[..Math.Min(baseName.Length + 9, 32)]; + } + + private static string NormalizeSourceName(string value) + { + var chars = new List(value.Length); + var previousWasHyphen = false; + foreach (var c in value.Trim()) + { + if (char.IsLetterOrDigit(c)) + { + chars.Add(char.ToLowerInvariant(c)); + previousWasHyphen = false; + } + else if (!previousWasHyphen) + { + chars.Add('-'); + previousWasHyphen = true; + } + } + + var normalized = new string(chars.ToArray()).Trim('-'); + return string.IsNullOrWhiteSpace(normalized) ? "custom-source" : normalized; + } + + private static string TruncateMiddle(string value, int maxLength) + { + if (value.Length <= maxLength) + return value; + + var keep = (maxLength - 3) / 2; + return value[..keep] + "..." + value[^keep..]; + } + + private static string Check(bool value) => value ? "x" : " "; + + private static string Plural(int count) => count == 1 ? string.Empty : "s"; + + private sealed class SkillFeedsConfigDocument + { + public int SyncIntervalMinutes { get; set; } = 60; + + public List Feeds { get; set; } = []; + } + + private sealed class SkillFeedConfigEntry + { + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string? ApiKey { get; set; } + + public bool Enabled { get; set; } = true; + + public int TimeoutSeconds { get; set; } = DefaultFeedTimeoutSeconds; + } +} diff --git a/src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigPage.cs b/src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigPage.cs new file mode 100644 index 000000000..629072a1f --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigPage.cs @@ -0,0 +1,281 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class TelemetryAlertingConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _keyBindingsNode; + private readonly TextInputNode _pasteBuffer = new(); + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.TelemetryEnabled.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.OtlpEndpointDraft.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.Webhooks.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.Screen.Subscribe(_ => InvalidateAll()).DisposeWith(Subscriptions); + ViewModel.SelectedRow.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.FormFieldIndex.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.WebhookNameDraft.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.WebhookUrlDraft.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.WebhookAuthHeaderDraft.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Telemetry & Alerting", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => ViewModel.Screen.Value == TelemetryConfigScreen.WebhookForm + ? BuildWebhookForm() + : BuildList()); + + return _contentNode; + } + + private ILayoutNode BuildList() + { + var webhooks = ViewModel.Webhooks.Value; + var layout = Layouts.Vertical() + .WithChild(Header(" Telemetry & Alerting")) + .WithChild(Hint(" Configure OpenTelemetry export and outbound alert webhooks.")) + .WithChild(Hint(" Slack URLs use Slack format automatically. Delivery-policy tuning is parked.")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Row(0, $"Telemetry enabled [{Check(ViewModel.TelemetryEnabled.Value)}]")) + .WithChild(Row(1, $"OTLP endpoint {ViewModel.OtlpEndpointDraft.Value}")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(new TextNode(" Outbound Webhooks").WithForeground(Color.White).Bold()); + + if (webhooks.Count == 0) + layout = layout.WithChild(Hint(" No outbound webhooks configured yet.")); + + for (var i = 0; i < webhooks.Count; i++) + { + var row = webhooks[i]; + var rowIndex = TelemetryAlertingConfigViewModel.OtlpRowCount + i; + var auth = row.HasAuthHeader ? "auth" : "—"; + layout = layout.WithChild(Row( + rowIndex, + $"{row.Name,-16} {Truncate(row.Url, 40),-40} {row.Format,-8} {auth}")); + } + + layout = layout.WithChild(Row(ViewModel.AddRowIndex, "+ Add webhook")); + + return layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" {FocusedHelp()}")); + } + + private ILayoutNode BuildWebhookForm() + { + var format = ViewModel.DraftFormat; + var authState = ViewModel.EditingHasPersistedAuthHeader.Value && string.IsNullOrWhiteSpace(ViewModel.WebhookAuthHeaderDraft.Value) + ? "(stored header preserved — enter - to clear)" + : string.IsNullOrWhiteSpace(ViewModel.WebhookAuthHeaderDraft.Value) ? "(optional)" : "(new header entered)"; + + var title = ViewModel.EditingHasPersistedAuthHeader.Value || !string.IsNullOrWhiteSpace(ViewModel.WebhookNameDraft.Value) + ? $" Edit webhook: {DisplayName()}" + : " Add outbound webhook"; + + return Layouts.Vertical() + .WithChild(new TextNode(title).WithForeground(Color.White).Bold()) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(FormRow(0, "Name ", ViewModel.WebhookNameDraft.Value, "(optional)", masked: false)) + .WithChild(FormRow(1, "URL ", ViewModel.WebhookUrlDraft.Value, "e.g. https://hooks.slack.com/services/…", masked: false)) + .WithChild(FormRow(2, "Auth header ", ViewModel.WebhookAuthHeaderDraft.Value, authState, masked: true)) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" Format: {format} (auto-detected from URL)")) + .WithChild(Hint(" URL is required. Auth header is optional and stored masked.")); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + { + _keyBindingsNode = new DynamicLayoutNode(() => NetclawTuiChrome.BuildKeyHintLine( + ViewModel.Screen.Value == TelemetryConfigScreen.WebhookForm + ? " [↑/↓ or Tab] Fields [Type/Paste] Edit [Enter] Save [Esc] Back [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Space] Toggle [Enter] Edit/Add/Save [Delete] Remove [Type/Paste] Edit [Esc] Settings Areas [Ctrl+Q] Quit")); + + return _keyBindingsNode.Height(1); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + if (ViewModel.Screen.Value == TelemetryConfigScreen.WebhookForm) + { + HandleFormKey(keyInfo); + return; + } + + HandleListKey(keyInfo); + } + + private void HandleListKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + return; + case ConsoleKey.DownArrow: + ViewModel.MoveSelection(1); + return; + case ConsoleKey.Spacebar when ViewModel.SelectedRow.Value == 0: + ViewModel.ToggleTelemetry(); + return; + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + return; + case ConsoleKey.Delete: + ViewModel.RemoveSelectedWebhook(); + return; + case ConsoleKey.Backspace: + ViewModel.Backspace(); + return; + } + + if (!char.IsControl(keyInfo.KeyChar)) + ViewModel.AppendText(keyInfo.KeyChar.ToString()); + } + + private void HandleFormKey(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + ViewModel.MoveSelection(-1); + return; + case ConsoleKey.DownArrow: + case ConsoleKey.Tab: + ViewModel.MoveSelection(1); + return; + case ConsoleKey.Enter: + ViewModel.ActivateSelected(); + return; + case ConsoleKey.Backspace: + ViewModel.Backspace(); + return; + } + + if (!char.IsControl(keyInfo.KeyChar)) + ViewModel.AppendText(keyInfo.KeyChar.ToString()); + } + + private void HandlePaste(PasteEvent paste) + { + _pasteBuffer.Text = string.Empty; + _pasteBuffer.HandlePaste(paste); + ViewModel.AppendText(_pasteBuffer.Text); + } + + private ILayoutNode Row(int index, string label) + => ConfigSelectionRow.Create($" {label}", index == ViewModel.SelectedRow.Value); + + private ILayoutNode FormRow(int index, string label, string draft, string placeholder, bool masked) + { + var isPlaceholder = string.IsNullOrEmpty(draft); + var value = DisplayField(draft, placeholder, masked); + // Placeholder/example text renders dim (hint gray) so it never reads as an + // entered value; a real (or masked) value renders bright white. + var valueColor = isPlaceholder ? Color.Gray : Color.White; + return ConfigSelectionRow.CreateLabeled($" {label} ", value, index == ViewModel.FormFieldIndex.Value, valueColor); + } + + private string FocusedHelp() + { + var row = ViewModel.SelectedRow.Value; + if (row == 0) + return "Toggle daemon OTLP logs and metrics export."; + if (row == 1) + return "gRPC OTLP collector endpoint, usually port 4317."; + if (row == ViewModel.AddRowIndex) + return "Add a new outbound alert target."; + if (ViewModel.IsWebhookRow(row)) + { + var webhook = ViewModel.Webhooks.Value[ViewModel.WebhookIndexFor(row)]; + return $"{webhook.Format} format · {(webhook.HasAuthHeader ? "auth header set" : "no auth header")} · Enter to edit, Delete to remove."; + } + + return string.Empty; + } + + private string DisplayName() + => string.IsNullOrWhiteSpace(ViewModel.WebhookNameDraft.Value) ? "(unnamed)" : ViewModel.WebhookNameDraft.Value; + + private static string DisplayField(string value, string placeholder, bool masked) + { + if (string.IsNullOrEmpty(value)) + return placeholder; + return masked ? new string('•', Math.Min(value.Length, 24)) : value; + } + + private static string Truncate(string value, int width) + => value.Length <= width ? value : string.Concat(value.AsSpan(0, Math.Max(0, width - 1)), "…"); + + private static string Check(bool value) => value ? "x" : " "; + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.Gray); + + private void InvalidateAll() + { + _contentNode?.Invalidate(); + _keyBindingsNode?.Invalidate(); + } + + private static Color ToColor(ConfigStatusTone tone) + => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.Gray + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs new file mode 100644 index 000000000..091a3cdb2 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/TelemetryAlertingConfigViewModel.cs @@ -0,0 +1,582 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Cli.Json; +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +/// +/// Which sub-screen the Telemetry & Alerting editor is showing. +/// +internal enum TelemetryConfigScreen +{ + /// OTLP toggle/endpoint rows plus the outbound-webhook list. + List, + + /// The add/edit form for a single outbound webhook. + WebhookForm +} + +/// +/// A read-model row for one configured outbound webhook in the list editor. +/// +internal sealed record TelemetryWebhookRow(string Name, string Url, WebhookFormat Format, bool HasAuthHeader); + +/// +/// Telemetry & Alerting editor. Keeps the OTLP enable/endpoint rows and exposes +/// as a multi-entry list editor (the +/// earlier revision surfaced only a single webhook). Each webhook carries a name, +/// URL, and one optional Authorization-style header (masked); the payload format +/// is auto-detected from the URL and shown read-only. Delivery policy +/// (dedup/retries/timeout) is intentionally out of scope and preserved untouched. +/// +internal sealed class TelemetryAlertingConfigViewModel : ReactiveViewModel +{ + private const string DefaultOtlpEndpoint = "http://127.0.0.1:4317"; + + private readonly NetclawPaths _paths; + private string _acceptedOtlpEndpoint; + private int? _editingWebhookIndex; + + public TelemetryAlertingConfigViewModel(NetclawPaths paths) + { + _paths = paths; + // Degrade to default telemetry state on a malformed/unreadable netclaw.json rather than + // throwing from the constructor (which would make the Telemetry page permanently inaccessible). + string? loadError = null; + (bool TelemetryEnabled, string OtlpEndpoint, IReadOnlyList Webhooks) state; + try + { + state = LoadState(paths); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + state = (false, DefaultOtlpEndpoint, []); + loadError = $"Could not read netclaw.json: {ex.Message}"; + } + TelemetryEnabled = new ReactiveProperty(state.TelemetryEnabled); + OtlpEndpointDraft = new ReactiveProperty(state.OtlpEndpoint); + _acceptedOtlpEndpoint = state.OtlpEndpoint; + Webhooks = new ReactiveProperty>(state.Webhooks); + Screen = new ReactiveProperty(TelemetryConfigScreen.List); + SelectedRow = new ReactiveProperty(0); + FormFieldIndex = new ReactiveProperty(0); + WebhookNameDraft = new ReactiveProperty(string.Empty); + WebhookUrlDraft = new ReactiveProperty(string.Empty); + WebhookAuthHeaderDraft = new ReactiveProperty(string.Empty); + EditingHasPersistedAuthHeader = new ReactiveProperty(false); + Status = new ReactiveProperty(loadError is null + ? new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral) + : new ConfigStatusMessage(loadError, ConfigStatusTone.Error)); + IsSaved = new ReactiveProperty(false); + } + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ReactiveProperty TelemetryEnabled { get; } + public ReactiveProperty OtlpEndpointDraft { get; } + public ReactiveProperty> Webhooks { get; } + public ReactiveProperty Screen { get; } + public ReactiveProperty SelectedRow { get; } + public ReactiveProperty FormFieldIndex { get; } + public ReactiveProperty WebhookNameDraft { get; } + public ReactiveProperty WebhookUrlDraft { get; } + public ReactiveProperty WebhookAuthHeaderDraft { get; } + public ReactiveProperty EditingHasPersistedAuthHeader { get; } + public ReactiveProperty Status { get; } + public ReactiveProperty IsSaved { get; } + + // List layout: 2 OTLP rows + one row per webhook + an "Add webhook" row. + public const int OtlpRowCount = 2; + public int WebhookCount => Webhooks.Value.Count; + public int AddRowIndex => OtlpRowCount + WebhookCount; + public int ListRowCount => AddRowIndex + 1; + + public bool IsWebhookRow(int index) => index >= OtlpRowCount && index < AddRowIndex; + public int WebhookIndexFor(int row) => row - OtlpRowCount; + + public static readonly IReadOnlyList FormFields = ["Name", "URL", "Auth header"]; + + public void MoveSelection(int delta) + { + if (Screen.Value == TelemetryConfigScreen.WebhookForm) + { + FormFieldIndex.Value = (FormFieldIndex.Value + delta + FormFields.Count) % FormFields.Count; + return; + } + + var next = Math.Clamp(SelectedRow.Value + delta, 0, ListRowCount - 1); + if (next != SelectedRow.Value) + SelectedRow.Value = next; + } + + /// Toggles telemetry from the OTLP-enabled row and autosaves. + public bool ToggleTelemetry() + { + var previous = TelemetryEnabled.Value; + TelemetryEnabled.Value = !TelemetryEnabled.Value; + if (AutosaveCompletedAction("Telemetry enabled state saved.")) + return true; + + TelemetryEnabled.Value = previous; + IsSaved.Value = false; + RequestRedraw(); + return false; + } + + public void AppendText(string text) + { + if (Screen.Value == TelemetryConfigScreen.WebhookForm) + { + FormFieldDraft.Value += text; + MarkDirty(); + return; + } + + if (SelectedRow.Value == 1) + { + if (OtlpEndpointDraft.Value == _acceptedOtlpEndpoint) + OtlpEndpointDraft.Value = string.Empty; + + OtlpEndpointDraft.Value += text; + MarkDirty(); + } + } + + public void Backspace() + { + if (Screen.Value == TelemetryConfigScreen.WebhookForm) + { + var draft = FormFieldDraft; + if (draft.Value.Length > 0) + { + draft.Value = draft.Value[..^1]; + MarkDirty(); + } + + return; + } + + if (SelectedRow.Value == 1 && OtlpEndpointDraft.Value.Length > 0) + { + OtlpEndpointDraft.Value = OtlpEndpointDraft.Value[..^1]; + MarkDirty(); + } + } + + private ReactiveProperty FormFieldDraft => FormFieldIndex.Value switch + { + 0 => WebhookNameDraft, + 1 => WebhookUrlDraft, + _ => WebhookAuthHeaderDraft + }; + + /// Format auto-detected from the in-progress URL draft (read-only). + public WebhookFormat DraftFormat => WebhookFormatDetection.InferFromUrl(WebhookUrlDraft.Value); + + public void ActivateSelected() + { + if (Screen.Value == TelemetryConfigScreen.WebhookForm) + { + SaveWebhookForm(); + return; + } + + switch (SelectedRow.Value) + { + case 0: + ToggleTelemetry(); + break; + case 1: + Save(); + break; + default: + if (SelectedRow.Value == AddRowIndex) + BeginAddWebhook(); + else if (IsWebhookRow(SelectedRow.Value)) + BeginEditWebhook(WebhookIndexFor(SelectedRow.Value)); + break; + } + } + + public void BeginAddWebhook() + { + _editingWebhookIndex = null; + WebhookNameDraft.Value = string.Empty; + WebhookUrlDraft.Value = string.Empty; + WebhookAuthHeaderDraft.Value = string.Empty; + EditingHasPersistedAuthHeader.Value = false; + FormFieldIndex.Value = 0; + ClearStatus(); + Screen.Value = TelemetryConfigScreen.WebhookForm; + RequestRedraw(); + } + + public void BeginEditWebhook(int index) + { + var rows = Webhooks.Value; + if (index < 0 || index >= rows.Count) + return; + + var row = rows[index]; + _editingWebhookIndex = index; + WebhookNameDraft.Value = row.Name; + WebhookUrlDraft.Value = row.Url; + WebhookAuthHeaderDraft.Value = string.Empty; + EditingHasPersistedAuthHeader.Value = row.HasAuthHeader; + FormFieldIndex.Value = 0; + ClearStatus(); + Screen.Value = TelemetryConfigScreen.WebhookForm; + RequestRedraw(); + } + + public void RemoveSelectedWebhook() + { + if (!IsWebhookRow(SelectedRow.Value)) + return; + + var index = WebhookIndexFor(SelectedRow.Value); + var removedName = Webhooks.Value[index].Name; + if (PersistWebhooks(webhooks => webhooks.RemoveAt(index), $"Removed {removedName}. Saved.")) + SelectedRow.Value = Math.Clamp(SelectedRow.Value, 0, ListRowCount - 1); + } + + public void CancelWebhookForm() + { + Screen.Value = TelemetryConfigScreen.List; + ClearStatus(); + RequestRedraw(); + } + + private void SaveWebhookForm() + { + var url = WebhookUrlDraft.Value.Trim(); + if (string.IsNullOrWhiteSpace(url)) + { + Status.Value = new ConfigStatusMessage("Outbound webhook URL is required.", ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + if (!TryValidateHttpUri(url, "Outbound webhook URL", out var normalizedUrl, out var urlError)) + { + Status.Value = new ConfigStatusMessage(urlError, ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + var authDraft = WebhookAuthHeaderDraft.Value.Trim(); + // A single "-" explicitly clears a persisted auth header; a blank field preserves it. Without + // this gesture there is no way to remove a header once set (blank always means "keep"). + var clearAuth = authDraft == "-"; + string? headerName = null; + string? headerValue = null; + if (!clearAuth + && !string.IsNullOrWhiteSpace(authDraft) + && !TryParseHeader(authDraft, out headerName, out headerValue, out var headerError)) + { + Status.Value = new ConfigStatusMessage(headerError, ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + var name = string.IsNullOrWhiteSpace(WebhookNameDraft.Value) + ? $"{WebhookFormatDetection.InferFromUrl(normalizedUrl!).ToString().ToLowerInvariant()}-webhook" + : WebhookNameDraft.Value.Trim(); + + var editing = _editingWebhookIndex; + var newAuth = !clearAuth && !string.IsNullOrWhiteSpace(authDraft); + var verb = editing is null ? "added" : "updated"; + var saved = PersistWebhooks(webhooks => + { + var target = editing is { } index && index < webhooks.Count + ? webhooks[index] + : new WebhookTarget(); + + target.Name = name; + target.Url = normalizedUrl!; + target.Format = WebhookFormatDetection.InferFromUrl(normalizedUrl!); + if (newAuth) + { + target.Headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [headerName!] = headerValue! + }; + } + else if (clearAuth) + { + target.Headers = null; + } + + // Otherwise (blank, no "-"): leave target.Headers untouched so an unedited header is kept. + + if (editing is null) + webhooks.Add(target); + }, $"Webhook {name} {verb}. Saved."); + + if (saved) + { + Screen.Value = TelemetryConfigScreen.List; + SelectedRow.Value = editing is { } idx + ? OtlpRowCount + idx + : OtlpRowCount + Math.Max(0, WebhookCount - 1); + } + } + + public bool Save() + => Save("Telemetry & Alerting settings saved."); + + private bool Save(string successMessage) + { + var endpoint = string.IsNullOrWhiteSpace(OtlpEndpointDraft.Value) + ? DefaultOtlpEndpoint + : OtlpEndpointDraft.Value.Trim(); + if (!TryValidateHttpUri(endpoint, "OTLP endpoint", out var normalizedEndpoint, out var endpointError)) + { + Status.Value = new ConfigStatusMessage(endpointError, ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + return ConfigAutosave.Run( + () => + { + var root = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + root["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + root["Telemetry"] = new Dictionary + { + ["Enabled"] = TelemetryEnabled.Value, + ["Otlp"] = new Dictionary + { + ["Endpoint"] = normalizedEndpoint! + } + }; + + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, root); + ReloadState(successMessage, resetOtlpDraft: true); + return true; + }, + Status, + "Telemetry & Alerting save failed", + RequestRedraw); + } + + /// + /// Mutates the persisted list through + /// the same section-preserving writer the rest of the editor uses, leaving the + /// delivery policy and unrelated sections untouched. + /// + private bool PersistWebhooks(Action> mutate, string successMessage) + => ConfigAutosave.Run( + () => + { + var root = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + root["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + var notifications = ConfigFileHelper.LoadSection(root, "Notifications"); + mutate(notifications.Webhooks); + + if (notifications.Webhooks.Count > 0 + || root.ContainsKey("Notifications")) + { + root["Notifications"] = BuildNotificationsSection(notifications); + } + + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, root); + ReloadState(successMessage, resetOtlpDraft: false); + return true; + }, + Status, + "Telemetry & Alerting autosave failed", + RequestRedraw); + + private void ReloadState(string successMessage, bool resetOtlpDraft) + { + var state = LoadState(_paths); + TelemetryEnabled.Value = state.TelemetryEnabled; + _acceptedOtlpEndpoint = state.OtlpEndpoint; + Webhooks.Value = state.Webhooks; + + if (resetOtlpDraft) + { + // The OTLP endpoint was just persisted: sync the draft to it and mark fully saved. + OtlpEndpointDraft.Value = state.OtlpEndpoint; + IsSaved.Value = true; + } + else + { + // A different section (a webhook) was saved. Preserve any in-progress OTLP endpoint edit + // and report fully-saved only when that draft is not dirty — never discard the edit or + // falsely flip IsSaved=true over it. + IsSaved.Value = OtlpEndpointDraft.Value == state.OtlpEndpoint; + } + + Status.Value = new ConfigStatusMessage(successMessage, ConfigStatusTone.Success); + RequestRedraw(); + } + + private bool AutosaveCompletedAction(string successMessage) + => ConfigAutosave.Run( + () => Save(successMessage), + Status, + "Telemetry & Alerting autosave failed", + RequestRedraw); + + public void GoBack() + { + if (Screen.Value == TelemetryConfigScreen.WebhookForm) + { + CancelWebhookForm(); + return; + } + + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + TelemetryEnabled.Dispose(); + OtlpEndpointDraft.Dispose(); + Webhooks.Dispose(); + Screen.Dispose(); + SelectedRow.Dispose(); + FormFieldIndex.Dispose(); + WebhookNameDraft.Dispose(); + WebhookUrlDraft.Dispose(); + WebhookAuthHeaderDraft.Dispose(); + EditingHasPersistedAuthHeader.Dispose(); + Status.Dispose(); + IsSaved.Dispose(); + base.Dispose(); + } + + private void MarkDirty() + { + IsSaved.Value = false; + ClearStatus(); + RequestRedraw(); + } + + private void ClearStatus() + { + if (!string.IsNullOrWhiteSpace(Status.Value.Text)) + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } + + private static bool TryValidateHttpUri(string value, string label, out string? normalized, out string error) + { + normalized = null; + error = string.Empty; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) + || uri.Scheme is not ("http" or "https")) + { + error = $"{label} must be an absolute HTTP or HTTPS URI."; + return false; + } + + normalized = uri.ToString().TrimEnd('/'); + return true; + } + + private static bool TryParseHeader(string value, out string? name, out string? headerValue, out string error) + { + name = null; + headerValue = null; + error = string.Empty; + if (value.Contains('\r') || value.Contains('\n')) + { + error = "Outbound webhook auth header must be a single line."; + return false; + } + + var separator = value.IndexOf(':', StringComparison.Ordinal); + if (separator <= 0 || separator == value.Length - 1) + { + error = "Outbound webhook auth header must use 'Header-Name: value' format."; + return false; + } + + name = value[..separator].Trim(); + headerValue = value[(separator + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(headerValue)) + { + error = "Outbound webhook auth header name and value are required."; + return false; + } + + return true; + } + + private static (bool TelemetryEnabled, string OtlpEndpoint, IReadOnlyList Webhooks) LoadState(NetclawPaths paths) + { + var root = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + var telemetry = LoadRawSection(root, "Telemetry"); + var enabled = ConfigFileHelper.TryGetPathValue(telemetry, "Enabled", out var enabledValue) + && enabledValue is bool enabledFlag + && enabledFlag; + var endpoint = ConfigFileHelper.TryGetPathValue(telemetry, "Otlp.Endpoint", out var endpointValue) + && endpointValue is string endpointText + && !string.IsNullOrWhiteSpace(endpointText) + ? endpointText + : DefaultOtlpEndpoint; + + var notifications = ConfigFileHelper.LoadSection(root, "Notifications"); + var rows = notifications.Webhooks + .Select(static webhook => new TelemetryWebhookRow( + string.IsNullOrWhiteSpace(webhook.Name) ? "(unnamed)" : webhook.Name, + webhook.Url, + webhook.Format, + webhook.Headers is { Count: > 0 })) + .ToArray(); + + return (enabled, endpoint, rows); + } + + private static Dictionary LoadRawSection(Dictionary root, string sectionName) + { + if (!root.TryGetValue(sectionName, out var raw) || raw is null) + return []; + + if (raw is JsonElement element) + return JsonSerializer.Deserialize>(element.GetRawText(), JsonDefaults.ConfigRead) ?? []; + + return raw as Dictionary ?? []; + } + + private static Dictionary BuildNotificationsSection(NotificationsConfig config) + => new() + { + ["DeduplicationWindowSeconds"] = config.DeduplicationWindowSeconds, + ["MaxRetries"] = config.MaxRetries, + ["TimeoutSeconds"] = config.TimeoutSeconds, + ["Webhooks"] = config.Webhooks.Select(static webhook => + { + var item = new Dictionary + { + ["Url"] = webhook.Url, + ["Format"] = webhook.Format.ToString() + }; + + if (!string.IsNullOrWhiteSpace(webhook.Name)) + item["Name"] = webhook.Name; + if (webhook.Headers is { Count: > 0 }) + item["Headers"] = webhook.Headers; + + return (object)item; + }).ToArray() + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/WorkspacesConfigPage.cs b/src/Netclaw.Cli/Tui/Config/WorkspacesConfigPage.cs new file mode 100644 index 000000000..a8a6df395 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/WorkspacesConfigPage.cs @@ -0,0 +1,204 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class WorkspacesConfigPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + // The picker is the screen (no Tab gate). Created once and reused so it keeps its navigation + // state across renders; rebuilding it every frame would snap it back to the start path. + private FilePickerNode? _directoryPicker; + private readonly CompositeDisposable _pickerSubscriptions = []; + // Inline "new folder" naming overlay — the picker itself cannot create directories. + private bool _namingNewFolder; + private string _newFolderParent = string.Empty; + private TextInputNode? _newFolderInput; + + protected override void OnBound() + { + base.OnBound(); + _pickerSubscriptions.DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.CurrentDirectory.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + ViewModel.IsSaved.Subscribe(_ => _contentNode?.Invalidate()).DisposeWith(Subscriptions); + + EnsurePicker(); + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Workspaces Directory", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithChild(BuildContent().Fill()) + .WithChild(BuildStatusBar()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + if (_namingNewFolder && _newFolderInput is not null) + { + return Layouts.Vertical() + .WithChild(Header(" New folder")) + .WithChild(Hint($" Created inside: {_newFolderParent}")) + .WithChild(Hint(" [Enter] create [Esc] cancel [Ctrl+Q] quit")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(NetclawTuiChrome.BuildTextInputPanel(_newFolderInput, "Folder name")); + } + + if (_directoryPicker is null) + return Layouts.Empty(); + + // Only the picker renders a key-hint footer (Termina draws it and it can't be turned + // off); the app-specific keys live up here so there is a single strip, not two. + return Layouts.Vertical() + .WithChild(Header(" Choose the workspaces directory")) + .WithChild(Hint($" Current: {ViewModel.CurrentDirectory.Value}")) + .WithChild(Hint(" [Ctrl+N] new folder [Ctrl+Q] quit")) + .WithChild(Layouts.Empty().Height(1)) + .WithChild(_directoryPicker); + }); + + return _contentNode; + } + + private LayoutNode BuildStatusBar() + => ViewModel.Status + .Select(status => string.IsNullOrWhiteSpace(status.Text) + ? Layouts.Empty() + : NetclawTuiChrome.BuildStatusLine(status.Text, ToColor(status.Tone))) + .AsLayout() + .Height(1); + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (_namingNewFolder) + { + HandleNewFolderKey(keyInfo); + return; + } + + if (keyInfo.Key == ConsoleKey.N && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + BeginNewFolder(); + return; + } + + // The picker owns every other key: arrows, Enter (open folder), Space (choose), + // Backspace (up), Esc (cancel -> GoBack). Its events drive selection + exit. + _directoryPicker?.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + } + + private void HandleNewFolderKey(ConsoleKeyInfo keyInfo) + { + if (_newFolderInput is null) + return; + + switch (keyInfo.Key) + { + case ConsoleKey.Escape: + EndNewFolder(); + return; + case ConsoleKey.Enter: + // On success the folder is created + saved; on failure a status error shows. Either + // way re-create the picker so a newly created folder actually shows up, then leave + // naming so the operator sees the result against the refreshed picker. + ViewModel.CreateAndSelectFolder(_newFolderParent, _newFolderInput.Text); + RecreatePickerAt(_newFolderParent); + EndNewFolder(); + return; + default: + _newFolderInput.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + return; + } + } + + private void HandlePaste(PasteEvent paste) + { + if (_namingNewFolder && _newFolderInput is not null) + { + _newFolderInput.HandlePaste(paste); + ViewModel.RequestRedraw(); + } + } + + private void EnsurePicker() + { + if (_directoryPicker is null) + RecreatePickerAt(ViewModel.BrowseStartPath); + } + + // (Re)creates the picker rooted at a directory. Used on first show and to refresh the listing + // after a new folder is created (FilePickerNode has no public reload). WithFillHeight paints + // the full content area so it does not leave stale cells from earlier frames. + private void RecreatePickerAt(string path) + { + _pickerSubscriptions.Clear(); + _directoryPicker = DirectoryPickerFactory.Build( + path, + ViewModel.FileSystemProvider, + _pickerSubscriptions, + ViewModel.ApplyPickedDirectory, + ViewModel.GoBack); + } + + private void BeginNewFolder() + { + _newFolderParent = _directoryPicker?.CurrentPath ?? ViewModel.BrowseStartPath; + _newFolderInput = new TextInputNode().WithPlaceholder("my-workspace"); + _newFolderInput.OnFocused(); + _namingNewFolder = true; + InvalidateAll(); + } + + private void EndNewFolder() + { + _namingNewFolder = false; + _newFolderInput = null; + InvalidateAll(); + } + + private void InvalidateAll() + { + _contentNode?.Invalidate(); + } + + private static TextNode Header(string text) => new TextNode(text).WithForeground(Color.White).Bold(); + private static TextNode Hint(string text) => new TextNode(text).WithForeground(Color.Gray); + + private static Color ToColor(ConfigStatusTone tone) + => tone switch + { + ConfigStatusTone.Success => Color.Green, + ConfigStatusTone.Warning => Color.Yellow, + ConfigStatusTone.Error => Color.Red, + _ => Color.Gray + }; +} diff --git a/src/Netclaw.Cli/Tui/Config/WorkspacesConfigViewModel.cs b/src/Netclaw.Cli/Tui/Config/WorkspacesConfigViewModel.cs new file mode 100644 index 000000000..8a978b6c9 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Config/WorkspacesConfigViewModel.cs @@ -0,0 +1,250 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using R3; +using Termina.Layout; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui.Config; + +internal sealed class WorkspacesConfigViewModel : ReactiveViewModel +{ + private readonly NetclawPaths _paths; + + public WorkspacesConfigViewModel(NetclawPaths paths, IFileSystemProvider? fileSystemProvider = null) + { + _paths = paths; + FileSystemProvider = fileSystemProvider ?? new DefaultFileSystemProvider(); + // Degrade to no current directory on a malformed/unreadable netclaw.json rather than throwing + // from the constructor (which would make the Workspaces page permanently inaccessible). + string? loadError = null; + string currentDirectory; + try + { + currentDirectory = LoadCurrentDirectory(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + currentDirectory = string.Empty; + loadError = $"Could not read netclaw.json: {ex.Message}"; + } + CurrentDirectory = new ReactiveProperty(currentDirectory); + DirectoryDraft = new ReactiveProperty(string.Empty); + Status = new ReactiveProperty(loadError is null + ? new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral) + : new ConfigStatusMessage(loadError, ConfigStatusTone.Error)); + IsSaved = new ReactiveProperty(false); + } + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public IFileSystemProvider FileSystemProvider { get; } + + public ReactiveProperty CurrentDirectory { get; } + public ReactiveProperty DirectoryDraft { get; } + public ReactiveProperty Status { get; } + public ReactiveProperty IsSaved { get; } + + /// + /// Directory the picker opens at. Prefers the current workspaces directory when it exists + /// (you are most likely re-pointing near it); otherwise the launch working directory. The + /// picker can navigate up to the filesystem root and back down, so this is only an anchor. + /// + public string BrowseStartPath + { + get + { + var current = CurrentDirectory.Value; + if (!string.IsNullOrWhiteSpace(current)) + { + if (FileSystemProvider.DirectoryExists(current)) + return current; + + // The configured dir does not exist yet (e.g. never created, or removed): open at + // its parent so you stay in the right neighborhood rather than the process working + // directory (which can be the binary's location). + var parent = FileSystemProvider.GetParentDirectory(current); + if (!string.IsNullOrWhiteSpace(parent) && FileSystemProvider.DirectoryExists(parent)) + return parent; + } + + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + } + + /// + /// Creates under and selects it. The + /// inline "new folder" affordance the picker lacks; performs the actual + /// directory creation and persistence. + /// + public void CreateAndSelectFolder(string parentPath, string name) + { + var trimmed = name.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + Status.Value = new ConfigStatusMessage("Enter a valid folder name (no path separators).", ConfigStatusTone.Error); + RequestRedraw(); + return; + } + + ApplyPickedDirectory(Path.Combine(parentPath, trimmed)); + } + + /// + /// Applies a directory chosen in the picker: stages it as the draft and saves immediately + /// (picking an existing directory is itself the confirmation). The picker stays open with the + /// new value reflected as Current. + /// + public void ApplyPickedDirectory(string path) + { + DirectoryDraft.Value = path; + IsSaved.Value = false; + Save(); + } + + public string CandidateDirectory => string.IsNullOrWhiteSpace(DirectoryDraft.Value) + ? CurrentDirectory.Value + : DirectoryDraft.Value; + + public void AppendText(string text) + { + DirectoryDraft.Value += text; + IsSaved.Value = false; + ClearStatus(); + RequestRedraw(); + } + + public void Backspace() + { + if (DirectoryDraft.Value.Length == 0) + return; + + DirectoryDraft.Value = DirectoryDraft.Value[..^1]; + IsSaved.Value = false; + ClearStatus(); + RequestRedraw(); + } + + public bool Save() + { + if (!TryNormalizeLocalDirectory(CandidateDirectory, out var fullPath, out var error)) + { + Status.Value = new ConfigStatusMessage(error, ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + try + { + if (File.Exists(fullPath) && !Directory.Exists(fullPath)) + { + Status.Value = new ConfigStatusMessage("Workspaces Directory must be a directory, not a file.", ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + Directory.CreateDirectory(fullPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + Status.Value = new ConfigStatusMessage($"Workspaces Directory could not be created: {ex.Message}", ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + try + { + // Read + modify + write as one guarded unit: LoadJsonDict deserializes netclaw.json, so a + // malformed (hand-edited) config throws JsonException on the read — which sat outside the + // guard and propagated into the Termina event loop. + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + ConfigFileHelper.SetPathValue(config, "Workspaces.Directory", fullPath); + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Status.Value = new ConfigStatusMessage($"Workspaces Directory could not be saved: {ex.Message}", ConfigStatusTone.Error); + RequestRedraw(); + return false; + } + + CurrentDirectory.Value = fullPath; + DirectoryDraft.Value = string.Empty; + IsSaved.Value = true; + Status.Value = new ConfigStatusMessage("Workspaces Directory saved.", ConfigStatusTone.Success); + RequestRedraw(); + return true; + } + + public void GoBack() + { + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + + public void RequestQuit() + { + ShutdownRequestedForTest = true; + Shutdown(); + } + + public override void Dispose() + { + CurrentDirectory.Dispose(); + DirectoryDraft.Dispose(); + Status.Dispose(); + IsSaved.Dispose(); + base.Dispose(); + } + + private string LoadCurrentDirectory() + { + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return ConfigFileHelper.TryGetPathValue(config, "Workspaces.Directory", out var value) + ? new NetclawPaths(_paths.BasePath, value?.ToString()).WorkspacesDirectory + : _paths.WorkspacesDirectory; + } + + private void ClearStatus() + { + if (!string.IsNullOrWhiteSpace(Status.Value.Text)) + Status.Value = new ConfigStatusMessage(string.Empty, ConfigStatusTone.Neutral); + } + + private static bool TryNormalizeLocalDirectory(string value, out string fullPath, out string error) + { + fullPath = string.Empty; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(value)) + { + error = "Workspaces Directory is required."; + return false; + } + + var trimmed = value.Trim(); + if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && !uri.IsFile) + { + error = "Workspaces Directory must be a local filesystem path, not a URL."; + return false; + } + + try + { + fullPath = Path.GetFullPath(PathExpansion.ExpandHome(trimmed) ?? trimmed); + return true; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + error = $"Workspaces Directory is not a valid local path: {ex.Message}"; + return false; + } + } +} diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs new file mode 100644 index 000000000..381d535fc --- /dev/null +++ b/src/Netclaw.Cli/Tui/ConfigDashboardPage.cs @@ -0,0 +1,143 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +public sealed class ConfigDashboardPage : ReactivePage +{ + private SelectionListNode? _entryList; + + protected override void OnBound() + { + base.OnBound(); + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + { + return NetclawTuiChrome.BuildPageFrame("Netclaw Config", BuildInnerLayout()); + } + + private DynamicLayoutNode? _helpLineNode; + + private ILayoutNode BuildInnerLayout() + { + return Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildList()) + .WithChild(BuildHelpLine()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + } + + private ILayoutNode BuildList() + { + // Status-summary column: "Label ". Terminal rows (Doctor / + // Quit) carry no status and render as the bare label. + var rows = ViewModel.Items + .Select(item => + { + var status = ViewModel.StatusFor(item); + return string.IsNullOrEmpty(status) + ? item.Label + : $"{item.Label,-22} {status}"; + }) + .ToList(); + + _entryList = Layouts.SelectionList(rows) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Cyan); + + _entryList.OnFocused(); + _entryList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count == 0) + return; + + var index = rows.IndexOf(selected[0]); + if (index >= 0) + { + ViewModel.SelectedIndex.Value = index; + ViewModel.ActivateSelected(); + } + }) + .DisposeWith(Subscriptions); + + _entryList.Invalidated + .Subscribe(_ => + { + var highlighted = _entryList.HighlightedItem; + if (highlighted is not null) + { + var index = rows.IndexOf(highlighted.Value); + if (index >= 0) + ViewModel.SelectedIndex.Value = index; + } + + _helpLineNode?.Invalidate(); + }) + .DisposeWith(Subscriptions); + + return Layouts.Vertical() + .WithChild(new TextNode(" Settings Areas").WithForeground(Color.White).Bold()) + .WithChild(_entryList); + } + + // The focused item's description rendered as a dim help line below the list. + private LayoutNode BuildHelpLine() + { + _helpLineNode = new DynamicLayoutNode(() => + { + var index = Math.Clamp(ViewModel.SelectedIndex.Value, 0, ViewModel.Items.Count - 1); + return (ILayoutNode)new TextNode($" {ViewModel.Items[index].Description}").WithForeground(Color.BrightBlack); + }); + + return _helpLineNode.Height(1); + } + + private LayoutNode BuildStatusBar() + { + return ViewModel.StatusMessage + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + .AsLayout() + .Height(1); + } + + private LayoutNode BuildKeyBindings() + { + return NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Select [Esc] Quit [Ctrl+Q] Quit"); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.RequestQuit(); + return; + } + + _entryList?.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + } +} diff --git a/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs new file mode 100644 index 000000000..fd0c0a7ff --- /dev/null +++ b/src/Netclaw.Cli/Tui/ConfigDashboardViewModel.cs @@ -0,0 +1,337 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Actors.Channels; +using Netclaw.Cli.Config; +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui; + +public enum ConfigDashboardAction +{ + None, + RunDoctor, +} + +public sealed class ConfigDashboardNavigationState +{ + public ConfigDashboardAction PendingAction { get; set; } +} + +/// +/// Marker service registered only by the embedded netclaw config host. Its presence in DI +/// tells the routed Provider/Model managers they were reached from the config dashboard, so backing +/// out past their root navigates back to the dashboard instead of exiting the process. The +/// standalone netclaw provider/netclaw model hosts do not register it, leaving those +/// managers in their default "exit on back-out" behavior. +/// +public sealed class EmbeddedConfigHostMarker +{ +} + +public sealed record ConfigDashboardItem(string Label, string Description, string? Route = null, bool IsTerminal = false); + +/// +/// Root dashboard for netclaw config. Provider and model management are +/// routed into their dedicated TUIs; the remaining areas are scaffolded as +/// domain-oriented entries so config no longer lands on a stub. +/// +/// +/// Each row carries a live status summary computed from the current config on +/// disk (e.g. Search ✓ Brave, Security & Access Team · 4/6 +/// enabled). Statuses are read fresh whenever they are requested so edits +/// made in the sub-editors are reflected on return (autosave reentrancy). The +/// focused row's description renders as a dim help line below the list. +/// +public sealed class ConfigDashboardViewModel : ReactiveViewModel +{ + private readonly ConfigDashboardNavigationState _navigationState; + private readonly ConfigDashboardStatusReader? _statusReader; + + internal Action? RouteRequested { get; set; } + internal bool ShutdownRequestedForTest { get; private set; } + + public ConfigDashboardViewModel(ConfigDashboardNavigationState navigationState, NetclawPaths? paths = null) + { + _navigationState = navigationState; + _statusReader = paths is null ? null : new ConfigDashboardStatusReader(paths); + } + + public ReactiveProperty StatusMessage { get; } = new(""); + public ReactiveProperty SelectedIndex { get; } = new(0); + + public IReadOnlyList Items { get; } = + [ + new("Inference Providers", "Manage provider definitions and authentication.", "/provider"), + new("Models", "Assign model roles and discover provider models.", "/model"), + new("Channels", "Slack, Discord, and Mattermost settings.", "/channels"), + new("Inbound Webhooks", "Global webhook enablement and route diagnostics.", "/inbound-webhooks"), + new("Skill Sources", "External skills and private skill feeds.", "/skill-sources"), + new("Search", "Search backend and credentials.", "/search"), + new("Browser Automation", "Canonical browser MCP profile settings.", "/browser-automation"), + new("Telemetry & Alerting", "Telemetry and outbound webhook alerting.", "/telemetry-alerting"), + new("Security & Access", "Posture, enabled features, audience profiles, and exposure mode.", "/security"), + new("Workspaces Directory", "Project discovery root for workspace-aware prompts.", "/workspaces"), + new("Run Full Doctor", "Exit the dashboard and run `netclaw doctor`.", IsTerminal: true), + new("Quit", "Exit without changing settings.", IsTerminal: true), + ]; + + /// + /// Computes the live status-summary column entry for an item. Terminal rows + /// (Doctor / Quit) have no status. Returns an empty string when no config + /// reader is available (e.g. unit tests constructing the VM directly). + /// + public string StatusFor(ConfigDashboardItem item) + { + if (item.IsTerminal || _statusReader is null) + return string.Empty; + + return _statusReader.Summarize(item.Label); + } + + public void MoveSelection(int delta) + { + if (Items.Count == 0) + return; + + var next = Math.Clamp(SelectedIndex.Value + delta, 0, Items.Count - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + public void ActivateSelected() + { + Activate(Items[SelectedIndex.Value]); + } + + internal void Activate(ConfigDashboardItem item) + { + if (item.Route is not null) + { + RouteRequested?.Invoke(item.Route); + Navigate?.Invoke(item.Route); + return; + } + + if (string.Equals(item.Label, "Run Full Doctor", StringComparison.Ordinal)) + { + _navigationState.PendingAction = ConfigDashboardAction.RunDoctor; + ShutdownRequestedForTest = true; + Shutdown(); + return; + } + + if (string.Equals(item.Label, "Quit", StringComparison.Ordinal)) + { + ShutdownRequestedForTest = true; + Shutdown(); + return; + } + + StatusMessage.Value = $"{item.Label} is not implemented yet in `netclaw config`."; + RequestRedraw(); + } + + public void RequestQuit() => Shutdown(); + + public override void Dispose() + { + StatusMessage.Dispose(); + SelectedIndex.Dispose(); + base.Dispose(); + } +} + +/// +/// Reads the live netclaw.json (and secrets) and renders a one-line +/// status summary for each dashboard area. Kept beside the view model because +/// it exists only to feed the dashboard's status column, and reads the same +/// section keys the dedicated editors write through their persistence seams. +/// +internal sealed class ConfigDashboardStatusReader +{ + private static readonly string[] FeatureConfigPaths = + [ + "Memory.Enabled", + "Search.Enabled", + "SkillSync.Enabled", + "Scheduling.Enabled", + "SubAgents.Enabled", + "Webhooks.Enabled" + ]; + + private static readonly (ChannelType Type, string Section)[] ChannelAdapters = + [ + (ChannelType.Slack, "Slack"), + (ChannelType.Discord, "Discord"), + (ChannelType.Mattermost, "Mattermost") + ]; + + private readonly NetclawPaths _paths; + + internal ConfigDashboardStatusReader(NetclawPaths paths) + { + _paths = paths; + } + + internal string Summarize(string label) + { + // Read once per call so edits made in sub-editors are reflected on + // return to the dashboard (no caching = no staleness). + var config = ConfigFileHelper.LoadJsonDict(_paths.NetclawConfigPath); + return label switch + { + "Inference Providers" => ProvidersSummary(config), + "Models" => ModelsSummary(config), + "Channels" => ChannelsSummary(config), + "Inbound Webhooks" => OnOff(BoolAt(config, "Webhooks.Enabled")), + "Skill Sources" => SkillSourcesSummary(config), + "Search" => SearchSummary(config), + "Browser Automation" => OnOff(BrowserEnabled(config)), + "Telemetry & Alerting" => TelemetrySummary(config), + "Security & Access" => SecuritySummary(config), + "Workspaces Directory" => WorkspacesSummary(config), + _ => string.Empty + }; + } + + private static string ProvidersSummary(Dictionary config) + { + var count = ConfigFileHelper.GetSectionOrNull(config, "Providers")?.Count ?? 0; + return $"{count} configured"; + } + + private static string ModelsSummary(Dictionary config) + { + if (ConfigFileHelper.TryGetPathValue(config, "Models.Main.ModelId", out var modelId) + && modelId is string id && !string.IsNullOrWhiteSpace(id)) + { + return id; + } + + return "– not set"; + } + + private string ChannelsSummary(Dictionary config) + { + var configured = new List(); + var totalChannels = 0; + foreach (var (_, section) in ChannelAdapters) + { + if (!BoolAt(config, $"{section}.Enabled")) + continue; + + configured.Add(section); + if (ConfigFileHelper.TryGetPathValue(config, $"{section}.AllowedChannelIds", out var raw) + && raw is object[] channels) + { + totalChannels += channels.Length; + } + } + + if (configured.Count == 0) + return "– none configured"; + + if (configured.Count == 1) + return $"{configured[0]} · {Pluralize(totalChannels, "channel", "channels")}"; + + return $"{string.Join(" · ", configured)} · {Pluralize(totalChannels, "channel", "channels")}"; + } + + private string SkillSourcesSummary(Dictionary config) + { + try + { + var dirs = ConfigFileHelper.LoadSection(config, "ExternalSkills").Sources.Count; + var feeds = ConfigFileHelper.LoadSection(config, "SkillFeeds").Feeds.Count; + return $"{dirs} {(dirs == 1 ? "dir" : "dirs")} · {feeds} {(feeds == 1 ? "feed" : "feeds")}"; + } + catch (JsonException) + { + // These two summaries deserialize whole sections (unlike the others, which use + // TryGetPathValue and can't throw). A hand-edited/migrated section with the wrong shape + // must degrade to a visible indicator here — Summarize runs in the dashboard layout render. + return "– config error"; + } + } + + private static string SearchSummary(Dictionary config) + { + if (!ConfigFileHelper.TryGetPathValue(config, "Search.Backend", out var raw) + || raw is not string backend || string.IsNullOrWhiteSpace(backend)) + { + return "– not set"; + } + + return backend.ToLowerInvariant() switch + { + "brave" => "✓ Brave", + "searxng" => "✓ SearXNG", + "duckduckgo" => "✓ DuckDuckGo", + _ => $"✓ {backend}" + }; + } + + private string TelemetrySummary(Dictionary config) + { + var otlp = BoolAt(config, "Telemetry.Enabled") ? "on" : "off"; + try + { + var webhooks = ConfigFileHelper.LoadSection(config, "Notifications").Webhooks.Count; + return $"OTLP {otlp} · {Pluralize(webhooks, "webhook", "webhooks")}"; + } + catch (JsonException) + { + // A malformed Notifications section must not crash the dashboard layout render. + return $"OTLP {otlp} · – config error"; + } + } + + private static string SecuritySummary(Dictionary config) + { + var posture = ConfigFileHelper.TryGetPathValue(config, "Security.DeploymentPosture", out var value) + && value is string text + && Enum.TryParse(text, ignoreCase: true, out var parsed) + ? parsed + : DeploymentPosture.Personal; + + var enabled = 0; + foreach (var path in FeatureConfigPaths) + { + // Features default to enabled when absent, matching the security editor. + var flag = true; + if (ConfigFileHelper.TryGetPathValue(config, path, out var featureValue) && featureValue is bool configuredFlag) + flag = configuredFlag; + if (flag) + enabled++; + } + + return $"{posture} · {enabled}/{FeatureConfigPaths.Length} enabled"; + } + + private string WorkspacesSummary(Dictionary config) + => ConfigFileHelper.TryGetPathValue(config, "Workspaces.Directory", out var value) + && value is string dir && !string.IsNullOrWhiteSpace(dir) + ? dir + : _paths.WorkspacesDirectory; + + private static bool BoolAt(Dictionary config, string path) + => ConfigFileHelper.TryGetPathValue(config, path, out var value) && value is bool flag && flag; + + // Browser Automation has no `Browser.Enabled` flag; the editor persists enablement as + // the presence of the canonical browser MCP server entries, so the dashboard reads it + // back the same way BrowserAutomationConfigViewModel does. + private static bool BrowserEnabled(Dictionary config) + => ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_playwright", out _) + || ConfigFileHelper.TryGetPathValue(config, "McpServers.browser_chrome_devtools", out _); + + private static string OnOff(bool value) => value ? "enabled" : "– disabled"; + + private static string Pluralize(int count, string singular, string plural) + => $"{count} {(count == 1 ? singular : plural)}"; +} diff --git a/src/Netclaw.Cli/Tui/IdentityRedoPage.cs b/src/Netclaw.Cli/Tui/IdentityRedoPage.cs new file mode 100644 index 000000000..57eea9500 --- /dev/null +++ b/src/Netclaw.Cli/Tui/IdentityRedoPage.cs @@ -0,0 +1,160 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Workflow; +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +/// +/// Termina page hosting the single-step identity redo flow. Mirrors the single-step +/// section-editor host pattern: renders the identity step view, then a saved screen. +/// +public sealed class IdentityRedoPage : ReactivePage +{ + private DynamicLayoutNode? _contentNode; + private DynamicLayoutNode? _helpTextNode; + private readonly CompositeDisposable _stepSubs = []; + + protected override void OnBound() + { + base.OnBound(); + + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + ViewModel.Input.OfType() + .Subscribe(HandlePaste) + .DisposeWith(Subscriptions); + + ViewModel.OnStepContentChanged = () => + { + _stepSubs.Clear(); + _contentNode?.Invalidate(); + _helpTextNode?.Invalidate(); + }; + } + + public override ILayoutNode BuildLayout() + => NetclawTuiChrome.BuildPageFrame("Netclaw Setup", BuildInnerLayout()); + + private ILayoutNode BuildInnerLayout() + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(BuildContent()) + .WithChild(BuildHelpText()) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + + private LayoutNode BuildContent() + { + _contentNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + return WorkflowViewComponents.BuildSavedScreen( + "Identity updated.", + "Press Enter to exit. Run `netclaw chat` to talk to your agent."); + + ViewModel.StepView.ClearFocusState(); + return ViewModel.StepView.BuildContent(ViewModel.Step, CreateCallbacks()); + }); + + return _contentNode; + } + + private LayoutNode BuildHelpText() + { + _helpTextNode = new DynamicLayoutNode(() => + { + if (ViewModel.IsSaved.Value) + return (ILayoutNode)new TextNode("").WithForeground(Color.Gray); + + return (ILayoutNode)new TextNode(ViewModel.Step.GetHelpText()).WithForeground(Color.Gray); + }); + + return _helpTextNode.Height(2); + } + + private LayoutNode BuildStatusBar() + => ViewModel.Context.StatusMessage + .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) + ? Layouts.Empty() + : new TextNode($" {msg}").WithForeground(Color.Green))) + .AsLayout() + .Height(1); + + private LayoutNode BuildKeyBindings() + => ViewModel.IsSaved + .Select(saved => (ILayoutNode)new TextNode(saved + ? " [Enter] Exit [Ctrl+Q] Quit" + : " [↑/↓] Navigate [Enter] Next/Save [Esc] Back [Ctrl+Q] Quit") + .WithForeground(Color.BrightBlack)) + .AsLayout() + .Height(1); + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (base.HandlePageInput(keyInfo)) + return true; + + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return true; + } + + return false; + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + if (ViewModel.IsSaved.Value && keyInfo.Key == ConsoleKey.Enter) + { + ViewModel.GoNext(); + return; + } + + ViewModel.StepView.HandleKeyPress(key); + ViewModel.RequestRedraw(); + } + + private void HandlePaste(PasteEvent paste) + { + ViewModel.StepView.HandlePaste(paste); + ViewModel.RequestRedraw(); + } + + private StepViewCallbacks CreateCallbacks() + => new() + { + Subscriptions = _stepSubs, + InvalidateContent = () => _contentNode?.Invalidate(), + InvalidateHelp = () => _helpTextNode?.Invalidate(), + AdvanceStep = ViewModel.GoNext, + RequestRedraw = ViewModel.RequestRedraw, + }; + + public override void Dispose() + { + _stepSubs.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/IdentityRedoViewModel.cs b/src/Netclaw.Cli/Tui/IdentityRedoViewModel.cs new file mode 100644 index 000000000..b26b6047b --- /dev/null +++ b/src/Netclaw.Cli/Tui/IdentityRedoViewModel.cs @@ -0,0 +1,107 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Configuration; +using Netclaw.Providers; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui; + +/// +/// "Redo identity setup" flow reached from the existing-install menu. Hosts the +/// init-owned identity step single-step and, on completion, rewrites ONLY the identity +/// files — it deliberately does not call , +/// which would clobber the existing netclaw.json with bootstrap defaults +/// (simplify-netclaw-init: identity stays init-owned and is editable on its own). +/// +public sealed class IdentityRedoViewModel : ReactiveViewModel +{ + private readonly WizardContext _context; + private readonly WizardOrchestrator _orchestrator; + private readonly IdentityStepViewModel _step; + private readonly NetclawPaths _paths; + + public IdentityRedoViewModel(NetclawPaths paths) + { + _paths = paths; + _step = new IdentityStepViewModel(); + _context = new WizardContext + { + Paths = paths, + Registry = new ProviderDescriptorRegistry([]), + RequestRedraw = RequestRedraw, + ExistingConfig = ConfigFileHelper.LoadJsonDictOrNull(paths.NetclawConfigPath), + }; + _orchestrator = new WizardOrchestrator([_step], _context, singleStepMode: true); + } + + public WizardContext Context => _context; + public IdentityStepViewModel Step => _step; + public IdentityStepView StepView { get; } = new(); + public ReactiveProperty IsSaved { get; } = new(false); + public Action? OnStepContentChanged { get; set; } + + public void GoNext() + { + if (IsSaved.Value) + { + Shutdown(); + return; + } + + if (_orchestrator.GoNext()) + { + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + // Identity collected. Rewrite identity files only; built-in agents are left + // untouched so a redo never clobbers customized agent definitions. + _step.WriteIdentityFiles(_paths); + IsSaved.Value = true; + _context.StatusMessage.Value = "Identity updated. Run `netclaw chat` to talk to your agent."; + NotifyContentChanged(); + } + + public void GoBack() + { + if (IsSaved.Value) + { + Shutdown(); + return; + } + + if (_orchestrator.GoBack()) + { + _context.StatusMessage.Value = ""; + NotifyContentChanged(); + return; + } + + // Esc at the first identity field returns to the existing-install menu. + Navigate?.Invoke(InitExistingInstallViewModel.MenuRoute); + } + + public void RequestQuit() => Shutdown(); + + private void NotifyContentChanged() + { + OnStepContentChanged?.Invoke(); + RequestRedraw(); + } + + public override void Dispose() + { + IsSaved.Dispose(); + _orchestrator.Dispose(); + _context.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/InitExistingInstallPage.cs b/src/Netclaw.Cli/Tui/InitExistingInstallPage.cs new file mode 100644 index 000000000..e4823d2c9 --- /dev/null +++ b/src/Netclaw.Cli/Tui/InitExistingInstallPage.cs @@ -0,0 +1,153 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Extensions; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +/// +/// Termina page for the existing-install menu and its in-place start-over flow. +/// Renders a single selection list whose contents follow the ViewModel's phase +/// (menu → reset scope → two confirmations), so the destructive path is explicit and +/// double-confirmed (simplify-netclaw-init). +/// +public sealed class InitExistingInstallPage : ReactivePage +{ + private SelectionListNode? _list; + private DynamicLayoutNode? _bodyNode; + private readonly CompositeDisposable _phaseSubs = []; + + protected override void OnBound() + { + base.OnBound(); + + ViewModel.Input.OfType() + .Subscribe(HandleKeyPress) + .DisposeWith(Subscriptions); + + // Rebuild the body when the phase changes so the list reflects the new options. + ViewModel.CurrentPhase + .Subscribe(_ => _bodyNode?.Invalidate()) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() + { + return NetclawTuiChrome.BuildPageFrame("Netclaw Setup", BuildInnerLayout()); + } + + private ILayoutNode BuildInnerLayout() + { + _bodyNode = new DynamicLayoutNode(BuildBody); + return Layouts.Vertical() + .WithSpacing(1) + .WithChild(_bodyNode) + .WithChild(Layouts.Empty().Fill()) + .WithChild(BuildStatusBar()) + .WithChild(BuildKeyBindings()); + } + + private ILayoutNode BuildBody() + { + // Each rebuild creates a fresh list + subscription; clear the previous one so + // SelectionConfirmed handlers don't accumulate across phase changes (#792). + _phaseSubs.Clear(); + + var phase = ViewModel.CurrentPhase.Value; + var header = Layouts.Vertical().WithSpacing(0); + + switch (phase) + { + case InitExistingInstallViewModel.Phase.Menu: + header.WithChild(new TextNode(" Existing Netclaw install detected.").WithForeground(Color.White).Bold()); + header.WithChild(new TextNode(" Your current config is untouched until you confirm an action.").WithForeground(Color.Gray)); + break; + case InitExistingInstallViewModel.Phase.ResetScope: + header.WithChild(new TextNode(" Start over from scratch — choose a scope:").WithForeground(Color.White).Bold()); + break; + default: + var full = ViewModel.Scope == InitExistingInstallViewModel.ResetScopeKind.Full; + var n = phase == InitExistingInstallViewModel.Phase.ResetConfirm1 ? 1 : 2; + header.WithChild(new TextNode($" ⚠ {(full ? "Full reset" : "Reset setup")} — confirmation {n} of 2") + .WithForeground(Color.Yellow).Bold()); + header.WithChild(new TextNode(full + ? " This permanently deletes config, memory, sessions, and secrets. This cannot be undone." + : " This re-runs setup. Memory, sessions, and skills are kept.") + .WithForeground(Color.Gray)); + break; + } + + var rows = ViewModel.CurrentItems + .Select(item => $"{item.Label,-26} {item.Description}") + .ToList(); + + _list = Layouts.SelectionList(rows) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Cyan); + _list.OnFocused(); + _list.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count == 0) + return; + var index = rows.IndexOf(selected[0]); + if (index >= 0) + { + ViewModel.SelectedIndex.Value = index; + ViewModel.ActivateSelected(); + } + }) + .DisposeWith(_phaseSubs); + + return Layouts.Vertical() + .WithSpacing(1) + .WithChild(header) + .WithChild(_list); + } + + private LayoutNode BuildStatusBar() + { + return ViewModel.StatusMessage + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Yellow)) + .AsLayout() + .Height(1); + } + + private LayoutNode BuildKeyBindings() + { + return NetclawTuiChrome.BuildKeyHintLine(" [↑/↓] Navigate [Enter] Select [Esc] Back [Ctrl+Q] Quit"); + } + + private void HandleKeyPress(KeyPressed key) + { + var keyInfo = key.KeyInfo; + if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestQuit(); + return; + } + + if (keyInfo.Key == ConsoleKey.Escape) + { + ViewModel.GoBack(); + return; + } + + _list?.HandleInput(keyInfo); + ViewModel.RequestRedraw(); + } + + public override void Dispose() + { + _phaseSubs.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/InitExistingInstallViewModel.cs b/src/Netclaw.Cli/Tui/InitExistingInstallViewModel.cs new file mode 100644 index 000000000..e713b78e1 --- /dev/null +++ b/src/Netclaw.Cli/Tui/InitExistingInstallViewModel.cs @@ -0,0 +1,278 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using R3; +using Termina.Reactive; + +namespace Netclaw.Cli.Tui; + +/// +/// Follow-up action requested from the existing-install menu that the init host +/// cannot service itself and must hand back to Program after the TUI exits +/// (mirrors / RunDoctor). In-host destinations +/// (the wizard, identity redo) are reached by routing instead. +/// +public enum InitFollowUpAction +{ + None, + OpenConfigEditor, +} + +/// Shared state carrying the existing-install menu's deferred action out of +/// the Termina host so Program can dispatch it. +public sealed class InitNavigationState +{ + public InitFollowUpAction PendingAction { get; set; } +} + +/// +/// Existing-install menu shown when netclaw init runs against a config that +/// already exists (simplify-netclaw-init). Instead of silently re-walking setup or +/// refusing with a hidden --force, it offers an explicit action menu and an +/// in-place, double-confirmed start-over flow. Config is untouched until the operator +/// confirms a destructive action. +/// +public sealed class InitExistingInstallViewModel : ReactiveViewModel +{ + public enum Phase + { + Menu, + ResetScope, + ResetConfirm1, + ResetConfirm2, + } + + public enum ResetScopeKind + { + SetupOnly, + Full, + } + + public sealed record MenuItem(string Label, string Description); + + private readonly NetclawPaths _paths; + private readonly InitNavigationState _navigationState; + + public InitExistingInstallViewModel(NetclawPaths paths, InitNavigationState navigationState) + { + _paths = paths; + _navigationState = navigationState; + } + + /// Route the wizard launches for "Redo identity setup". + public const string IdentityRoute = "/init/identity"; + + /// Route launched for a fresh setup after a confirmed reset. + public const string WizardRoute = "/init"; + + /// This menu's own route (identity redo returns here on Esc). + public const string MenuRoute = "/init/menu"; + + public ReactiveProperty CurrentPhase { get; } = new(Phase.Menu); + public ReactiveProperty SelectedIndex { get; } = new(0); + public ReactiveProperty StatusMessage { get; } = new(""); + + private ResetScopeKind _scope = ResetScopeKind.SetupOnly; + public ResetScopeKind Scope => _scope; + + public static readonly IReadOnlyList MenuItems = + [ + new("Redo identity setup", "Re-run just the identity step; provider and settings are kept."), + new("Open configuration editor", "Adjust settings in `netclaw config` instead."), + new("Start over from scratch", "Reset and run the whole setup again."), + new("Cancel", "Leave everything as-is and exit."), + ]; + + public static readonly IReadOnlyList ScopeItems = + [ + new("Reset setup only", "Re-run setup; keep memory, sessions, and skills."), + new("Full reset", "Delete ALL Netclaw data: config, memory, sessions, secrets."), + new("Cancel", "Go back without changing anything."), + ]; + + /// Items for the current phase (drives the rendered list). + public IReadOnlyList CurrentItems => CurrentPhase.Value switch + { + Phase.Menu => MenuItems, + Phase.ResetScope => ScopeItems, + _ => ConfirmItems(), + }; + + private IReadOnlyList ConfirmItems() + { + var full = _scope == ResetScopeKind.Full; + return + [ + new("Cancel", "Go back without changing anything."), + new(full ? "Yes, delete everything" : "Yes, reset setup", + full + ? "Permanently deletes config, memory, sessions, and secrets. Cannot be undone." + : "Re-runs setup. Memory, sessions, and skills are kept."), + ]; + } + + public void MoveSelection(int delta) + { + var count = CurrentItems.Count; + if (count == 0) + return; + + var next = Math.Clamp(SelectedIndex.Value + delta, 0, count - 1); + if (next != SelectedIndex.Value) + SelectedIndex.Value = next; + } + + /// Enter on the highlighted row. + public void ActivateSelected() + { + switch (CurrentPhase.Value) + { + case Phase.Menu: + ActivateMenu(SelectedIndex.Value); + break; + case Phase.ResetScope: + ActivateScope(SelectedIndex.Value); + break; + case Phase.ResetConfirm1: + ActivateConfirm(first: true); + break; + case Phase.ResetConfirm2: + ActivateConfirm(first: false); + break; + } + } + + private void ActivateMenu(int index) + { + switch (index) + { + case 0: // Redo identity setup + Navigate?.Invoke(IdentityRoute); + break; + case 1: // Open configuration editor + _navigationState.PendingAction = InitFollowUpAction.OpenConfigEditor; + Shutdown(); + break; + case 2: // Start over from scratch + EnterPhase(Phase.ResetScope); + break; + default: // Cancel + Shutdown(); + break; + } + } + + private void ActivateScope(int index) + { + switch (index) + { + case 0: + _scope = ResetScopeKind.SetupOnly; + EnterPhase(Phase.ResetConfirm1); + break; + case 1: + _scope = ResetScopeKind.Full; + EnterPhase(Phase.ResetConfirm1); + break; + default: // Cancel + EnterPhase(Phase.Menu); + break; + } + } + + // Confirm rows are [Cancel, Yes]. Default selection is Cancel (index 0), so a stray + // Enter never deletes — the operator must move to "Yes" and confirm twice. + private void ActivateConfirm(bool first) + { + if (SelectedIndex.Value == 0) // Cancel + { + EnterPhase(Phase.ResetScope); + return; + } + + if (first) + { + EnterPhase(Phase.ResetConfirm2); + return; + } + + PerformReset(); + } + + private void PerformReset() + { + try + { + if (_scope == ResetScopeKind.Full) + { + DeleteDirectory(_paths.BasePath); + } + else + { + // Setup-only: remove what the bootstrap wizard writes (config + secrets + + // identity), preserving memory, sessions, and skills. SecretsPath lives + // under ConfigDirectory, so deleting it covers secrets too. + DeleteDirectory(_paths.ConfigDirectory); + DeleteDirectory(_paths.IdentityDirectory); + DeleteDirectory(_paths.SoulDirectory); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + StatusMessage.Value = $"Reset failed: {ex.Message}"; + RequestRedraw(); + return; + } + + // Fresh setup from the top. + Navigate?.Invoke(WizardRoute); + } + + private static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + + /// Esc: step back one phase, or quit from the menu. + public void GoBack() + { + switch (CurrentPhase.Value) + { + case Phase.Menu: + Shutdown(); + break; + case Phase.ResetScope: + EnterPhase(Phase.Menu); + break; + case Phase.ResetConfirm1: + EnterPhase(Phase.ResetScope); + break; + case Phase.ResetConfirm2: + EnterPhase(Phase.ResetConfirm1); + break; + } + } + + private void EnterPhase(Phase phase) + { + CurrentPhase.Value = phase; + // Confirm phases default to Cancel (index 0); menus/scope start at the top. + SelectedIndex.Value = 0; + StatusMessage.Value = ""; + RequestRedraw(); + } + + public void RequestQuit() => Shutdown(); + + public override void Dispose() + { + CurrentPhase.Dispose(); + SelectedIndex.Dispose(); + StatusMessage.Dispose(); + base.Dispose(); + } +} diff --git a/src/Netclaw.Cli/Tui/InitWizardPage.cs b/src/Netclaw.Cli/Tui/InitWizardPage.cs index 774a297e1..1ef7edc6e 100644 --- a/src/Netclaw.Cli/Tui/InitWizardPage.cs +++ b/src/Netclaw.Cli/Tui/InitWizardPage.cs @@ -225,11 +225,15 @@ private LayoutNode BuildStatusBar() private LayoutNode BuildKeyBindings() { return Observable.CombineLatest(ViewModel.Orchestrator.CurrentStepIndex, ViewModel.IsComplete, - (_, complete) => + ViewModel.HealthCheckStep.Succeeded, + (_, complete, succeeded) => { if (complete) + { + var doneLabel = succeeded ? "Launch netclaw chat" : "Exit"; return (ILayoutNode)new TextNode( - " [Enter] Exit [Ctrl+Q] Quit").WithForeground(Color.BrightBlack); + $" [Enter] {doneLabel} [Ctrl+Q] Quit").WithForeground(Color.BrightBlack); + } var backLabel = ViewModel.Orchestrator.CurrentStepIndex.Value == 0 ? "Quit" : "Back"; return (ILayoutNode)new TextNode( @@ -350,13 +354,23 @@ private void HandleKeyPress(KeyPressed key) } } - // Health check step: Enter triggers the check or exits + // Health check step: Enter triggers the check. On completion a clean bootstrap + // auto-launches `netclaw chat` (HealthCheckStepViewModel calls LaunchChat itself), + // so the success branch here is only a fallback; warnings/failures stay on the + // summary and Enter exits. if (currentStep is HealthCheckStepViewModel healthVm && keyInfo.Key == ConsoleKey.Enter) { if (healthVm.IsComplete.Value) - ViewModel.RequestQuit(); + { + if (healthVm.Succeeded.Value) + healthVm.LaunchChat(); + else + ViewModel.RequestQuit(); + } else + { ViewModel.GoNext(); + } return; } @@ -387,6 +401,7 @@ private StepViewCallbacks CreateCallbacks() InvalidateHelp = () => _helpTextNode?.Invalidate(), AdvanceStep = () => ViewModel.GoNext(), RequestRedraw = ViewModel.RequestRedraw, + SetStatusMessage = message => ViewModel.Context.StatusMessage.Value = message, }; } diff --git a/src/Netclaw.Cli/Tui/InitWizardViewModel.cs b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs index 3904729b7..6a8781e43 100644 --- a/src/Netclaw.Cli/Tui/InitWizardViewModel.cs +++ b/src/Netclaw.Cli/Tui/InitWizardViewModel.cs @@ -5,6 +5,8 @@ // ----------------------------------------------------------------------- using Netclaw.Channels.Slack; using Netclaw.Cli.Daemon; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Cli.Discord; using Netclaw.Cli.Tui.Wizard; using Netclaw.Cli.Tui.Wizard.Steps; @@ -29,6 +31,7 @@ public partial class InitWizardViewModel : ReactiveViewModel private readonly WizardOrchestrator _orchestrator; private readonly Dictionary _stepViews; private readonly HealthCheckStepViewModel _healthCheckStep; + private readonly SectionEditorRegistry? _sectionEditors; /// The wizard orchestrator managing step sequencing. public WizardOrchestrator Orchestrator => _orchestrator; @@ -58,12 +61,12 @@ public InitWizardViewModel( DaemonManager? daemonManager = null, DaemonApi? daemonApi = null, IClipboardService? clipboardService = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + SectionEditorRegistry? sectionEditors = null) : this(paths, registry, registry, slackProbe, discordProbe, navigationState: navigationState, oauthFactory: oauthFactory, daemonManager: daemonManager, daemonApi: daemonApi, - clipboardService: clipboardService, - timeProvider: timeProvider) + clipboardService: clipboardService, timeProvider: timeProvider, sectionEditors: sectionEditors) { } @@ -81,44 +84,36 @@ internal InitWizardViewModel( DaemonManager? daemonManager = null, DaemonApi? daemonApi = null, IClipboardService? clipboardService = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + SectionEditorRegistry? sectionEditors = null) { + _sectionEditors = sectionEditors; + // Create shared context _context = new WizardContext { Paths = paths, Registry = registry, - RequestRedraw = RequestRedraw + RequestRedraw = RequestRedraw, + ExistingConfig = ConfigFileHelper.LoadJsonDictOrNull(paths.NetclawConfigPath) }; - // Create step VMs in the canonical order: - // provider -> security-posture -> feature-selection -> channel-picker -> channels -> search -> browser-automation -> identity -> external-skills -> exposure-mode -> health-check + // Create step VMs in the canonical bootstrap order (simplify-netclaw-init): + // provider -> identity -> security-posture -> feature-selection -> health-check. + // Channels, Search, Browser Automation, and Skill Sources are no longer part of + // first-run bootstrap; they moved to `netclaw config` (the post-install surface). ProviderStep = new ProviderStepViewModel(registry, probe, oauthFactory, daemonApi); + var identityStep = new IdentityStepViewModel(); var securityPostureStep = new SecurityPostureStepViewModel(); var featureSelectionStep = new FeatureSelectionStepViewModel(); - var exposureModeStep = new ExposureModeStepViewModel(); - var channelPickerStep = new ChannelPickerStepViewModel(slackProbe, discordProbe); - var channelsStep = new ChannelsStepViewModel(); - var searchStep = new SearchStepViewModel(); - var browserStep = new BrowserAutomationStepViewModel(); - var identityStep = new IdentityStepViewModel(); - var externalSkillsStep = new ExternalSkillsStepViewModel(); - var skillFeedsStep = new SkillFeedsStepViewModel(); _healthCheckStep = new HealthCheckStepViewModel(daemonManager, daemonApi, navigationState, timeProvider); var steps = new List { ProviderStep, + identityStep, securityPostureStep, featureSelectionStep, - channelPickerStep, - channelsStep, - searchStep, - browserStep, - identityStep, - externalSkillsStep, - skillFeedsStep, - exposureModeStep, _healthCheckStep }; @@ -134,20 +129,13 @@ internal InitWizardViewModel( // Create orchestrator _orchestrator = new WizardOrchestrator(steps, _context); - // Create step views + // Create step views (bootstrap steps only). _stepViews = new Dictionary { [WizardStepIds.Provider] = new ProviderStepView(clipboardService), + [WizardStepIds.Identity] = new IdentityStepView(), [WizardStepIds.SecurityPosture] = new SecurityPostureStepView(), [WizardStepIds.FeatureSelection] = new FeatureSelectionStepView(), - [WizardStepIds.ExposureMode] = new ExposureModeStepView(), - [WizardStepIds.ChannelPicker] = new ChannelPickerStepView(), - [WizardStepIds.Channels] = new ChannelsStepView(), - [WizardStepIds.Search] = new SearchStepView(), - [WizardStepIds.BrowserAutomation] = new BrowserAutomationStepView(), - [WizardStepIds.Identity] = new IdentityStepView(), - [WizardStepIds.ExternalSkills] = new ExternalSkillsStepView(), - [WizardStepIds.SkillFeeds] = new SkillFeedsStepView(), [WizardStepIds.HealthCheck] = new HealthCheckStepView() }; } @@ -220,6 +208,7 @@ private void HandleGlobalKey(KeyPressed key) public override void Dispose() { + _sectionEditors?.Dispose(); _orchestrator.Dispose(); _context.Dispose(); base.Dispose(); diff --git a/src/Netclaw.Cli/Tui/ModelManagerPage.cs b/src/Netclaw.Cli/Tui/ModelManagerPage.cs index 62faa9c52..cbe611d1b 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerPage.cs @@ -42,14 +42,7 @@ protected override void OnBound() public override ILayoutNode BuildLayout() { - return Layouts.Vertical() - .WithChild( - new PanelNode() - .WithTitle("Model Manager") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(BuildInnerLayout()) - .Fill()); + return NetclawTuiChrome.BuildPageFrame("Model Manager", BuildInnerLayout()); } private ILayoutNode BuildInnerLayout() @@ -90,9 +83,7 @@ private LayoutNode BuildContent() private LayoutNode BuildStatusBar() { return ViewModel.StatusMessage - .Select(msg => (ILayoutNode)(string.IsNullOrWhiteSpace(msg) - ? Layouts.Empty() - : new TextNode($" {msg}").WithForeground(Color.Green))) + .Select(msg => NetclawTuiChrome.BuildStatusLine(msg, Color.Green)) .AsLayout() .Height(1); } @@ -111,7 +102,7 @@ private LayoutNode BuildKeyBindings() _ => " [\u2191/\u2193] Navigate [Enter] Select [Esc] Back [Ctrl+Q] Quit" }; - return (ILayoutNode)new TextNode(text).WithForeground(Color.BrightBlack); + return (ILayoutNode)NetclawTuiChrome.BuildKeyHintLine(text); }) .AsLayout() .Height(1); @@ -255,12 +246,7 @@ private ILayoutNode BuildDiscoverModels() .WithForeground(Color.White)) .WithChild(new TextNode("").Height(1)) .WithChild(new TextNode(" Enter model ID:").WithForeground(Color.White)) - .WithChild(new PanelNode() - .WithTitle("Model ID") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_manualModelInput) - .Height(3)); + .WithChild(NetclawTuiChrome.BuildTextInputPanel(_manualModelInput, "Model ID")); } // Build model list with manual entry option diff --git a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs index 3b4158885..49a9a7f09 100644 --- a/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ModelManagerViewModel.cs @@ -38,6 +38,15 @@ public sealed class ModelManagerViewModel : ReactiveViewModel private readonly ProviderDescriptorRegistry? _registry; private CancellationTokenSource? _probeCts; + internal Action? RouteRequested { get; set; } + + /// + /// True when this manager is hosted inside netclaw config (reached from the dashboard). + /// Set by the embedded host registration; left false for the standalone netclaw model + /// host. Controls whether backing out past the root navigates to the dashboard or exits the app. + /// + internal bool IsEmbeddedInConfig { get; set; } + public ReactiveProperty CurrentState { get; } = new(ModelManagerState.RoleOverview); public ReactiveProperty StatusMessage { get; } = new(""); public ReactiveProperty IsProbing { get; } = new(false); @@ -66,11 +75,12 @@ public sealed class ModelManagerViewModel : ReactiveViewModel internal Task? ProbeCompletion { get; private set; } public ModelManagerViewModel(NetclawPaths paths, IProviderProbe probe, - ProviderDescriptorRegistry? registry = null) + ProviderDescriptorRegistry? registry = null, EmbeddedConfigHostMarker? embeddedHost = null) { _paths = paths; _probe = probe; _registry = registry; + IsEmbeddedInConfig = embeddedHost is not null; } public override void OnActivated() @@ -256,10 +266,23 @@ public void GoBack() ClearAssignmentState(); CurrentState.Value = ModelManagerState.RoleOverview; NotifyStateChanged(); - } + } break; default: - Shutdown(); + if (IsEmbeddedInConfig) + { + // Embedded in `netclaw config`: return to the dashboard. We must NOT Shutdown + // here — Shutdown cancels the run loop's token before the queued navigation is + // processed, dropping the nav and quitting the entire config app. + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + else + { + // Standalone `netclaw model`: backing out past the root exits the app. + Shutdown(); + } + break; } } diff --git a/src/Netclaw.Cli/Tui/NetclawTuiChrome.cs b/src/Netclaw.Cli/Tui/NetclawTuiChrome.cs new file mode 100644 index 000000000..4e42853d4 --- /dev/null +++ b/src/Netclaw.Cli/Tui/NetclawTuiChrome.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina.Layout; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +internal static class NetclawTuiChrome +{ + internal static ILayoutNode BuildPageFrame(string title, ILayoutNode content, Color? borderColor = null) + => Layouts.Vertical() + .WithChild(BuildPanel(title, content, borderColor ?? Color.Cyan).Fill()); + + internal static PanelNode BuildPanel(string title, ILayoutNode content, Color borderColor) + => new PanelNode() + .WithTitle(title) + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(borderColor) + .WithContent(content); + + internal static LayoutNode BuildTextInputPanel(TextInputNode input, string title) + => BuildPanel(title, input, Color.Gray) + .Height(3); + + internal static ILayoutNode BuildStatusLine(string? text, Color color) + => string.IsNullOrWhiteSpace(text) + ? Layouts.Empty() + : new TextNode($" {text}").WithForeground(color); + + internal static LayoutNode BuildKeyHintLine(string text) + => new TextNode(text) + .WithForeground(Color.BrightBlack) + .Height(1); +} diff --git a/src/Netclaw.Cli/Tui/NetclawValidationDialog.cs b/src/Netclaw.Cli/Tui/NetclawValidationDialog.cs new file mode 100644 index 000000000..fb4f0c9d6 --- /dev/null +++ b/src/Netclaw.Cli/Tui/NetclawValidationDialog.cs @@ -0,0 +1,64 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina.Layout; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +internal enum NetclawValidationDialogAction +{ + RetryValidation, + BackToEdit, + SaveAnyway, +} + +internal sealed record NetclawValidationDialogModel(string Title, string Intro, string Message); + +internal static class NetclawValidationDialogViews +{ + private const string RetryLabel = "Retry validation"; + private const string BackToEditLabel = "Back to edit"; + private const string SaveAnywayLabel = "Save anyway"; + + public static SelectionListNode BuildActionList() + { + var list = Layouts.SelectionList(new List + { + RetryLabel, + BackToEditLabel, + SaveAnywayLabel, + }) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Yellow); + list.OnFocused(); + return list; + } + + public static ILayoutNode BuildWarningPanel(NetclawValidationDialogModel model, SelectionListNode actionList) + { + ArgumentNullException.ThrowIfNull(model); + ArgumentNullException.ThrowIfNull(actionList); + + return NetclawTuiChrome.BuildPanel( + model.Title, + Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {model.Intro}").WithForeground(Color.White)) + .WithChild(new TextNode($" {model.Message}").WithForeground(Color.Yellow)) + .WithChild(actionList), + Color.Yellow); + } + + public static NetclawValidationDialogAction ParseAction(string label) + => label switch + { + RetryLabel => NetclawValidationDialogAction.RetryValidation, + BackToEditLabel => NetclawValidationDialogAction.BackToEdit, + SaveAnywayLabel => NetclawValidationDialogAction.SaveAnyway, + _ => throw new InvalidOperationException($"Unknown validation dialog action '{label}'."), + }; +} diff --git a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs index c6a4a7e45..274508c5a 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerPage.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerPage.cs @@ -55,14 +55,7 @@ protected override void OnBound() public override ILayoutNode BuildLayout() { - return Layouts.Vertical() - .WithChild( - new PanelNode() - .WithTitle("Provider Manager") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(BuildInnerLayout()) - .Fill()); + return NetclawTuiChrome.BuildPageFrame("Provider Manager", BuildInnerLayout()); } private ILayoutNode BuildInnerLayout() @@ -120,11 +113,9 @@ private LayoutNode BuildStatusBar() // validation feedback immediately. return ViewModel.ErrorMessage .CombineLatest(ViewModel.StatusMessage, (err, status) => (err, status)) - .Select(t => (ILayoutNode)(!string.IsNullOrWhiteSpace(t.err) - ? new TextNode($" {t.err}").WithForeground(Color.Red) - : !string.IsNullOrWhiteSpace(t.status) - ? new TextNode($" {t.status}").WithForeground(Color.Green) - : Layouts.Empty())) + .Select(t => !string.IsNullOrWhiteSpace(t.err) + ? NetclawTuiChrome.BuildStatusLine(t.err, Color.Red) + : NetclawTuiChrome.BuildStatusLine(t.status, Color.Green)) .AsLayout() .Height(1); } @@ -157,7 +148,7 @@ private LayoutNode BuildKeyBindings() _ => " [\u2191/\u2193] Navigate [Enter] Next [Esc] Back [Ctrl+Q] Quit" }; - return (ILayoutNode)new TextNode(text).WithForeground(Color.BrightBlack); + return (ILayoutNode)NetclawTuiChrome.BuildKeyHintLine(text); }) .AsLayout() .Height(1); @@ -319,12 +310,7 @@ private ILayoutNode BuildAddNameView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("Name") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_nameInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_nameInput, "Name")); children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode(" This is how the provider appears in `netclaw provider list`") @@ -392,12 +378,7 @@ private ILayoutNode BuildCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("API Key") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_apiKeyInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_apiKeyInput, "API Key")); if (descriptor.Auth.GetApiKeyGuidanceUrl() is { } guidanceUrl) { @@ -425,12 +406,7 @@ private ILayoutNode BuildCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("Endpoint") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_endpointInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode($" {descriptor.DisplayName} runs locally. No authentication required.") @@ -655,12 +631,7 @@ private ILayoutNode BuildRenameView() .Subscribe(text => ViewModel.ConfirmRename(text)) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("New name") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_renameInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_renameInput, "New name")); children.WithChild(new TextNode("").Height(1)); children.WithChild(new TextNode(" Renames the provider and cascades the change to any model") @@ -736,12 +707,7 @@ private ILayoutNode BuildFixCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("Endpoint") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_endpointInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_endpointInput, "Endpoint")); } else { @@ -763,12 +729,7 @@ private ILayoutNode BuildFixCredentialsView() }) .DisposeWith(_stepSubs); - children.WithChild(new PanelNode() - .WithTitle("API Key") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(_apiKeyInput) - .Height(3)); + children.WithChild(NetclawTuiChrome.BuildTextInputPanel(_apiKeyInput, "API Key")); if (descriptor.Auth.GetApiKeyGuidanceUrl() is { } guidanceUrl) { diff --git a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs index 67542d2e3..d175e7b8e 100644 --- a/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs +++ b/src/Netclaw.Cli/Tui/ProviderManagerViewModel.cs @@ -78,6 +78,16 @@ public sealed class ProviderManagerViewModel : ReactiveViewModel private readonly IProviderProbe _probe; private readonly DeviceFlowServiceFactory? _oauthFactory; private CancellationTokenSource? _probeCts; + private CancellationTokenSource? _revalidateCts; + + internal Action? RouteRequested { get; set; } + + /// + /// True when this manager is hosted inside netclaw config (reached from the dashboard). + /// Set by the embedded host registration; left false for the standalone netclaw provider + /// host. Controls whether backing out past the root navigates to the dashboard or exits the app. + /// + internal bool IsEmbeddedInConfig { get; set; } public ReactiveProperty CurrentState { get; } = new(ProviderManagerState.Loading); public ReactiveProperty StatusMessage { get; } = new(""); @@ -138,24 +148,32 @@ public sealed class ProviderManagerViewModel : ReactiveViewModel /// internal Task? EagerProbeCompletion { get; private set; } + /// + /// Completes when the detail-provider revalidation finishes. Used for testing. + /// + internal Task? RevalidateCompletion { get; private set; } + /// /// The provider descriptor registry. Exposed for use by the page. /// public ProviderDescriptorRegistry Registry => _registry; public ProviderManagerViewModel(NetclawPaths paths, ProviderDescriptorRegistry registry, - DeviceFlowServiceFactory? oauthFactory = null, DaemonApi? daemonApi = null) - : this(paths, registry, registry, oauthFactory, daemonApi) + DeviceFlowServiceFactory? oauthFactory = null, DaemonApi? daemonApi = null, + EmbeddedConfigHostMarker? embeddedHost = null) + : this(paths, registry, registry, oauthFactory, daemonApi, embeddedHost) { } public ProviderManagerViewModel(NetclawPaths paths, ProviderDescriptorRegistry registry, IProviderProbe probe, - DeviceFlowServiceFactory? oauthFactory = null, DaemonApi? daemonApi = null) + DeviceFlowServiceFactory? oauthFactory = null, DaemonApi? daemonApi = null, + EmbeddedConfigHostMarker? embeddedHost = null) { _paths = paths; _registry = registry; _probe = probe; _oauthFactory = oauthFactory; + IsEmbeddedInConfig = embeddedHost is not null; OAuth = new OAuthFlowCoordinator( registry, oauthFactory, @@ -518,35 +536,9 @@ public void SubmitFixCredentials() return; } - // Write updated credentials - if (DetailProvider.ConfiguredName is not null) - { - if (!string.IsNullOrWhiteSpace(FixApiKey)) - { - var (_, secrets) = ConfigFileHelper.LoadConfigFiles(_paths); - var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); - secretProviders[DetailProvider.ConfiguredName] = new Dictionary - { - ["ApiKey"] = FixApiKey - }; - ConfigFileHelper.WriteSecretsFile(_paths, secrets); - } - - if (FixEndpoint is not null && DetailProvider.Entry is not null - && !string.Equals(FixEndpoint, DetailProvider.Entry.Endpoint, StringComparison.Ordinal)) - { - var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); - var providers = ConfigFileHelper.GetOrCreateSection(config, "Providers"); - if (providers.TryGetValue(DetailProvider.ConfiguredName, out var existing) && - existing is Dictionary providerDict) - { - providerDict["Endpoint"] = FixEndpoint; - ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); - } - } - } - - // Set up probe using fix credentials + // Do NOT write the new credential yet: defer the secrets/config write to the probe-success + // branch (WriteFixedCredentials) so a bad API key or endpoint never clobbers the working one + // on disk with no rollback. The normal add flow defers its write identically. NewProviderType = type; NewEndpoint = FixEndpoint; NewApiKey = FixApiKey @@ -559,6 +551,39 @@ public void SubmitFixCredentials() StartProbe(); } + // Persists the fixed API key (to secrets.json) and endpoint (to netclaw.json) for the provider + // being repaired. Called only from the probe-success branch so an invalid new credential never + // overwrites the working one. Updates the existing provider entry keyed by ConfiguredName. + private void WriteFixedCredentials() + { + if (DetailProvider?.ConfiguredName is not { } name) + return; + + if (!string.IsNullOrWhiteSpace(FixApiKey)) + { + var (_, secrets) = ConfigFileHelper.LoadConfigFiles(_paths); + var secretProviders = ConfigFileHelper.GetOrCreateSection(secrets, "Providers"); + secretProviders[name] = new Dictionary + { + ["ApiKey"] = FixApiKey + }; + ConfigFileHelper.WriteSecretsFile(_paths, secrets); + } + + if (FixEndpoint is not null && DetailProvider.Entry is not null + && !string.Equals(FixEndpoint, DetailProvider.Entry.Endpoint, StringComparison.Ordinal)) + { + var (config, _) = ConfigFileHelper.LoadConfigFiles(_paths); + var providers = ConfigFileHelper.GetOrCreateSection(config, "Providers"); + if (providers.TryGetValue(name, out var existing) && + existing is Dictionary providerDict) + { + providerDict["Endpoint"] = FixEndpoint; + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); + } + } + } + /// /// Finish a successful add flow and return to the refreshed provider list. /// @@ -706,25 +731,51 @@ public void RevalidateDetailProvider() DetailProvider.Health = ProviderHealthStatus.Probing; NotifyStateChanged(); - _ = RevalidateAsync(DetailProvider); + CancelRevalidate(); + _revalidateCts = new CancellationTokenSource(); + RevalidateCompletion = RevalidateAsync(DetailProvider, _revalidateCts.Token); } - private async Task RevalidateAsync(ProviderDisplayItem item) + // Cancel and dispose the in-flight detail-provider revalidation. Called when a newer revalidate + // starts, when the operator leaves the detail view, and on dispose — all on the UI thread. + private void CancelRevalidate() + { + if (_revalidateCts is not null) + { + _revalidateCts.Cancel(); + _revalidateCts.Dispose(); + _revalidateCts = null; + } + } + + private async Task RevalidateAsync(ProviderDisplayItem item, CancellationToken ct) { try { var result = item.Entry is not null - ? await _probe.ProbeAsync(item.Entry, CancellationToken.None) + ? await _probe.ProbeAsync(item.Entry, ct) : await _probe.ProbeAsync(item.ProviderType, item.Entry?.Endpoint, - GetProbeCredential(item.Entry), CancellationToken.None); + GetProbeCredential(item.Entry), ct); + + // Abandoned (operator left the detail view, or a newer revalidate started): do not + // update health or redraw against a stale/disposed view-model. + if (ct.IsCancellationRequested) + return; item.ProbeResult = result; item.Health = result.Success ? ProviderHealthStatus.Healthy : ProviderHealthStatus.Unhealthy; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + return; + } catch { + if (ct.IsCancellationRequested) + return; + item.Health = ProviderHealthStatus.Unhealthy; } @@ -748,6 +799,7 @@ public async Task SubmitRedirectUrlAsync(string? pastedUrl) public void GoBackToList() { CancelProbe(); + CancelRevalidate(); ClearAddState(); DetailProvider = null; IsFixFlow = false; @@ -815,7 +867,20 @@ public void GoBack() CancelRename(); break; default: - Shutdown(); + if (IsEmbeddedInConfig) + { + // Embedded in `netclaw config`: return to the dashboard. We must NOT Shutdown + // here — Shutdown cancels the run loop's token before the queued navigation is + // processed, dropping the nav and quitting the entire config app. + RouteRequested?.Invoke("/config"); + Navigate?.Invoke("/config"); + } + else + { + // Standalone `netclaw provider`: backing out past the root exits the app. + Shutdown(); + } + break; } } @@ -932,6 +997,12 @@ internal async Task ProbeProviderAsync() WriteProviderConfig(); _newProviderPersisted = true; } + else + { + // API-key / endpoint fix: persist only now that the probe succeeded, so a typo + // in the new credential leaves the prior working secret untouched on disk. + WriteFixedCredentials(); + } // Fix flow: re-probe all providers so list shows fresh health IsFixFlow = false; @@ -1069,6 +1140,7 @@ private void HandleGlobalKey(KeyPressed key) public override void Dispose() { CancelProbe(); + CancelRevalidate(); OAuth.Dispose(); CurrentState.Dispose(); StatusMessage.Dispose(); diff --git a/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs new file mode 100644 index 000000000..4a41c744d --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorSession.cs @@ -0,0 +1,189 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Config; +using Netclaw.Cli.Json; +using Netclaw.Cli.Secrets; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Sections; + +/// +/// Shared merge pipeline for config leaf editors. It applies explicit editor +/// contributions to runtime config, secrets, and passive editor state. +/// +internal sealed class ConfigEditorSession +{ + private readonly NetclawPaths _paths; + private readonly ConfigEditorStateStore _stateStore; + private readonly bool _secretsFileExists; + private bool _secretsChanged; + + public ConfigEditorSession(NetclawPaths paths) + { + _paths = paths; + _stateStore = new ConfigEditorStateStore(paths); + _secretsFileExists = File.Exists(paths.SecretsPath); + Config = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); + Secrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); + } + + internal Dictionary Config { get; } + + internal Dictionary Secrets { get; } + + public void Apply(SectionContribution contribution) + { + ApplyFieldActions(Config, contribution); + _secretsChanged |= ApplySecretActions(Secrets, contribution); + _stateStore.Apply(contribution.StateActionsOrEmpty); + } + + public void Save() + { + _paths.EnsureDirectoriesExist(); + Config["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, Config); + + if (_secretsChanged && (_secretsFileExists || HasUserSecretData(Secrets))) + ConfigFileHelper.WriteSecretsFile(_paths, Secrets); + } + + internal static bool ApplyFieldActions(Dictionary config, SectionContribution contribution) + { + var changed = false; + foreach (var action in contribution.FieldActionsOrEmpty) + { + switch (action.Action) + { + case SectionFieldActionKind.Set: + ConfigFileHelper.SetPathValue(config, action.Path, action.Value); + changed = true; + break; + case SectionFieldActionKind.Delete: + changed |= ConfigFileHelper.RemovePath(config, action.Path); + break; + } + } + + return changed; + } + + internal static bool ApplySecretActions(Dictionary secrets, SectionContribution contribution) + { + var changed = false; + foreach (var action in contribution.SecretActionsOrEmpty) + { + switch (action.Action) + { + case SectionSecretActionKind.Preserve: + break; + case SectionSecretActionKind.Set: + SetSecretPathValue(secrets, action.Path, action.Value!); + changed = true; + break; + case SectionSecretActionKind.Delete: + changed |= RemoveSecretPath(secrets, action.Path); + break; + } + } + + return changed; + } + + internal static void ApplyEditorStateActions( + NetclawPaths paths, + IEnumerable contributions) + { + var stateStore = new ConfigEditorStateStore(paths); + foreach (var contribution in contributions) + stateStore.Apply(contribution.StateActionsOrEmpty); + } + + private static bool HasUserSecretData(Dictionary secrets) + => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); + + // Mirrors SecretsJsonUpdater's path-merge (colon-collision cleanup + nested upsert), but over the + // Dictionary shape ConfigFileHelper loads rather than a JsonObject. The two share + // ParseKeyPath; keep the collision cleanup below in sync with + // SecretsJsonUpdater.RemoveLiteralCollisionKeys. Note one deliberate difference: this engine + // rejects a scalar at an intermediate segment (GetOrCreateSection throws) instead of overwriting + // it the way SecretsJsonUpdater does — see ConfigEditorSessionTests for the pinned behavior. + private static void SetSecretPathValue(Dictionary secrets, string path, object value) + { + var segments = SecretsJsonUpdater.ParseKeyPath(path); + RemoveLiteralCollisionKeys(secrets, segments); + + var current = secrets; + for (var i = 0; i < segments.Length - 1; i++) + current = ConfigFileHelper.GetOrCreateSection(current, segments[i]); + + current[segments[^1]] = value; + } + + private static bool RemoveSecretPath(Dictionary secrets, string path) + { + var segments = SecretsJsonUpdater.ParseKeyPath(path); + var changed = RemovePathBySegments(secrets, segments); + changed |= RemoveLiteralCollisionKeys(secrets, segments); + return changed; + } + + private static bool RemovePathBySegments(Dictionary root, IReadOnlyList segments) + { + var current = root; + for (var i = 0; i < segments.Count - 1; i++) + { + var next = ConfigFileHelper.GetSectionOrNull(current, segments[i]); + if (next is null) + return false; + + current = next; + } + + var removed = current.Remove(segments[^1]); + if (removed) + PruneEmptySections(root, segments); + + return removed; + } + + private static bool RemoveLiteralCollisionKeys(Dictionary root, IReadOnlyList segments) + => RemoveLiteralCollisionKeys(root, segments, offset: 0); + + private static bool RemoveLiteralCollisionKeys(Dictionary current, IReadOnlyList segments, int offset) + { + var changed = false; + for (var end = offset + 2; end <= segments.Count; end++) + changed |= current.Remove(string.Join(':', segments.Skip(offset).Take(end - offset))); + + if (offset < segments.Count - 1 && ConfigFileHelper.GetSectionOrNull(current, segments[offset]) is { } child) + changed |= RemoveLiteralCollisionKeys(child, segments, offset + 1); + + return changed; + } + + private static void PruneEmptySections(Dictionary root, IReadOnlyList segments) + { + for (var depth = segments.Count - 1; depth > 0; depth--) + { + var parent = root; + for (var i = 0; i < depth - 1; i++) + { + var next = ConfigFileHelper.GetSectionOrNull(parent, segments[i]); + if (next is null) + return; + + parent = next; + } + + var key = segments[depth - 1]; + if (ConfigFileHelper.GetSectionOrNull(parent, key) is { Count: 0 }) + parent.Remove(key); + else + return; + } + } +} diff --git a/src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs b/src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs new file mode 100644 index 000000000..9853d3b69 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/ConfigEditorStateStore.cs @@ -0,0 +1,96 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Cli.Config; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Sections; + +/// +/// Passive editor-only state for values that must be dormant while inactive. +/// The daemon never reads this file; runtime config stays in netclaw.json. +/// +internal sealed class ConfigEditorStateStore(NetclawPaths paths) +{ + private const string FileName = "editor-state.json"; + private const string SectionsKey = "Sections"; + + private string StatePath => Path.Combine(paths.ConfigDirectory, FileName); + + internal void Apply(IEnumerable actions) + { + var actionList = actions.ToArray(); + if (actionList.Length == 0) + return; + + var state = LoadState(); + var sections = ConfigFileHelper.GetOrCreateSection(state, SectionsKey); + + foreach (var action in actionList) + { + var section = ConfigFileHelper.GetOrCreateSection(sections, action.SectionId); + switch (action.Action) + { + case SectionEditorStateActionKind.Set: + section[action.Key] = action.Value!; + break; + case SectionEditorStateActionKind.Delete: + section.Remove(action.Key); + break; + } + } + + WriteState(state); + } + + internal bool TryGetValue(string sectionId, string key, out object? value) + { + var state = LoadState(); + value = null; + + if (ConfigFileHelper.GetSectionOrNull(state, SectionsKey) is not { } sections + || ConfigFileHelper.GetSectionOrNull(sections, sectionId) is not { } section + || !section.TryGetValue(key, out var rawValue)) + { + return false; + } + + value = NormalizeValue(rawValue); + return true; + } + + private Dictionary LoadState() + { + if (!File.Exists(StatePath)) + return new Dictionary { ["configVersion"] = 1 }; + + return ConfigFileHelper.LoadJsonDict(StatePath); + } + + private void WriteState(Dictionary state) + { + Directory.CreateDirectory(paths.ConfigDirectory); + ConfigFileHelper.WriteConfigFile(StatePath, state); + } + + private static object? NormalizeValue(object? value) + => value switch + { + JsonElement element when element.ValueKind == JsonValueKind.Array + => JsonSerializer.Deserialize(element.GetRawText()), + JsonElement element when element.ValueKind == JsonValueKind.String + => element.GetString(), + JsonElement element when element.ValueKind == JsonValueKind.True + => true, + JsonElement element when element.ValueKind == JsonValueKind.False + => false, + JsonElement element when element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var longValue) + => longValue, + JsonElement element when element.ValueKind == JsonValueKind.Number + => element.GetDouble(), + _ => value + }; +} diff --git a/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs new file mode 100644 index 000000000..fd266bb3c --- /dev/null +++ b/src/Netclaw.Cli/Tui/Sections/SectionEditorInfrastructure.cs @@ -0,0 +1,184 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Wizard; +using Netclaw.Configuration; + +namespace Netclaw.Cli.Tui.Sections; + +/// +/// Reusable leaf editor contract shared by init-owned flows and future config surfaces. +/// The registry is intentionally flat at the leaf level and does not define dashboard IA. +/// +public interface ISectionEditor +{ + string SectionId { get; } + string DisplayName { get; } + string? Category { get; } + bool ShowInMenu { get; } + SectionStatus GetStatus(WizardContext context); + string Summary(WizardContext context); + IReadOnlyList RelevantDoctorChecks { get; } + IWizardStepViewModel CreateEditor(IServiceProvider services); + SectionContribution BuildContribution(IWizardStepViewModel editor); +} + +public enum SectionStatus +{ + NotConfigured, + Configured, + NeedsAttention, +} + +/// +/// Path-based merge instructions for one leaf editor. +/// Config and secret paths use dot-separated segments rooted at the top-level file object. +/// +public sealed record SectionContribution( + IReadOnlyList? FieldActions = null, + IReadOnlyList? SecretActions = null, + IReadOnlyList? StateActions = null) +{ + public static readonly SectionContribution Empty = new([], [], []); + + public IReadOnlyList FieldActionsOrEmpty => FieldActions ?? []; + public IReadOnlyList SecretActionsOrEmpty => SecretActions ?? []; + public IReadOnlyList StateActionsOrEmpty => StateActions ?? []; +} + +public sealed record SectionFieldAction(string Path, SectionFieldActionKind Action, object? Value = null); + +public sealed record SectionSecretAction +{ + public SectionSecretAction(string path, SectionSecretActionKind action, SensitiveString? value = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + if (action == SectionSecretActionKind.Set && value is null) + throw new ArgumentNullException(nameof(value), "Secret set actions require a SensitiveString value."); + + if (action != SectionSecretActionKind.Set && value is not null) + throw new ArgumentException("Only secret set actions may carry a value.", nameof(value)); + + Path = path; + Action = action; + Value = value; + } + + public string Path { get; } + public SectionSecretActionKind Action { get; } + public SensitiveString? Value { get; } +} + +public sealed record SectionEditorStateAction( + string SectionId, + string Key, + SectionEditorStateActionKind Action, + object? Value = null); + +public enum SectionFieldActionKind +{ + Set, + Delete, +} + +public enum SectionSecretActionKind +{ + Preserve, + Set, + Delete, +} + +public enum SectionEditorStateActionKind +{ + Set, + Delete, +} + +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class NoDoctorChecksAttribute(string justification) : Attribute +{ + public string Justification { get; } = justification; +} + +/// +/// Documents synthetic or init-owned surfaces that intentionally do not behave like config-menu entries. +/// Future routed handoff entries belong to the config command change and are audited separately. +/// +public static class SectionEditorExemptions +{ + public static readonly IReadOnlySet ConfigSmokeExemptions = + new HashSet(StringComparer.Ordinal) + { + "provider", + "identity" + }; +} + +public sealed record SectionEditorRegistration(Type ImplementationType); + +/// +/// Registry of reusable leaf editors. It validates duplicate IDs eagerly and does not imply any future menu hierarchy. +/// +public sealed class SectionEditorRegistry : IDisposable +{ + private readonly List _editors; + + public SectionEditorRegistry(IServiceProvider services, IEnumerable registrations) + { + _editors = []; + var ids = new HashSet(StringComparer.Ordinal); + + foreach (var registration in registrations) + { + var editor = (ISectionEditor)ActivatorUtilities.CreateInstance(services, registration.ImplementationType); + if (!ids.Add(editor.SectionId)) + { + throw new InvalidOperationException( + $"Duplicate section editor ID '{editor.SectionId}'. Leaf editor IDs must be unique."); + } + + _editors.Add(editor); + } + } + + public IReadOnlyList Editors => _editors; + + public ISectionEditor Get(string sectionId) + => _editors.FirstOrDefault(e => string.Equals(e.SectionId, sectionId, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Unknown section editor '{sectionId}'."); + + public void Dispose() + { + foreach (var editor in _editors.OfType()) + editor.Dispose(); + } +} + +public static class SectionEditorServiceCollectionExtensions +{ + public static IServiceCollection AddSectionEditor(this IServiceCollection services) + where TEditor : class, ISectionEditor + { + services.AddTransient(); + services.AddSingleton(new SectionEditorRegistration(typeof(TEditor))); + services.AddSingleton(); + return services; + } +} + +internal static class SectionEditorAudit +{ + public static string? GetDoctorCheckJustification(ISectionEditor editor) + => editor.GetType().GetCustomAttributes(typeof(NoDoctorChecksAttribute), inherit: false) + .OfType() + .FirstOrDefault() + ?.Justification; + + public static bool HasExistingConfig(WizardContext context, string path) + => context.ExistingConfig is not null && ConfigFileHelper.PathPresent(context.ExistingConfig, path); +} diff --git a/src/Netclaw.Cli/Tui/TuiNavigation.cs b/src/Netclaw.Cli/Tui/TuiNavigation.cs new file mode 100644 index 000000000..9e84fd5da --- /dev/null +++ b/src/Netclaw.Cli/Tui/TuiNavigation.cs @@ -0,0 +1,31 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina; + +namespace Netclaw.Cli.Tui; + +public sealed class TuiNavigation +{ + private TerminaApplication? _application; + + public void Attach(TerminaApplication application) + { + ArgumentNullException.ThrowIfNull(application); + _application = application; + } + + public bool TryGoBack() + { + if (_application is null) + throw new InvalidOperationException("TUI navigation was requested before TerminaApplication was attached."); + + if (!_application.CanGoBack) + return false; + + _application.GoBack(); + return true; + } +} diff --git a/src/Netclaw.Cli/Tui/Wizard/HealthCheckRunner.cs b/src/Netclaw.Cli/Tui/Wizard/HealthCheckRunner.cs index 1dbb3f83c..281484e5b 100644 --- a/src/Netclaw.Cli/Tui/Wizard/HealthCheckRunner.cs +++ b/src/Netclaw.Cli/Tui/Wizard/HealthCheckRunner.cs @@ -28,7 +28,10 @@ public HealthCheckRunner(List results, Action notifyChanged) /// public void Add(HealthCheckItem item) { - Results.Add(item); + // Results is read by the render thread while step checks mutate it off-thread; synchronize + // on the list instance — the same lock HealthCheckStepViewModel uses for its own writes. + lock (Results) + Results.Add(item); _notifyChanged(); } @@ -38,11 +41,44 @@ public void Add(HealthCheckItem item) /// public void UpdateLast(HealthCheckItem item) { - if (Results.Count > 0) - Results[^1] = item; + lock (Results) + { + if (Results.Count > 0) + Results[^1] = item; + } + _notifyChanged(); } + /// + /// Emit the standard channel-adapter pre-flight: an in-progress " + /// configuration" row, then short-circuit to a passed "(disabled)" row when the adapter is + /// off, or a failed "(<label> missing)" row for the first blank required credential + /// (checked in the order given). Returns true only when the adapter is enabled and + /// every required credential is present, i.e. the caller should continue probing. + /// + public bool BeginAdapterCheck(string name, bool enabled, params (string? value, string label)[] requiredCredentials) + { + Add(new HealthCheckItem($"{name} configuration", null)); + + if (!enabled) + { + UpdateLast(new HealthCheckItem($"{name} configuration (disabled)", true)); + return false; + } + + foreach (var (value, label) in requiredCredentials) + { + if (string.IsNullOrWhiteSpace(value)) + { + UpdateLast(new HealthCheckItem($"{name} configuration ({label} missing)", false)); + return false; + } + } + + return true; + } + /// /// Add a placeholder "in progress" item, then update it with the final result. /// @@ -61,5 +97,12 @@ public async Task RunCheckAsync(string label, FuncWhether all checks passed so far. - public bool AllPassed => Results.All(h => h.Passed == true); + public bool AllPassed + { + get + { + lock (Results) + return Results.All(h => h.Passed == true); + } + } } diff --git a/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs b/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs index d5bb8dd1d..8f28235c8 100644 --- a/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/IWizardStepView.cs @@ -33,6 +33,9 @@ public sealed class StepViewCallbacks /// Request a terminal redraw. public required Action RequestRedraw { get; init; } + /// Show a step-scoped validation or status message. + public Action? SetStatusMessage { get; init; } + /// Invalidate content and help, then request a redraw. public void InvalidateAndRedraw() { @@ -40,6 +43,16 @@ public void InvalidateAndRedraw() InvalidateHelp(); RequestRedraw(); } + + /// Show a validation error and redraw without advancing the step. + public void ShowValidationError(string message) + { + SetStatusMessage?.Invoke(message); + RequestRedraw(); + } + + /// Clear the step-scoped validation or status message. + public void ClearStatusMessage() => SetStatusMessage?.Invoke(string.Empty); } /// diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/BrowserAutomationStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/BrowserAutomationStepView.cs deleted file mode 100644 index adf70ac11..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/BrowserAutomationStepView.cs +++ /dev/null @@ -1,127 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; -using R3; -using Termina.Extensions; -using Termina.Input; -using Termina.Layout; -using Termina.Reactive; -using Termina.Terminal; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Termina view for the BrowserAutomation wizard step. -/// Sub-step 0: enable/disable selection. Sub-step 1: backend selection. -/// -public sealed class BrowserAutomationStepView : IWizardStepView -{ - private IDisposable? _enabledList; - private IDisposable? _backendList; - private IFocusable? _lastFocusedList; - - public string StepId => WizardStepIds.BrowserAutomation; - - public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) - { - var vm = (BrowserAutomationStepViewModel)stepVm; - - return vm.CurrentSubStep switch - { - 0 => BuildEnableSubStep(vm, callbacks), - 1 => BuildBackendSubStep(vm, callbacks), - _ => Layouts.Empty() - }; - } - - private ILayoutNode BuildEnableSubStep(BrowserAutomationStepViewModel vm, StepViewCallbacks callbacks) - { - var noOption = new SelectionOption(false, "No — skip browser automation for now"); - var yesOption = new SelectionOption(true, "Yes — configure browser MCP tools"); - - var enabledList = Layouts.SelectionList>( - [noOption, yesOption], static o => o.ToString()) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _enabledList = enabledList; - enabledList.OnFocused(); - _lastFocusedList = enabledList; - - enabledList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - vm.Enabled = selected[0].Value; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Enable browser automation MCP tools?").WithForeground(Color.White)) - .WithChild(enabledList); - } - - private ILayoutNode BuildBackendSubStep(BrowserAutomationStepViewModel vm, StepViewCallbacks callbacks) - { - var chromeLabel = vm.IsChromeDevToolsAvailable - ? "Chrome DevTools MCP" - : $"Chrome DevTools MCP (disabled - {vm.ChromeDevToolsUnavailableReason})"; - var chromeOption = new SelectionOption(BrowserAutomationBackend.ChromeDevTools, chromeLabel); - var playwrightOption = new SelectionOption(BrowserAutomationBackend.Playwright, "Playwright MCP"); - - var backendList = Layouts.SelectionList>( - [chromeOption, playwrightOption], static o => o.ToString()) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _backendList = backendList; - backendList.OnFocused(); - _lastFocusedList = backendList; - - backendList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - if (selected[0].Value == BrowserAutomationBackend.ChromeDevTools && !vm.IsChromeDevToolsAvailable) - { - // Can't select disabled option - return; - } - - vm.SelectedBackend = selected[0].Value; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Choose browser MCP backend:").WithForeground(Color.White)) - .WithChild(backendList); - } - - public bool HandleKeyPress(KeyPressed key) - { - if (_lastFocusedList is not null) - { - _lastFocusedList.HandleInput(key.KeyInfo); - return true; - } - return false; - } - - public void HandlePaste(PasteEvent paste) { } - - public void ClearFocusState() - { - _lastFocusedList = null; - _enabledList = null; - _backendList = null; - } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/BrowserAutomationStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/BrowserAutomationStepViewModel.cs deleted file mode 100644 index 1ea4caa88..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/BrowserAutomationStepViewModel.cs +++ /dev/null @@ -1,106 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Cli.Mcp; -using Netclaw.Configuration; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Wizard step for enabling browser automation and selecting the MCP backend. -/// Two sub-steps: enable/disable, then backend selection. -/// -public sealed class BrowserAutomationStepViewModel : IWizardStepViewModel -{ - private int _currentSubStep; - private int _highWaterSubStep; - - public string StepId => WizardStepIds.BrowserAutomation; - public string DisplayTitle => "Browser Automation"; - - public bool Enabled { get; set; } - public BrowserAutomationBackend SelectedBackend { get; set; } = BrowserAutomationBackend.Playwright; - public bool IsChromeDevToolsAvailable { get; } - public string ChromeDevToolsUnavailableReason { get; } - - public BrowserAutomationStepViewModel() - { - var detection = BrowserAutomationRuntimeDetector.DetectChrome(); - IsChromeDevToolsAvailable = detection.IsInstalled; - ChromeDevToolsUnavailableReason = detection.Reason ?? "local Chrome executable not found"; - } - - /// Test constructor. - internal BrowserAutomationStepViewModel(bool chromeAvailable, string chromeReason) - { - IsChromeDevToolsAvailable = chromeAvailable; - ChromeDevToolsUnavailableReason = chromeReason; - } - - public bool IsApplicable(WizardContext context) => true; - - public int CurrentSubStep => _currentSubStep; - public int SubStepCount => Enabled ? 2 : 1; - - public string GetHelpText() => _currentSubStep switch - { - 0 => " Optional. Enable this to let the agent delegate browser steering via MCP tools.", - 1 => " Playwright MCP is the default no-sudo path. Chrome DevTools is enabled only when a local Chrome executable is detected.", - _ => "" - }; - - public bool TryAdvance() - { - if (_currentSubStep == 0 && Enabled) - { - _currentSubStep = 1; - _highWaterSubStep = 1; - return true; - } - return false; - } - - public bool TryGoBack() - { - if (_currentSubStep > 0) - { - _currentSubStep--; - return true; - } - return false; - } - - public void OnEnter(WizardContext context, NavigationDirection direction) - { - if (direction == NavigationDirection.Back) - _currentSubStep = _highWaterSubStep; - else - _currentSubStep = 0; - } - - public void OnLeave() { } - - public void ContributeConfig(WizardConfigBuilder builder) - { - if (Enabled) - { - builder.BrowserAutomation = new BrowserAutomationConfigSection - { - Enabled = true, - Backend = SelectedBackend - }; - } - } - - public void ContributeSecrets(WizardSecretsBuilder builder) { } - - public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) - { - // Browser bootstrap health check will be added when HealthCheck step is extracted - return Task.CompletedTask; - } - - public void Dispose() { } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs index 93c83fe16..a451e5d50 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepView.cs @@ -12,7 +12,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// /// Termina view for the unified channel picker step. -/// Picker mode: checklist with ↑/↓ cursor, Space to toggle, Enter/E to configure, D to finish. +/// Picker mode: checklist with ↑/↓ cursor, Space to toggle, Enter/E to configure, configurable done key to finish. /// Sub-flow mode: delegates rendering and input to the active adapter's view. /// public sealed class ChannelPickerStepView : IWizardStepView @@ -75,12 +75,31 @@ private ILayoutNode BuildPickerChecklist() layout = layout.WithChild(node); } + if (_vm.ShowDonePickerRow) + { + var isFocused = _vm.IsDonePickerRowSelected; + var prefix = isFocused ? " ▶ " : " "; + var node = new TextNode($"{prefix}{_vm.DonePickerRowLabel,-24} Return to Settings Areas"); + node = isFocused + ? node.WithForeground(Color.Cyan).Bold() + : node.WithForeground(Color.White); + layout = layout.WithChild(node); + } + layout = layout.WithSpacing(1); var hasConfigured = _vm.AnyAdapterConfigured; var hintText = hasConfigured - ? " ↑/↓ to navigate, Space to toggle, Enter to configure selected.\n [e] Edit configured channel [d] Done — continue to next step" - : " ↑/↓ to navigate, Space to toggle, Enter to configure selected.\n [d] Done — continue to next step"; + ? " ↑/↓ to navigate, Space to toggle, Enter to open selected.\n [e] Edit configured channel" + : " ↑/↓ to navigate, Space to toggle, Enter to configure selected."; + if (_vm.ShowDonePickerRow) + hintText += "\n Select Done when finished; completed changes are already saved."; + if (_vm.ShowDoneAction) + { + hintText += hasConfigured + ? $" [{_vm.DoneKeyLabel}] {_vm.DoneKeyActionLabel} - {_vm.DoneActionText}" + : $"\n [{_vm.DoneKeyLabel}] {_vm.DoneKeyActionLabel} - {_vm.DoneActionText}"; + } layout = layout.WithChild(new TextNode(hintText).WithForeground(Color.BrightBlack)); @@ -99,6 +118,18 @@ public bool HandleKeyPress(KeyPressed key) var keyInfo = key.KeyInfo; var adapters = _vm.Adapters; + if (_vm.ShowDoneAction && keyInfo.Key == _vm.DoneKey) + { + _callbacks.AdvanceStep(); + return true; + } + + if (_vm.ShowDonePickerRow && keyInfo.Key == _vm.DoneKey) + { + _callbacks.AdvanceStep(); + return true; + } + switch (keyInfo.Key) { case ConsoleKey.UpArrow: @@ -108,17 +139,26 @@ public bool HandleKeyPress(KeyPressed key) return true; case ConsoleKey.DownArrow: - if (_vm.CursorIndex < adapters.Count - 1) + if (_vm.CursorIndex < _vm.PickerRowCount - 1) _vm.CursorIndex++; _callbacks.InvalidateAndRedraw(); return true; case ConsoleKey.Spacebar: + if (!_vm.IsAdapterRowSelected) + return true; + _vm.ToggleAdapter(_vm.CursorIndex); _callbacks.InvalidateAndRedraw(); return true; case ConsoleKey.Enter: + if (_vm.IsDonePickerRowSelected) + { + _callbacks.AdvanceStep(); + return true; + } + if (_vm.IsAdapterEnabled(_vm.CursorIndex)) { // Re-enter sub-flow for editing @@ -132,10 +172,6 @@ public bool HandleKeyPress(KeyPressed key) _callbacks.InvalidateAndRedraw(); return true; - case ConsoleKey.D: - _callbacks.AdvanceStep(); - return true; - case ConsoleKey.E: if (_vm.IsAdapterEnabled(_vm.CursorIndex)) { diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs index f3884e921..44c32e1b6 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelPickerStepViewModel.cs @@ -6,6 +6,7 @@ using Netclaw.Actors.Channels; using Netclaw.Channels.Slack; using Netclaw.Cli.Discord; +using Netclaw.Cli.Mattermost; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -31,12 +32,13 @@ private enum Mode { Picker, SubFlow } private readonly List _adapters; private readonly Dictionary _enabled = []; private readonly Dictionary _summaries = []; + private readonly HashSet _knownAdapters = []; - public ChannelPickerStepViewModel(ISlackProbe slackProbe, IDiscordProbe discordProbe) + public ChannelPickerStepViewModel(ISlackProbe slackProbe, IDiscordProbe discordProbe, IMattermostProbe mattermostProbe) { var slackVm = new SlackStepViewModel(slackProbe) { SkipEnableSubStep = true }; var discordVm = new DiscordStepViewModel(discordProbe) { SkipEnableSubStep = true }; - var mattermostVm = new MattermostStepViewModel { SkipEnableSubStep = true }; + var mattermostVm = new MattermostStepViewModel(mattermostProbe) { SkipEnableSubStep = true }; _adapters = [ @@ -66,20 +68,91 @@ public ChannelPickerStepViewModel(ISlackProbe slackProbe, IDiscordProbe discordP internal int CursorIndex { get => _cursorIndex; - set => _cursorIndex = Math.Clamp(value, 0, Math.Max(_adapters.Count - 1, 0)); + set => _cursorIndex = Math.Clamp(value, 0, Math.Max(PickerRowCount - 1, 0)); } internal IWizardStepViewModel? ActiveAdapterVm => _activeAdapter?.Vm; internal IWizardStepView? ActiveAdapterView => _activeAdapter?.View; + internal ChannelType? ActiveAdapterType => _activeAdapter?.Type; + internal ChannelType SelectedAdapterType => _adapters[CursorIndex].Type; + internal string SelectedAdapterDisplayName => _adapters[CursorIndex].DisplayName; + internal int PickerRowCount => _adapters.Count + (ShowDonePickerRow ? 1 : 0); + internal bool IsAdapterRowSelected => CursorIndex < _adapters.Count; + internal bool IsDonePickerRowSelected => ShowDonePickerRow && CursorIndex == _adapters.Count; + + internal string DoneActionText { get; set; } = "continue to next step"; + internal string DoneKeyActionLabel { get; set; } = "Done"; + internal ConsoleKey DoneKey { get; set; } = ConsoleKey.D; + internal bool ShowDoneAction { get; set; } = true; + internal bool ShowDonePickerRow { get; set; } + internal string DonePickerRowLabel { get; set; } = "Done"; + internal string DoneKeyLabel => DoneKey switch + { + ConsoleKey.D => "d", + ConsoleKey.S => "s", + _ => DoneKey.ToString() + }; + + internal bool PreserveDisabledAdapterDrafts { get; set; } internal bool IsAdapterEnabled(int index) => index >= 0 && index < _adapters.Count && _enabled[_adapters[index].Type]; + internal bool IsAdapterEnabled(ChannelType type) => + _enabled.TryGetValue(type, out var enabled) && enabled; + + internal bool IsAdapterKnown(ChannelType type) => _knownAdapters.Contains(type); + + internal TAdapter GetAdapterViewModel(ChannelType type) + where TAdapter : class, IWizardStepViewModel + => _adapters.Single(a => a.Type == type).Vm as TAdapter + ?? throw new InvalidOperationException($"Channel adapter '{type}' is not a {typeof(TAdapter).Name}."); + + internal void LoadAdapterState( + ChannelType type, + bool enabled, + string? summary, + Action configure, + bool isKnown = false) + { + var adapter = _adapters.Single(a => a.Type == type); + _enabled[type] = enabled; + SetChildEnabled(adapter, enabled); + configure(adapter.Vm); + + if (isKnown) + _knownAdapters.Add(type); + else + _knownAdapters.Remove(type); + + if (summary is null) + _summaries.Remove(type); + else + _summaries[type] = summary; + } + + internal void ResetAdapterState(ChannelType type) + { + var adapter = _adapters.Single(a => a.Type == type); + _enabled[type] = false; + _knownAdapters.Remove(type); + _summaries.Remove(type); + ResetChildConfig(adapter); + } + internal string? GetAdapterSummary(int index) => index >= 0 && index < _adapters.Count && _summaries.TryGetValue(_adapters[index].Type, out var summary) ? summary : null; + internal void SetAdapterSummary(ChannelType type, string? summary) + { + if (summary is null) + _summaries.Remove(type); + else + _summaries[type] = summary; + } + internal bool AnyAdapterConfigured => _summaries.Count > 0; internal void ToggleAdapter(int index) @@ -89,17 +162,27 @@ internal void ToggleAdapter(int index) if (_enabled[adapter.Type]) { - // Toggling OFF — clear config + // Config-editor toggles disable without throwing away dormant setup. _enabled[adapter.Type] = false; - _summaries.Remove(adapter.Type); - ResetChildConfig(adapter); + SetChildEnabled(adapter, false); + if (PreserveDisabledAdapterDrafts && _knownAdapters.Contains(adapter.Type)) + { + _summaries[adapter.Type] = "disabled, saved setup"; + } + else + { + _summaries.Remove(adapter.Type); + ResetChildConfig(adapter); + } } else { - // Toggling ON — enter sub-flow _enabled[adapter.Type] = true; SetChildEnabled(adapter, true); - EnterSubFlow(adapter); + if (PreserveDisabledAdapterDrafts && _knownAdapters.Contains(adapter.Type)) + _summaries[adapter.Type] = ComputeSummary(adapter); + else + EnterSubFlow(adapter); } } @@ -118,7 +201,12 @@ public string GetHelpText() if (_mode == Mode.SubFlow && _activeAdapter is not null) return _activeAdapter.Vm.GetHelpText(); - return " Select which communication channels to connect. Press [d] when done."; + if (ShowDonePickerRow) + return " Select which communication channels to connect. Use Done when finished."; + + return ShowDoneAction + ? $" Select which communication channels to connect. Press [{DoneKeyLabel}] when done." + : " Select which communication channels to connect. Completed actions save automatically."; } public bool TryAdvance() @@ -223,6 +311,7 @@ private void CompleteSubFlow() { var adapter = _activeAdapter!; _summaries[adapter.Type] = ComputeSummary(adapter); + _knownAdapters.Add(adapter.Type); _mode = Mode.Picker; _activeAdapter = null; } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelsStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelsStepView.cs deleted file mode 100644 index a13a3c404..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelsStepView.cs +++ /dev/null @@ -1,250 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using System.Collections.Immutable; -using Netclaw.Actors.Channels; -using Netclaw.Cli.Tui; -using Netclaw.Configuration; -using R3; -using Termina.Extensions; -using Termina.Input; -using Termina.Layout; -using Termina.Rendering; -using Termina.Terminal; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Termina view for the Channels wizard step. -/// Custom keyboard navigation: ↑/↓ cursor, ←/→ audience cycling, a/d add/delete, Enter to continue. -/// -public sealed class ChannelsStepView : IWizardStepView -{ - // Most-trusted-first cycling order (Personal → Team → Public). Sourced - // from TrustAudiences.All so new audiences flow through automatically. - private static readonly ImmutableArray AudienceValues = - [.. TrustAudiences.All.Reverse()]; - - private int _cursorIndex; - private bool _addMode; - private TextInputNode? _addInput; - private TextInputBaseNode? _lastFocusedInput; - private StepViewCallbacks? _callbacks; - private ChannelsStepViewModel? _vm; - - public string StepId => WizardStepIds.Channels; - public bool ManagesOwnFocusState => true; - public bool CapturesInput => true; - - /// Whether the view is in add-channel mode. Exposed for headless testing. - internal bool IsAddMode => _addMode; - - public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) - { - _callbacks = callbacks; - _vm = (ChannelsStepViewModel)stepVm; - return BuildChannelList(callbacks); - } - - private ILayoutNode BuildChannelList(StepViewCallbacks callbacks) - { - if (_addMode && _addInput is not null) - { - return Layouts.Vertical() - .WithChild(new TextNode(" Add channel:").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_addInput, "Channel Name")) - .WithSpacing(1) - .WithChild(new TextNode(" Enter to add, Esc to cancel.") - .WithForeground(Color.BrightBlack)); - } - - var entries = _vm?.AllEntries ?? []; - - if (entries.Count == 0) - { - return Layouts.Vertical() - .WithChild(new TextNode(" No channels configured.").WithForeground(Color.Yellow)) - .WithChild(new TextNode(" Press [a] to add a channel, or Enter to continue.") - .WithForeground(Color.BrightBlack)); - } - - if (_cursorIndex >= entries.Count) _cursorIndex = entries.Count - 1; - if (_cursorIndex < 0) _cursorIndex = 0; - - var layout = Layouts.Vertical() - .WithChild(new TextNode(" Chat channels:").WithForeground(Color.White)) - .WithSpacing(1); - - var nameColumnWidth = Math.Max(20, entries.Max(e => EntryDisplayName(e).Length) + 2); - - var showHeaders = _vm!.HasMultipleSources; - var entryIndex = 0; - - foreach (var (source, groupEntries) in _vm.GroupedEntries) - { - if (showHeaders) - { - var label = $" \u2500\u2500 {source} \u2500\u2500"; - layout = layout.WithChild( - new TextNode($" {label}").WithForeground(Color.BrightBlack)); - } - - foreach (var entry in groupEntries) - { - var isFocused = entryIndex == _cursorIndex; - var prefix = isFocused ? " \u25b6 " : " "; - var name = EntryDisplayName(entry).PadRight(nameColumnWidth); - var audience = $"[\u25c0 {entry.Audience.ToWireValue(),-8} \u25b6]"; - var line = $"{prefix}{name} {audience}"; - - var node = new TextNode(line); - node = isFocused - ? node.WithForeground(Color.Cyan).Bold() - : node.WithForeground(Color.White); - layout = layout.WithChild(node); - entryIndex++; - } - } - - layout = layout.WithSpacing(1) - .WithChild(new TextNode(" [a] Add channel [d] Remove channel") - .WithForeground(Color.BrightBlack)); - - return layout; - } - - public bool HandleKeyPress(KeyPressed key) - { - var keyInfo = key.KeyInfo; - var entries = _vm?.AllEntries ?? []; - - // Add-channel mode - if (_addMode) - { - if (keyInfo.Key == ConsoleKey.Escape) - { - _addMode = false; - _addInput = null; - _lastFocusedInput = null; - _callbacks?.InvalidateAndRedraw(); - return true; - } - - if (_addInput is not null) - { - if (keyInfo.Key == ConsoleKey.Enter) - { - var text = _addInput.Text?.Trim().TrimStart('#'); - if (!string.IsNullOrWhiteSpace(text) && _vm is not null) - { - var posture = _vm.SelectedPosture; - var audience = posture == DeploymentPosture.Public - ? TrustAudience.Public - : TrustAudience.Team; - - if (!entries.Any(e => - e.DisplayName.Equals($"#{text}", StringComparison.OrdinalIgnoreCase))) - { - var source = _vm.GetPreferredAddSource(); - _vm.AddEntry(source, new ChannelEntry($"#{text}", text, audience)); - } - } - - _addMode = false; - _addInput = null; - _lastFocusedInput = null; - _callbacks?.InvalidateAndRedraw(); - return true; - } - - _addInput.HandleInput(keyInfo); - _callbacks?.RequestRedraw(); - } - return true; - } - - // Normal mode — Escape is not consumed here so it falls through to back-navigation - if (keyInfo.Key == ConsoleKey.Escape) - return false; - - switch (keyInfo.Key) - { - case ConsoleKey.UpArrow: - if (_cursorIndex > 0) _cursorIndex--; - break; - - case ConsoleKey.DownArrow: - if (_cursorIndex < entries.Count - 1) _cursorIndex++; - break; - - case ConsoleKey.RightArrow: - if (entries.Count > 0) - { - var entry = entries[_cursorIndex]; - var idx = AudienceValues.IndexOf(entry.Audience); - entry.Audience = AudienceValues[(idx + 1) % AudienceValues.Length]; - } - break; - - case ConsoleKey.LeftArrow: - if (entries.Count > 0) - { - var entry = entries[_cursorIndex]; - var idx = AudienceValues.IndexOf(entry.Audience); - entry.Audience = AudienceValues[(idx - 1 + AudienceValues.Length) % AudienceValues.Length]; - } - break; - - case ConsoleKey.A: - _addMode = true; - _addInput = new TextInputNode().WithPlaceholder("channel-name"); - _addInput.OnFocused(); - _lastFocusedInput = _addInput; - break; - - case ConsoleKey.D: - if (entries.Count > 0 && !entries[_cursorIndex].IsDmRow && _vm is not null) - { - _vm.RemoveEntry(entries[_cursorIndex]); - // Re-fetch entries after removal for cursor clamping - var remaining = _vm.AllEntries; - if (_cursorIndex >= remaining.Count && remaining.Count > 0) - _cursorIndex = remaining.Count - 1; - } - break; - - case ConsoleKey.Enter: - _callbacks?.AdvanceStep(); - return true; - - default: - return false; - } - - _callbacks?.InvalidateAndRedraw(); - return true; - } - - public void HandlePaste(PasteEvent paste) - { - _lastFocusedInput?.HandlePaste(paste); - } - - public void ClearFocusState() - { - _lastFocusedInput = null; - _addInput = null; - _addMode = false; - _cursorIndex = 0; - } - - private static string EntryDisplayName(ChannelEntry entry) - { - var name = entry.DisplayName; - if (name.StartsWith("Discord:", StringComparison.Ordinal)) - name = name["Discord:".Length..]; - return name; - } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelsStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelsStepViewModel.cs deleted file mode 100644 index 23cc6cb05..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ChannelsStepViewModel.cs +++ /dev/null @@ -1,156 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Actors.Channels; -using Netclaw.Configuration; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Wizard step for per-channel audience configuration. -/// Conditionally shown only when at least one chat service is enabled. -/// Single sub-step with custom keyboard navigation (arrow keys, a/d). -/// -/// Channel entries are keyed by source ("slack", "discord", etc.) in the -/// shared context. Each channel step populates its own bucket in OnLeave. -/// This step renders all entries flattened across sources, grouped for display. -/// -public sealed class ChannelsStepViewModel : IWizardStepViewModel -{ - private WizardContext? _context; - - public string StepId => WizardStepIds.Channels; - public string DisplayTitle => "Channels"; - - /// - /// Flattened view of all channel entries across all sources. - /// The view reads this for rendering and keyboard navigation. - /// - public List AllEntries - { - get - { - if (_context is null) return []; - var all = new List(); - foreach (var entries in _context.ChannelEntries.Values) - all.AddRange(entries); - return all; - } - } - - public bool HasMultipleSources => - _context is not null && _context.ChannelEntries.Count > 1; - - public IReadOnlyList<(ChannelType Source, List Entries)> GroupedEntries - { - get - { - if (_context is null) return []; - return _context.ChannelEntries - .Where(kv => kv.Value.Count > 0) - .Select(kv => (kv.Key, kv.Value)) - .ToList(); - } - } - - /// The selected posture from the shared context, for deriving audience defaults. - public DeploymentPosture SelectedPosture => _context?.SelectedPosture ?? DeploymentPosture.Personal; - - public bool IsApplicable(WizardContext context) => context.AnyChatServicesEnabled; - - public int CurrentSubStep => 0; - public int SubStepCount => 1; - - public string GetHelpText() => - " Use \u2190/\u2192 to change audience. a to add, d to remove. Enter to continue."; - - public bool TryAdvance() => false; - public bool TryGoBack() => false; - - public void OnEnter(WizardContext context, NavigationDirection direction) - { - _context = context; - } - - public void OnLeave() { } - - public void ContributeConfig(WizardConfigBuilder builder) - { - // Channel audiences are set per-source by each channel step's ContributeConfig. - // This step just allows editing the entries in the shared context. - } - - public void ContributeSecrets(WizardSecretsBuilder builder) { } - - public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) - => Task.CompletedTask; - - /// - /// Add a channel entry to a specific source bucket. - /// Called by the Channels view when the user adds a channel manually. - /// - public void AddEntry(ChannelType source, ChannelEntry entry) - { - if (_context is null) return; - if (!_context.ChannelEntries.TryGetValue(source, out var entries)) - { - entries = []; - _context.ChannelEntries[source] = entries; - } - entries.Add(entry); - } - - /// - /// Remove a channel entry by reference from any source bucket. - /// - public bool RemoveEntry(ChannelEntry entry) - { - if (_context is null) return false; - foreach (var entries in _context.ChannelEntries.Values) - { - if (entries.Remove(entry)) - return true; - } - return false; - } - - /// - /// Get the source key for a given entry (for display grouping). - /// - public ChannelType? GetSource(ChannelEntry entry) - { - if (_context is null) return null; - foreach (var (source, entries) in _context.ChannelEntries) - { - if (entries.Contains(entry)) - return source; - } - return null; - } - - /// - /// Preferred source for new entries added from the Channels view. - /// When a single chat source is configured, additions go to that source. - /// When multiple sources exist, prefer Slack for compatibility. - /// - public ChannelType GetPreferredAddSource() - { - if (_context is null || _context.ChannelEntries.Count == 0) - return ChannelType.Slack; - - if (_context.ChannelEntries.Count == 1) - return _context.ChannelEntries.Keys.First(); - - if (_context.ChannelEntries.ContainsKey(ChannelType.Slack)) - return ChannelType.Slack; - - if (_context.ChannelEntries.ContainsKey(ChannelType.Discord)) - return ChannelType.Discord; - - return _context.ChannelEntries.Keys.First(); - } - - public void Dispose() { } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs index fd49d8fc8..7167834b2 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepView.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; using R3; using Termina.Extensions; using Termina.Input; @@ -19,6 +20,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// public sealed class DiscordStepView : IWizardStepView { + private DiscordStepViewModel? _vm; private SelectionListNode? _enabledList; private TextInputNode? _botTokenInput; private TextInputNode? _channelIdsInput; @@ -33,6 +35,7 @@ public sealed class DiscordStepView : IWizardStepView public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) { var vm = (DiscordStepViewModel)stepVm; + _vm = vm; return vm.CurrentSubStep switch { @@ -80,36 +83,59 @@ private ILayoutNode BuildBotTokenSubStep(DiscordStepViewModel vm, StepViewCallba _botTokenInput = new TextInputNode() .AsPassword() .WithPlaceholder("Discord bot token"); + WizardStepHelpers.SeedTextInput(_botTokenInput, vm.BotTokenDraft ?? vm.BotToken); _botTokenInput.OnFocused(); _lastFocusedInput = _botTokenInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_botTokenInput, StageFocusedInput, callbacks); _botTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + vm.BotTokenDraft = null; + if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + { + callbacks.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.DiscordBotTokenRequired); + } + + return; + } + vm.BotToken = text; + vm.BotTokenDraft = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Discord Bot Token:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_botTokenInput, "Bot Token")); + + if (vm.HasPersistedBotToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildChannelIdsSubStep(DiscordStepViewModel vm, StepViewCallbacks callbacks) { _channelIdsInput = new TextInputNode() .WithPlaceholder("123456789012345678, 223456789012345678 (leave blank to skip)"); - - if (!string.IsNullOrWhiteSpace(vm.ChannelIdsInput)) - _channelIdsInput.Text = vm.ChannelIdsInput; + WizardStepHelpers.SeedTextInput(_channelIdsInput, vm.ChannelIdsInput); _channelIdsInput.OnFocused(); _lastFocusedInput = _channelIdsInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_channelIdsInput, StageFocusedInput, callbacks); _channelIdsInput.Submitted .Subscribe(text => @@ -169,13 +195,12 @@ private ILayoutNode BuildAllowedUserIdsSubStep(DiscordStepViewModel vm, StepView { _allowedUserIdsInput = new TextInputNode() .WithPlaceholder("129847561203948576, 130111223344556677 (Discord user IDs)"); - - if (!string.IsNullOrWhiteSpace(vm.AllowedUserIdsInput)) - _allowedUserIdsInput.Text = vm.AllowedUserIdsInput; + WizardStepHelpers.SeedTextInput(_allowedUserIdsInput, vm.AllowedUserIdsInput); _allowedUserIdsInput.OnFocused(); _lastFocusedInput = _allowedUserIdsInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_allowedUserIdsInput, StageFocusedInput, callbacks); _allowedUserIdsInput.Submitted .Where(text => !string.IsNullOrWhiteSpace(text)) @@ -202,6 +227,8 @@ public bool HandleKeyPress(KeyPressed key) if (_lastFocusedInput is not null) { _lastFocusedInput.HandleInput(key.KeyInfo); + if (key.KeyInfo.Key != ConsoleKey.Enter) + StageFocusedInput(); return true; } @@ -211,6 +238,20 @@ public bool HandleKeyPress(KeyPressed key) public void HandlePaste(PasteEvent paste) { _lastFocusedInput?.HandlePaste(paste); + StageFocusedInput(); + } + + private void StageFocusedInput() + { + if (_vm is null) + return; + + if (ReferenceEquals(_lastFocusedInput, _botTokenInput)) + _vm.BotTokenDraft = _botTokenInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _channelIdsInput)) + _vm.ChannelIdsInput = _channelIdsInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _allowedUserIdsInput)) + _vm.AllowedUserIdsInput = _allowedUserIdsInput?.Text; } public void ClearFocusState() diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs index ba819be6a..44a5a7dd9 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/DiscordStepViewModel.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Text.Json; using Netclaw.Actors.Channels; using Netclaw.Cli.Discord; using Netclaw.Configuration; @@ -20,6 +21,7 @@ public sealed class DiscordStepViewModel : IWizardStepViewModel, IChannelAdapter private int _highWaterSubStep; private WizardContext? _context; private CancellationTokenSource? _resolutionCts; + private Task? _resolutionTask; public DiscordStepViewModel(IDiscordProbe discordProbe) { @@ -41,6 +43,8 @@ bool IChannelAdapterViewModel.AdapterEnabled ParseChannelIds(ChannelIdsInput).Count; public string? BotToken { get; set; } + internal string? BotTokenDraft { get; set; } + public bool HasPersistedBotToken { get; set; } public string? ChannelIdsInput { get; set; } public bool AllowDirectMessages { get; set; } public bool RestrictToSpecificUsers { get; set; } @@ -131,6 +135,7 @@ internal void ResetConfig() { DiscordEnabled = false; BotToken = null; + BotTokenDraft = null; ChannelIdsInput = null; AllowDirectMessages = false; RestrictToSpecificUsers = false; @@ -160,19 +165,11 @@ public void OnLeave() if (AllowDirectMessages) { var allowedUsers = ParseUserIds(AllowedUserIdsInput); - var dmAudience = allowedUsers.Count == 1 - ? TrustAudience.Personal - : posture == DeploymentPosture.Personal - ? TrustAudience.Personal - : posture == DeploymentPosture.Team - ? TrustAudience.Team - : TrustAudience.Public; + var dmAudience = ChannelAudienceDefaults.ForDirectMessage(posture, allowedUsers.Count); entries.Add(new ChannelEntry("Discord DMs", "dm", dmAudience, isDmRow: true)); } - var channelAudience = posture == DeploymentPosture.Public - ? TrustAudience.Public - : TrustAudience.Team; + var channelAudience = ChannelAudienceDefaults.ForChannel(posture); var channelIds = ParseChannelIds(ChannelIdsInput); foreach (var channelId in channelIds) @@ -189,14 +186,20 @@ public void ContributeConfig(WizardConfigBuilder builder) if (!DiscordEnabled) return; - var channelIds = ParseChannelIds(ChannelIdsInput); var userIds = ParseUserIds(AllowedUserIdsInput); + // Persist only canonical channel IDs the runtime ACL can match. An unresolved channel + // reference (a name/id the bot can't see) is omitted, not written verbatim — an + // unmatchable entry in AllowedChannelIds is inert and grants nothing. Mirrors Slack/Mattermost. + var resolvedChannelIds = LastChannelResolution is { Resolved.Count: > 0 } resolution + ? resolution.Resolved.Select(channel => channel.ChannelId).ToList() + : new List(); + builder.Discord = new DiscordConfigSection { Enabled = true, - DefaultChannelId = channelIds.FirstOrDefault(), - AllowedChannelIds = channelIds.Count > 0 ? channelIds : null, + DefaultChannelId = resolvedChannelIds.FirstOrDefault(), + AllowedChannelIds = resolvedChannelIds.Count > 0 ? resolvedChannelIds : null, AllowDirectMessages = AllowDirectMessages, AllowedUserIds = userIds.Count > 0 ? userIds : null, ChannelAudiences = BuildChannelAudiences() @@ -216,19 +219,14 @@ public void ContributeSecrets(WizardSecretsBuilder builder) public async Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) { - runner.Add(new HealthCheckItem("Discord configuration", null)); - - if (!DiscordEnabled) - { - runner.UpdateLast(new HealthCheckItem("Discord configuration (disabled)", true)); - return; - } + // A background channel-name prefetch may still be in flight (kicked off when the user + // advanced past the channel-IDs sub-step). Await it first so its LastChannelResolution + // publish is observed here rather than racing the read below — and so its work is reused + // instead of re-resolved. + await AwaitPendingResolutionAsync(); - if (string.IsNullOrWhiteSpace(BotToken)) - { - runner.UpdateLast(new HealthCheckItem("Discord configuration (bot token missing)", false)); + if (!runner.BeginAdapterCheck("Discord", DiscordEnabled, (BotToken, "bot token"))) return; - } bool discordAuthOk; try @@ -309,36 +307,67 @@ private void StartBackgroundChannelResolution() _resolutionCts?.Cancel(); _resolutionCts?.Dispose(); _resolutionCts = new CancellationTokenSource(); - var ct = _resolutionCts.Token; var token = BotToken!; var context = _context; - _ = Task.Run(async () => - { - try - { - var result = await _discordProbe.ResolveChannelIdsAsync(token, channelIds, ct); - if (ct.IsCancellationRequested) - return; + // Track the prefetch instead of firing Task.Run-and-forget. The network probe runs off the + // loop, but its only published state is LastChannelResolution — an atomic reference write + // guarded by the cancellation token. ChannelEntry.DisplayName is NOT mutated from this + // background task; the resolved names are applied on the loop thread (OnLeave / + // ApplyResolvedDisplayNamesToContext), so the render thread never reads a ChannelEntry that + // a pool thread is concurrently mutating. The tracked task lets the health-check phase await + // the publish before reading it. + _resolutionTask = ResolveChannelLabelsAsync(token, channelIds, context, _resolutionCts.Token); + } - LastChannelResolution = result; + private async Task ResolveChannelLabelsAsync( + string token, IReadOnlyList channelIds, WizardContext? context, CancellationToken ct) + { + try + { + var result = await _discordProbe.ResolveChannelIdsAsync(token, channelIds, ct); + + // Re-check cancellation right before the publish. This narrows — but cannot fully + // close — the window where a probe the user superseded (by editing the channel IDs + // and re-advancing) lands a stale result; a late publish here is at worst a + // cosmetically-wrong display name applied on the loop thread, never a crash, and the + // authoritative resolution in ContributeHealthChecksAsync re-resolves as the source + // of truth. Do not null out a prior result on cancellation/error below — a superseded + // probe must not clobber a still-valid resolution. + if (ct.IsCancellationRequested) + return; - if (context is not null && - context.ChannelEntries.TryGetValue(ChannelType.Discord, out var entries)) - { - ApplyResolvedDisplayNames(entries); - context.RequestRedraw(); - } - } - catch (OperationCanceledException) - { - LastChannelResolution = null; - } - catch (HttpRequestException) - { + // Atomic reference publish; OnLeave / ContributeHealthChecksAsync apply the display + // names to ChannelEntries on the loop thread. + LastChannelResolution = result; + context?.RequestRedraw(); + } + catch (Exception ex) when (ex is OperationCanceledException or HttpRequestException or JsonException) + { + // Best-effort cosmetic prefetch: cancellation/supersession and a probe failure (network, + // or a non-JSON Discord error body that fails to parse) are normal runtime conditions that + // must never fault the loop. Only the EXPECTED probe exceptions are caught here — an + // unexpected fault still surfaces (via the tracked task / health-check re-resolve) rather + // than being silently swallowed. On a genuine failure of the *current* probe clear our + // stale state so a later read re-resolves; on cancellation leave any newer result intact + // (a superseded probe must not clobber a still-valid resolution). + if (!ct.IsCancellationRequested) LastChannelResolution = null; - } - }, ct); + } + } + + // Exposes the in-flight background channel-name prefetch so the health-check phase can observe + // its publish on the loop thread, and so tests can await it without polling. + internal Task? PendingResolution => _resolutionTask; + + // Awaits the in-flight prefetch (if any) so LastChannelResolution is published before a + // loop-thread reader inspects it. The prefetch swallows its own probe failures, so the awaited + // task always completes successfully — this never throws. + private async Task AwaitPendingResolutionAsync() + { + var inFlight = _resolutionTask; + if (inFlight is not null) + await inFlight; } private void ApplyResolvedDisplayNames(List entries) @@ -375,11 +404,41 @@ private void ApplyResolvedDisplayNamesToContext() var audiences = new Dictionary(StringComparer.Ordinal); foreach (var entry in entries) - audiences[entry.Id] = entry.Audience.ToWireValue(); + { + // Only write an audience under a key the runtime ACL can match — a resolved channel ID + // or the literal "dm" DM key. An unresolved channel reference is a dead key the runtime + // never matches, so omit it instead of silently writing inert ACL config (a + // no-silent-fallback violation on a security path). Mirrors Slack/Mattermost. + if (TryResolveChannelAudienceKey(entry, out var key)) + audiences[key] = entry.Audience.ToWireValue(); + } return audiences.Count > 0 ? audiences : null; } + private bool TryResolveChannelAudienceKey(ChannelEntry entry, out string key) + { + if (entry.IsDmRow) + { + key = entry.Id; // canonical DM key ("dm") + return true; + } + + key = string.Empty; + if (LastChannelResolution is null) + return false; + + var resolved = LastChannelResolution.Resolved.FirstOrDefault(channel => + string.Equals(channel.ChannelName, entry.Id, StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.ChannelId, entry.Id, StringComparison.Ordinal)); + + if (string.IsNullOrWhiteSpace(resolved?.ChannelId)) + return false; + + key = resolved.ChannelId; + return true; + } + internal static List ParseChannelIds(string? input) => string.IsNullOrWhiteSpace(input) ? [] diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs index 08efec7ae..09a2a28b4 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepView.cs @@ -6,6 +6,7 @@ using System.Net; using System.Net.Sockets; using Netclaw.Configuration; +using Netclaw.Cli.Tui.Workflow; using R3; using Termina.Extensions; using Termina.Input; @@ -24,7 +25,16 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// public sealed class ExposureModeStepView : IWizardStepView { - private IDisposable? _modeList; + private static readonly IReadOnlyList> ModeOptions = + [ + new(ExposureMode.Local, "Local — loopback only, safest (recommended)"), + new(ExposureMode.ReverseProxy, "Reverse Proxy — behind nginx, Caddy, Traefik, IIS, ALB, etc."), + new(ExposureMode.TailscaleServe, "Tailscale Serve — accessible within your tailnet"), + new(ExposureMode.TailscaleFunnel, "Tailscale Funnel — public internet ⚠"), + new(ExposureMode.CloudflareTunnel, "Cloudflare Tunnel — public internet ⚠") + ]; + + private ActiveSelectionList>? _modeList; private SelectionListNode? _confirmList; private IDisposable? _webhookList; private TextInputNode? _hostInput; @@ -44,6 +54,9 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c { var vm = (ExposureModeStepViewModel)stepVm; + if (vm.CurrentSubStep != 0) + _modeList = null; + if (vm.CurrentSubStep == 0) return BuildModeSelection(vm, callbacks); @@ -64,50 +77,34 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c private ILayoutNode BuildModeSelection(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) { - var localOption = new SelectionOption(ExposureMode.Local, - "Local — loopback only, safest (recommended)"); - var reverseProxyOption = new SelectionOption(ExposureMode.ReverseProxy, - "Reverse Proxy — behind nginx, Caddy, Traefik, IIS, ALB, etc."); - var serveOption = new SelectionOption(ExposureMode.TailscaleServe, - "Tailscale Serve — accessible within your tailnet"); - var funnelOption = new SelectionOption(ExposureMode.TailscaleFunnel, - "Tailscale Funnel — public internet ⚠"); - var cloudflareOption = new SelectionOption(ExposureMode.CloudflareTunnel, - "Cloudflare Tunnel — public internet ⚠"); - - var modeList = Layouts.SelectionList>( - [localOption, reverseProxyOption, serveOption, funnelOption, cloudflareOption], - static o => o.ToString()) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _modeList = modeList; - modeList.OnFocused(); - _lastFocusedList = modeList; + _modeList = null; + _lastFocusedList = null; _lastFocusedInput = null; _confirmList = null; _webhookList = null; _hostInput = null; _trustedProxiesInput = null; - modeList.SelectionConfirmed - .Subscribe(selected => + var modeList = new ActiveSelectionList>( + ModeOptions, + static option => option.Label, + option => option.Value == vm.SelectedMode, + confirmed: option => { - if (selected.Count > 0) - { - vm.SelectedMode = selected[0].Value; - callbacks.AdvanceStep(); - } - }) - .DisposeWith(callbacks.Subscriptions); + vm.SelectedMode = option.Value; + callbacks.AdvanceStep(); + }, + changed: callbacks.RequestRedraw); + modeList.FocusFirst(option => option.Value == vm.SelectedMode); - return Layouts.Vertical() - .WithChild(new TextNode(" How will this Netclaw daemon be accessed?").WithForeground(Color.White)) - .WithSpacing(1) - .WithChild(modeList) - .WithSpacing(1) - .WithChild(new TextNode(" ⚠ = exposes daemon beyond this machine. Ensure auth is configured first.") - .WithForeground(Color.BrightBlack)); + _modeList = modeList; + + return WorkflowViewComponents.BuildSelectionScreen( + heading: "How will this Netclaw daemon be accessed?", + selector: modeList.AsLayout(), + legend: ActiveSelectionList>.BuildLegend("active exposure mode"), + supportText: "⚠ = exposes daemon beyond this machine. Ensure auth is configured first.", + supportColor: Color.BrightBlack); } private ILayoutNode BuildReverseProxyHost(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) @@ -280,18 +277,40 @@ private ILayoutNode BuildReverseProxyNotice(ExposureModeStepViewModel vm, StepVi private ILayoutNode BuildConfirmation(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) { - if (vm.IsHighRisk) - return BuildHighRiskWarning(vm, callbacks); + return vm.SelectedMode switch + { + ExposureMode.TailscaleFunnel => BuildTailscaleFunnelWarning(callbacks), + ExposureMode.CloudflareTunnel => BuildCloudflareTunnelWarning(callbacks), + _ => BuildTailscaleServeNotice(vm, callbacks) + }; + } - return BuildTailscaleServeNotice(vm, callbacks); + private ILayoutNode BuildTailscaleFunnelWarning(StepViewCallbacks callbacks) + { + return BuildHighRiskWarning( + "Tailscale Funnel", + [ + "Hub authentication is configured (device pairing or bearer token)", + "`tailscaled` is running and Funnel is explicitly enabled for this service", + "You trust your security posture selection" + ], + callbacks); } - private ILayoutNode BuildHighRiskWarning(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) + private ILayoutNode BuildCloudflareTunnelWarning(StepViewCallbacks callbacks) { - var modeLabel = vm.SelectedMode == ExposureMode.TailscaleFunnel - ? "Tailscale Funnel" - : "Cloudflare Tunnel"; + return BuildHighRiskWarning( + "Cloudflare Tunnel", + [ + "Hub authentication is configured (device pairing or bearer token)", + "`cloudflared` is running and Cloudflare Access protects the tunnel", + "You trust your security posture selection" + ], + callbacks); + } + private ILayoutNode BuildHighRiskWarning(string modeLabel, IReadOnlyList requirements, StepViewCallbacks callbacks) + { _confirmList = Layouts.SelectionList("I understand the risks — continue") .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Yellow); @@ -304,16 +323,16 @@ private ILayoutNode BuildHighRiskWarning(ExposureModeStepViewModel vm, StepViewC .Subscribe(_ => callbacks.AdvanceStep()) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode($" ⚠ {modeLabel} exposes your daemon to the public internet.") .WithForeground(Color.Yellow)) .WithSpacing(1) - .WithChild(new TextNode(" Before proceeding, ensure:").WithForeground(Color.White)) - .WithChild(new TextNode(" • Hub authentication is configured (device pairing or bearer token)").WithForeground(Color.BrightBlack)) - .WithChild(new TextNode(" • Your tunnel is running and healthy").WithForeground(Color.BrightBlack)) - .WithChild(new TextNode(" • You trust your security posture selection").WithForeground(Color.BrightBlack)) - .WithSpacing(1) - .WithChild(_confirmList); + .WithChild(new TextNode(" Before proceeding, ensure:").WithForeground(Color.White)); + + foreach (var requirement in requirements) + layout = layout.WithChild(new TextNode($" • {requirement}").WithForeground(Color.BrightBlack)); + + return layout.WithSpacing(1).WithChild(_confirmList); } private ILayoutNode BuildTailscaleServeNotice(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) @@ -330,16 +349,15 @@ private ILayoutNode BuildTailscaleServeNotice(ExposureModeStepViewModel vm, Step .Subscribe(_ => callbacks.AdvanceStep()) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() - .WithChild(new TextNode(" Tailscale Serve: daemon accessible within your tailnet only.") - .WithForeground(Color.Cyan)) - .WithSpacing(1) - .WithChild(new TextNode(" Devices on your tailnet can reach the daemon. Not reachable from the public internet.") - .WithForeground(Color.BrightBlack)) - .WithChild(new TextNode(" Ensure `tailscaled` is running before starting Netclaw.") - .WithForeground(Color.BrightBlack)) - .WithSpacing(1) - .WithChild(_confirmList); + return WorkflowViewComponents.BuildNoticeScreen( + title: "Tailscale Serve: daemon accessible within your tailnet only.", + bodyLines: + [ + "Devices on your tailnet can reach the daemon. Not reachable from the public internet.", + "Ensure `tailscaled` is running before starting Netclaw." + ], + confirmation: _confirmList, + titleColor: Color.Cyan); } private ILayoutNode BuildWebhookToggle(ExposureModeStepViewModel vm, StepViewCallbacks callbacks) @@ -368,15 +386,11 @@ private ILayoutNode BuildWebhookToggle(ExposureModeStepViewModel vm, StepViewCal }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() - .WithChild(new TextNode(" Should this daemon accept inbound webhooks?").WithForeground(Color.White)) - .WithSpacing(1) - .WithChild(webhookList) - .WithSpacing(1) - .WithChild(new TextNode(" Inbound webhooks let external services trigger autonomous runs via HTTP POST.") - .WithForeground(Color.BrightBlack)) - .WithChild(new TextNode(" This is separate from outbound notification webhooks.") - .WithForeground(Color.BrightBlack)); + return WorkflowViewComponents.BuildSelectionScreen( + heading: "Should this daemon accept inbound webhooks?", + selector: webhookList, + supportText: "Inbound webhooks let external services trigger autonomous runs via HTTP POST.\nThis is separate from outbound notification webhooks.", + supportColor: Color.BrightBlack); } private static string FormatServingUrl(string host) @@ -395,6 +409,9 @@ private static string FormatServingUrl(string host) public bool HandleKeyPress(KeyPressed key) { + if (_modeList is not null && _modeList.HandleInput(key.KeyInfo)) + return true; + if (_lastFocusedList is not null) { _lastFocusedList.HandleInput(key.KeyInfo); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs index f3a07eed9..148f2a85e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ExposureModeStepViewModel.cs @@ -4,11 +4,12 @@ // // ----------------------------------------------------------------------- using System.Buffers.Text; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Netclaw.Cli.Config; +using Netclaw.Cli.Doctor; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -22,11 +23,14 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// One TextInputNode per sub-step matches the established wizard pattern /// (see SlackStepView, IdentityStepView). /// -public sealed class ExposureModeStepViewModel : IWizardStepViewModel +public sealed class ExposureModeStepViewModel : IWizardStepViewModel, ISectionEditor { /// Default bind address suggested in the reverse-proxy config sub-step. public const string DefaultReverseProxyHost = "0.0.0.0"; + private const string ReverseProxyHostStateKey = "ReverseProxy.Host"; + private const string ReverseProxyTrustedProxiesStateKey = "ReverseProxy.TrustedProxies"; + private static readonly JsonSerializerOptions DevicesJsonOptions = new() { WriteIndented = true, @@ -35,13 +39,42 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel private int _currentSubStep; private int _highWaterSubStep; + private readonly TimeProvider _timeProvider; // Bootstrap device state — populated during ContributeSecrets for non-Local modes. private string? _bootstrapRawToken; private PairedDevice? _bootstrapDevice; + public ExposureModeStepViewModel() + : this(TimeProvider.System, includeWebhookToggle: true) + { + } + + public ExposureModeStepViewModel(TimeProvider timeProvider) + : this(timeProvider, includeWebhookToggle: true) + { + } + + internal ExposureModeStepViewModel(bool includeWebhookToggle) + : this(TimeProvider.System, includeWebhookToggle) + { + } + + private ExposureModeStepViewModel(TimeProvider timeProvider, bool includeWebhookToggle) + { + _timeProvider = timeProvider; + IncludeWebhookToggle = includeWebhookToggle; + } + public string StepId => WizardStepIds.ExposureMode; public string DisplayTitle => "Network Exposure"; + public string SectionId => StepId; + public string DisplayName => "Exposure Mode"; + public string? Category => "Security & Access"; + public bool ShowInMenu => true; + public IReadOnlyList RelevantDoctorChecks => ["Config Schema", "exposure-mode"]; + + internal bool IncludeWebhookToggle { get; } /// The selected exposure mode. Defaults to . public ExposureMode SelectedMode { get; set; } = ExposureMode.Local; @@ -68,7 +101,14 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel public int CurrentSubStep => _currentSubStep; /// Sub-step count varies by mode — see class summary. - public int SubStepCount => IsReverseProxy ? 5 : (NeedsConfirmation ? 3 : 2); + public int SubStepCount + { + get + { + var count = IsReverseProxy ? 4 : (NeedsConfirmation ? 2 : 1); + return IncludeWebhookToggle ? count + 1 : count; + } + } /// True when the selected mode requires a confirmation or notice screen. internal bool NeedsConfirmation => SelectedMode != ExposureMode.Local; @@ -90,14 +130,14 @@ public sealed class ExposureModeStepViewModel : IWizardStepViewModel internal int NoticeSubStep => IsReverseProxy ? 3 : 1; /// The sub-step index for the inbound webhook toggle (always last in the plan). - internal int WebhookSubStep => SubStepCount - 1; + internal int WebhookSubStep => IncludeWebhookToggle ? SubStepCount - 1 : -1; public string GetHelpText() { if (_currentSubStep == 0) return " Local is safest — daemon only reachable from this machine. Use tunnels for remote access."; - if (_currentSubStep == WebhookSubStep) + if (IncludeWebhookToggle && _currentSubStep == WebhookSubStep) return " Inbound webhooks let external services trigger autonomous runs via HTTP POST."; if (IsReverseProxy && _currentSubStep == ReverseProxyHostSubStep) @@ -152,6 +192,9 @@ public bool TryGoBack() public void OnEnter(WizardContext context, NavigationDirection direction) { + if (direction == NavigationDirection.Forward) + TryPrefillFromExisting(context); + if (direction == NavigationDirection.Back) { // SubStepCount depends on SelectedMode, which the operator can change @@ -167,6 +210,11 @@ public void OnEnter(WizardContext context, NavigationDirection direction) public void OnLeave() { } + internal void ReturnToModeSelection() + { + _currentSubStep = 0; + } + /// /// Writes the Daemon section (non-local modes) and Webhooks section (when enabled). /// For reverse-proxy mode the section also carries the operator-supplied bind address @@ -174,7 +222,7 @@ public void OnLeave() { } /// public void ContributeConfig(WizardConfigBuilder builder) { - if (SelectedMode != ExposureMode.Local) + if (IncludeWebhookToggle && SelectedMode != ExposureMode.Local) { builder.Daemon = new DaemonConfigSection { @@ -184,7 +232,7 @@ public void ContributeConfig(WizardConfigBuilder builder) }; } - if (WebhooksEnabled) + if (IncludeWebhookToggle && WebhooksEnabled) { builder.Webhooks = new WebhooksConfigSection { Enabled = true }; } @@ -213,7 +261,7 @@ public void ContributeSecrets(WizardSecretsBuilder builder) var saltHex = Convert.ToHexString(saltBytes).ToLowerInvariant(); var tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); _bootstrapDevice = new PairedDevice { Name = Environment.MachineName, @@ -231,6 +279,179 @@ public void ContributeSecrets(WizardSecretsBuilder builder) public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) => Task.CompletedTask; + public SectionStatus GetStatus(WizardContext context) => SectionStatus.Configured; + + public string Summary(WizardContext context) + => FormatModeLabel(ReadExistingMode(context)); + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => new ExposureModeStepViewModel(includeWebhookToggle: false); + + internal string? GetStructuralValidationError() + { + if (SelectedMode != ExposureMode.ReverseProxy) + return null; + + var host = string.IsNullOrWhiteSpace(Host) ? DefaultReverseProxyHost : Host.Trim(); + if (DaemonExposureValidator.IsLoopbackHost(host)) + return $"Daemon.Host '{host}' is loopback and cannot be used for reverse-proxy exposure."; + + if (TrustedProxies.Count == 0) + return "Daemon.TrustedProxies must contain at least one IP address or CIDR for reverse-proxy exposure."; + + return DaemonExposureValidator.TryGetInvalidTrustedProxy(TrustedProxies, out var error) + ? error + : null; + } + + /// + /// Guarantees the operator's current client keeps daemon access after a non-local exposure + /// mode is saved. If the local DeviceToken does not already match a paired device, the + /// configuring client is paired: an existing-but-unmatched token (orphaned or mismatched local + /// state) gets a device minted to accept it; a missing token gets a fresh token+device. Existing + /// devices are never removed, so this only ever ADDS access for the operator at the keyboard. + /// + /// This replaces an earlier hard "fix pairing via `netclaw doctor` before saving" block: that + /// block locked the configuring client out of netclaw chat on any leftover/partial pairing + /// state. Auto-pairing here mirrors the wizard's bootstrap () and + /// the daemon's BootstrapDeviceSeeder, which only auto-pair on a fully fresh install. + /// + public void EnsureCurrentClientPaired(NetclawPaths paths) + { + if (!SelectedMode.RequiresRemoteAuthentication()) + return; + + var snapshot = DeviceRegistryInspector.Read(paths); + if (snapshot.LocalTokenMatchesDevice) + return; // The configuring client already has a working pairing — nothing to do. + + var saltHex = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + + // Keep the operator's existing local token when one is present and usable (orphaned/ + // mismatched) so an already-distributed token keeps working; otherwise — including a + // corrupted/unparseable token — mint a fresh one for this client rather than crash the save. + var rawToken = snapshot.HasLocalDeviceToken ? ReadLocalDeviceTokenValue(paths) : null; + if (string.IsNullOrWhiteSpace(rawToken) || !TryComputeTokenHash(rawToken, saltHex, out var tokenHash)) + { + rawToken = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + WriteLocalDeviceTokenValue(paths, rawToken); + tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); + } + + var now = _timeProvider.GetUtcNow(); + var device = new PairedDevice + { + Name = Environment.MachineName, + IsBootstrapDevice = true, + TokenHash = tokenHash, + Salt = saltHex, + CreatedAt = now, + LastUsedAt = now, + }; + + var devices = ReadPairedDevices(paths); + devices.Add(device); + WritePairedDevices(paths, devices); + } + + private static string? ReadLocalDeviceTokenValue(NetclawPaths paths) + => ConfigFileHelper.ReadDecryptedSecret(paths, "DeviceToken"); + + private static void WriteLocalDeviceTokenValue(NetclawPaths paths, string rawToken) + { + var secrets = File.Exists(paths.SecretsPath) + ? ConfigFileHelper.LoadJsonDict(paths.SecretsPath) + : new Dictionary(); + secrets["configVersion"] = EmbeddedSchemaLoader.CurrentSchemaVersion; + secrets["DeviceToken"] = rawToken; + ConfigFileHelper.WriteSecretsFile(paths, secrets); + } + + private static List ReadPairedDevices(NetclawPaths paths) + { + if (!File.Exists(paths.DevicesPath)) + return []; + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(paths.DevicesPath)); + return doc.RootElement.ValueKind == JsonValueKind.Array + ? JsonSerializer.Deserialize>(doc.RootElement.GetRawText(), DevicesJsonOptions) ?? [] + : []; + } + catch (JsonException) + { + return []; + } + } + + private static void WritePairedDevices(NetclawPaths paths, IReadOnlyList devices) + { + var json = JsonSerializer.Serialize(devices, DevicesJsonOptions); + AtomicFile.WriteAllText(paths.DevicesPath, json, AtomicFile.HardenOwnerOnly); + } + + private static bool TryComputeTokenHash(string rawToken, string saltHex, out string tokenHash) + { + try + { + tokenHash = PairedDevice.ComputeTokenHash(rawToken, saltHex); + return true; + } + catch (FormatException) + { + // A corrupted/non-base64url local token cannot produce a usable device hash; signal the + // caller to mint a fresh token instead of letting the save crash. + tokenHash = string.Empty; + return false; + } + } + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (ExposureModeStepViewModel)editor; + var actions = new List + { + new("Daemon.ExposureMode", SectionFieldActionKind.Set, vm.SelectedMode.ToWireValue()) + }; + var stateActions = new List(); + + if (vm.SelectedMode == ExposureMode.ReverseProxy) + { + var host = string.IsNullOrWhiteSpace(vm.Host) ? DefaultReverseProxyHost : vm.Host; + var trustedProxies = vm.TrustedProxies.ToArray(); + actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Set, host)); + actions.Add(new SectionFieldAction("Daemon.TrustedProxies", SectionFieldActionKind.Set, + trustedProxies)); + + stateActions.Add(CreateStateAction(ReverseProxyHostStateKey, host, host != DefaultReverseProxyHost)); + stateActions.Add(CreateStateAction(ReverseProxyTrustedProxiesStateKey, trustedProxies, + trustedProxies.Length > 0)); + } + else + { + var trustedProxies = vm.TrustedProxies.ToArray(); + + if (!string.IsNullOrWhiteSpace(vm.Host) + && !DaemonExposureValidator.IsLoopbackHost(vm.Host) + && vm.Host != DefaultReverseProxyHost) + { + stateActions.Add(CreateStateAction(ReverseProxyHostStateKey, vm.Host, keepValue: true)); + } + + stateActions.Add(CreateStateAction(ReverseProxyTrustedProxiesStateKey, trustedProxies, + trustedProxies.Length > 0)); + + // These fields are runtime-active whenever they remain under Daemon. + // Move dormant reverse-proxy values to editor state so local/tunnel + // startup validation ignores them until reverse-proxy mode is active again. + actions.Add(new SectionFieldAction("Daemon.Host", SectionFieldActionKind.Delete)); + actions.Add(new SectionFieldAction("Daemon.TrustedProxies", SectionFieldActionKind.Delete)); + } + + return new SectionContribution(actions, StateActions: stateActions); + } + /// /// Write the bootstrap paired device to devices.json so the daemon can start /// with at least one paired device. No-op for Local mode. @@ -245,9 +466,7 @@ public void WriteBootstrapDevice(NetclawPaths paths) return; var json = JsonSerializer.Serialize(new[] { _bootstrapDevice }, DevicesJsonOptions); - File.WriteAllText(paths.DevicesPath, json); - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && File.Exists(paths.DevicesPath)) - File.SetUnixFileMode(paths.DevicesPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + AtomicFile.WriteAllText(paths.DevicesPath, json, AtomicFile.HardenOwnerOnly); } /// The raw bootstrap token, exposed for testing. @@ -269,5 +488,99 @@ private static bool HasExistingLocalDeviceToken(NetclawPaths paths) return !string.IsNullOrWhiteSpace(ConfigFileHelper.DecryptIfEncrypted(paths, rawToken)); } + private void TryPrefillFromExisting(WizardContext context) + { + if (context.ExistingConfig is null) + return; + + SelectedMode = ReadExistingMode(context); + var editorState = new ConfigEditorStateStore(context.Paths); + + if (SelectedMode == ExposureMode.ReverseProxy + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.Host", out var hostValue) + && TryReadHost(hostValue, out var activeHost)) + { + Host = activeHost; + } + else if (editorState.TryGetValue(SectionId, ReverseProxyHostStateKey, out var storedHostValue) + && TryReadHost(storedHostValue, out var storedHost)) + { + Host = storedHost; + } + else if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.Host", out var inactiveHostValue) + && TryReadHost(inactiveHostValue, out var inactiveHost) + && !DaemonExposureValidator.IsLoopbackHost(inactiveHost)) + { + Host = inactiveHost; + } + + if (SelectedMode == ExposureMode.ReverseProxy + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.TrustedProxies", out var proxiesValue)) + { + TrustedProxies = ReadTrustedProxies(proxiesValue); + } + else if (editorState.TryGetValue(SectionId, ReverseProxyTrustedProxiesStateKey, out var storedProxiesValue)) + { + TrustedProxies = ReadTrustedProxies(storedProxiesValue); + } + else if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.TrustedProxies", out var inactiveProxiesValue)) + { + TrustedProxies = ReadTrustedProxies(inactiveProxiesValue); + } + } + + private static SectionEditorStateAction CreateStateAction(string key, object? value, bool keepValue) + => new( + WizardStepIds.ExposureMode, + key, + keepValue ? SectionEditorStateActionKind.Set : SectionEditorStateActionKind.Delete, + value); + + private static bool TryReadHost(object? value, out string host) + { + host = value?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(host); + } + + private static ExposureMode ReadExistingMode(WizardContext context) + { + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Daemon.ExposureMode", out var modeValue)) + { + return ExposureMode.Local; + } + + try + { + return DaemonConfig.ParseExposureMode(modeValue?.ToString()); + } + catch (InvalidOperationException) + { + // A migrated/hand-edited config with an unsupported ExposureMode must not crash wizard + // prefill or the mode label render; fall back to the most restrictive Local default. + return ExposureMode.Local; + } + } + + private static IReadOnlyList ReadTrustedProxies(object? value) + => value switch + { + string[] strings => strings, + object[] objects => objects.Select(static item => item?.ToString()).Where(static item => !string.IsNullOrWhiteSpace(item)).Cast().ToArray(), + IEnumerable strings => strings.ToArray(), + _ => [] + }; + + private static string FormatModeLabel(ExposureMode mode) + => mode switch + { + ExposureMode.Local => "Local", + ExposureMode.ReverseProxy => "Reverse Proxy", + ExposureMode.TailscaleServe => "Tailscale Serve", + ExposureMode.TailscaleFunnel => "Tailscale Funnel", + ExposureMode.CloudflareTunnel => "Cloudflare Tunnel", + _ => mode.ToString() + }; + public void Dispose() { } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExternalSkillsStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExternalSkillsStepView.cs deleted file mode 100644 index a6528504f..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExternalSkillsStepView.cs +++ /dev/null @@ -1,214 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using R3; -using Termina.Extensions; -using Termina.Input; -using Termina.Layout; -using Termina.Reactive; -using Termina.Rendering; -using Termina.Terminal; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Termina view for the External Skills wizard step. -/// Sub-step 0: checklist of detected well-known sources (custom keyboard nav). -/// Sub-step 1: optional custom path text input. -/// Sub-step 2: symlink toggle for custom path. -/// -public sealed class ExternalSkillsStepView : IWizardStepView -{ - private int _cursorIndex; - private TextInputNode? _customPathInput; - private SelectionListNode? _symlinkList; - private IFocusable? _lastFocusedList; - private TextInputBaseNode? _lastFocusedInput; - private StepViewCallbacks? _callbacks; - private ExternalSkillsStepViewModel? _vm; - - public string StepId => WizardStepIds.ExternalSkills; - - public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) - { - _callbacks = callbacks; - _vm = (ExternalSkillsStepViewModel)stepVm; - - return _vm.CurrentSubStep switch - { - 0 => BuildSourceChecklist(), - 1 => BuildCustomPathInput(callbacks), - 2 => BuildSymlinkToggle(callbacks), - _ => Layouts.Empty() - }; - } - - private ILayoutNode BuildSourceChecklist() - { - _lastFocusedList = null; - _lastFocusedInput = null; - - var sources = _vm!.DetectedSources; - if (_cursorIndex >= sources.Count) _cursorIndex = sources.Count - 1; - if (_cursorIndex < 0) _cursorIndex = 0; - - var layout = Layouts.Vertical() - .WithChild(new TextNode(" External skill directories detected:").WithForeground(Color.White)) - .WithSpacing(1); - - for (var i = 0; i < sources.Count; i++) - { - var source = sources[i]; - var isFocused = i == _cursorIndex; - var isEnabled = _vm.IsSourceEnabled(i); - var prefix = isFocused ? " \u25b6 " : " "; - var checkbox = isEnabled ? "[x]" : "[ ]"; - var line = $"{prefix}{checkbox} {source.DisplayName} ({source.ResolvedPath})"; - - var node = new TextNode(line); - node = isFocused - ? node.WithForeground(Color.Cyan).Bold() - : node.WithForeground(Color.White); - layout = layout.WithChild(node); - } - - layout = layout.WithSpacing(1) - .WithChild(new TextNode(" Space to toggle, Enter to continue.") - .WithForeground(Color.BrightBlack)); - - return layout; - } - - private ILayoutNode BuildCustomPathInput(StepViewCallbacks callbacks) - { - _lastFocusedList = null; - - _customPathInput = new TextInputNode() - .WithPlaceholder("/path/to/team-skills"); - - if (!string.IsNullOrWhiteSpace(_vm!.CustomPath)) - _customPathInput.Text = _vm.CustomPath; - - _customPathInput.OnFocused(); - _lastFocusedInput = _customPathInput; - - _customPathInput.Submitted - .Subscribe(text => - { - _vm.CustomPath = string.IsNullOrWhiteSpace(text) ? null : text.Trim(); - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Add a custom skill directory (optional, Enter to skip):") - .WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_customPathInput, "Path")); - } - - private ILayoutNode BuildSymlinkToggle(StepViewCallbacks callbacks) - { - _lastFocusedInput = null; - - var noLabel = "No \u2014 stricter security (default)"; - var yesLabel = "Yes \u2014 needed if skill directory uses symlinks"; - - _symlinkList = Layouts.SelectionList(noLabel, yesLabel) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _symlinkList.OnFocused(); - _lastFocusedList = _symlinkList; - - _symlinkList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - _vm!.CustomPathAllowSymlinks = selected[0] == yesLabel; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Allow symlinks in custom skill directory?") - .WithForeground(Color.White)) - .WithChild(_symlinkList); - } - - public bool HandleKeyPress(KeyPressed key) - { - if (_vm is null) - return false; - - var keyInfo = key.KeyInfo; - - return _vm.CurrentSubStep switch - { - 0 => HandleChecklistKey(keyInfo), - 1 when _lastFocusedInput is not null => HandleDelegatedInput(keyInfo), - 2 when _lastFocusedList is not null => HandleDelegatedList(keyInfo), - _ => false - }; - } - - private bool HandleChecklistKey(ConsoleKeyInfo keyInfo) - { - var sources = _vm!.DetectedSources; - switch (keyInfo.Key) - { - case ConsoleKey.UpArrow: - if (_cursorIndex > 0) _cursorIndex--; - break; - - case ConsoleKey.DownArrow: - if (_cursorIndex < sources.Count - 1) _cursorIndex++; - break; - - case ConsoleKey.Spacebar: - if (sources.Count > 0) - _vm.ToggleSource(_cursorIndex); - break; - - case ConsoleKey.Enter: - _callbacks?.AdvanceStep(); - return true; - - default: - return false; - } - - _callbacks?.InvalidateAndRedraw(); - return true; - } - - private bool HandleDelegatedInput(ConsoleKeyInfo keyInfo) - { - _lastFocusedInput!.HandleInput(keyInfo); - return true; - } - - private bool HandleDelegatedList(ConsoleKeyInfo keyInfo) - { - _lastFocusedList!.HandleInput(keyInfo); - return true; - } - - public void HandlePaste(PasteEvent paste) - { - _lastFocusedInput?.HandlePaste(paste); - } - - public void ClearFocusState() - { - _lastFocusedList = null; - _lastFocusedInput = null; - _customPathInput = null; - _symlinkList = null; - _cursorIndex = 0; - } - -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ExternalSkillsStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ExternalSkillsStepViewModel.cs deleted file mode 100644 index 218ff8c0a..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ExternalSkillsStepViewModel.cs +++ /dev/null @@ -1,152 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Wizard step for detecting and enabling external skill directories. -/// Sub-step 0: checklist of detected well-known sources. -/// Sub-step 1: optional custom path text input. -/// Sub-step 2 (conditional): symlink toggle for the custom path. -/// -public sealed class ExternalSkillsStepViewModel : IWizardStepViewModel -{ - private int _currentSubStep; - private int _highWaterSubStep; - - private readonly IReadOnlyList _detectedSources; - private readonly bool[] _enabledFlags; - - public string StepId => WizardStepIds.ExternalSkills; - public string DisplayTitle => "External Skills"; - - /// Custom skill directory path (optional, entered in sub-step 1). - public string? CustomPath { get; set; } - - /// Whether to allow symlinks in the custom path directory. - public bool CustomPathAllowSymlinks { get; set; } - - public ExternalSkillsStepViewModel() - { - _detectedSources = ExternalSkillsConfig.ProbeWellKnownSources(); - _enabledFlags = new bool[_detectedSources.Count]; - Array.Fill(_enabledFlags, true); - } - - /// Test constructor for injecting fake probe results. - internal ExternalSkillsStepViewModel(IReadOnlyList detectedSources) - { - _detectedSources = detectedSources; - _enabledFlags = new bool[_detectedSources.Count]; - Array.Fill(_enabledFlags, true); - } - - /// Well-known sources detected on disk. - public IReadOnlyList DetectedSources => _detectedSources; - - /// Whether the source at the given index is enabled. - public bool IsSourceEnabled(int index) => _enabledFlags[index]; - - /// Toggle the enabled state of the source at the given index. - public void ToggleSource(int index) => _enabledFlags[index] = !_enabledFlags[index]; - - public bool IsApplicable(WizardContext context) => _detectedSources.Count > 0; - - public int CurrentSubStep => _currentSubStep; - - public int SubStepCount => HasCustomPath ? 3 : 2; - - private bool HasCustomPath => !string.IsNullOrWhiteSpace(CustomPath); - - public string GetHelpText() => _currentSubStep switch - { - 0 => " Use Space to toggle, Enter to confirm. Detected skill directories from other AI tools.", - 1 => " Optional. Enter a path to a shared team skill directory, or press Enter to skip.", - 2 => " Some skill directories use symlinks. Allow symlinks for this custom path?", - _ => "" - }; - - public bool TryAdvance() - { - if (_currentSubStep == 0) - { - _currentSubStep = 1; - _highWaterSubStep = 1; - return true; - } - - if (_currentSubStep == 1 && HasCustomPath) - { - _currentSubStep = 2; - _highWaterSubStep = 2; - return true; - } - - return false; - } - - public bool TryGoBack() - { - if (_currentSubStep > 0) - { - _currentSubStep--; - return true; - } - - return false; - } - - public void OnEnter(WizardContext context, NavigationDirection direction) - { - if (direction == NavigationDirection.Back) - _currentSubStep = _highWaterSubStep; - else - _currentSubStep = 0; - } - - public void OnLeave() { } - - public void ContributeConfig(WizardConfigBuilder builder) - { - var sources = new List(); - - for (var i = 0; i < _detectedSources.Count; i++) - { - var probe = _detectedSources[i]; - sources.Add(new ExternalSkillSource - { - Name = probe.WellKnownAlias, - WellKnown = probe.WellKnownAlias, - Enabled = _enabledFlags[i], - AllowSymlinks = probe.DefaultAllowSymlinks - }); - } - - if (HasCustomPath) - { - sources.Add(new ExternalSkillSource - { - Name = "custom", - Path = CustomPath, - Enabled = true, - AllowSymlinks = CustomPathAllowSymlinks - }); - } - - if (sources.Count > 0) - { - builder.ExternalSkillSources = sources; - } - } - - public void ContributeSecrets(WizardSecretsBuilder builder) { } - - public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) - => Task.CompletedTask; - - public void Dispose() { } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs index 425f79bdb..9b33ff84a 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepView.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Cli.Tui.Workflow; using Termina.Extensions; using Termina.Input; using Termina.Layout; @@ -19,38 +20,40 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; public sealed class FeatureSelectionStepView : IWizardStepView { private int _cursorIndex; + private ActiveSelectionList? _featureList; private StepViewCallbacks? _callbacks; private FeatureSelectionStepViewModel? _vm; public string StepId => WizardStepIds.FeatureSelection; + public bool ManagesOwnFocusState => true; + public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) { _callbacks = callbacks; _vm = (FeatureSelectionStepViewModel)stepVm; - var featureCount = FeatureSelectionStepViewModel.FeatureNames.Length; - if (_cursorIndex >= featureCount) _cursorIndex = featureCount - 1; + var options = FeatureToggleOption.All; + if (_cursorIndex >= options.Count) _cursorIndex = options.Count - 1; if (_cursorIndex < 0) _cursorIndex = 0; + _featureList = new ActiveSelectionList( + options, + static option => option.Name.PadRight(12), + option => _vm.IsFeatureEnabled(option.Index), + static option => option.Description, + focusedIndex: _cursorIndex, + toggled: option => _vm.ToggleFeature(option.Index), + changed: () => + { + _cursorIndex = _featureList?.FocusedIndex ?? _cursorIndex; + callbacks.RequestRedraw(); + }); + var layout = Layouts.Vertical() .WithChild(new TextNode(" Select which features to enable for this deployment:").WithForeground(Color.White)) - .WithSpacing(1); - - for (var i = 0; i < featureCount; i++) - { - var isFocused = i == _cursorIndex; - var isEnabled = _vm.IsFeatureEnabled(i); - var prefix = isFocused ? " ▶ " : " "; - var checkbox = isEnabled ? "[x]" : "[ ]"; - var line = $"{prefix}{checkbox} {FeatureSelectionStepViewModel.FeatureNames[i]} — {FeatureSelectionStepViewModel.FeatureDescriptions[i]}"; - - var node = new TextNode(line); - node = isFocused - ? node.WithForeground(Color.Cyan).Bold() - : node.WithForeground(Color.White); - layout = layout.WithChild(node); - } + .WithSpacing(1) + .WithChild(_featureList.AsLayout()); layout = layout.WithSpacing(1) .WithChild(new TextNode(" Space to toggle, Enter to continue.") @@ -72,33 +75,14 @@ public bool HandleKeyPress(KeyPressed key) if (_vm is null) return false; - var keyInfo = key.KeyInfo; - var featureCount = FeatureSelectionStepViewModel.FeatureNames.Length; - - switch (keyInfo.Key) + switch (key.KeyInfo.Key) { - case ConsoleKey.UpArrow: - if (_cursorIndex > 0) _cursorIndex--; - break; - - case ConsoleKey.DownArrow: - if (_cursorIndex < featureCount - 1) _cursorIndex++; - break; - - case ConsoleKey.Spacebar: - _vm.ToggleFeature(_cursorIndex); - break; - case ConsoleKey.Enter: _callbacks?.AdvanceStep(); return true; - - default: - return false; } - _callbacks?.InvalidateAndRedraw(); - return true; + return _featureList?.HandleInput(key.KeyInfo) ?? false; } public void HandlePaste(PasteEvent paste) @@ -109,5 +93,19 @@ public void HandlePaste(PasteEvent paste) public void ClearFocusState() { _cursorIndex = 0; + _featureList = null; + } + + private sealed record FeatureToggleOption(int Index, string Name, string Description) + { + public static readonly IReadOnlyList All = + [ + new(0, FeatureSelectionStepViewModel.FeatureNames[0], FeatureSelectionStepViewModel.FeatureDescriptions[0]), + new(1, FeatureSelectionStepViewModel.FeatureNames[1], FeatureSelectionStepViewModel.FeatureDescriptions[1]), + new(2, FeatureSelectionStepViewModel.FeatureNames[2], FeatureSelectionStepViewModel.FeatureDescriptions[2]), + new(3, FeatureSelectionStepViewModel.FeatureNames[3], FeatureSelectionStepViewModel.FeatureDescriptions[3]), + new(4, FeatureSelectionStepViewModel.FeatureNames[4], FeatureSelectionStepViewModel.FeatureDescriptions[4]), + new(5, FeatureSelectionStepViewModel.FeatureNames[5], FeatureSelectionStepViewModel.FeatureDescriptions[5]) + ]; } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs index 85198d17a..715b10efd 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/FeatureSelectionStepViewModel.cs @@ -4,6 +4,8 @@ // // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -11,7 +13,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Wizard step for selecting which deployment-wide features are enabled. /// Only shown for Team and Public postures (not Personal). /// -public sealed class FeatureSelectionStepViewModel : IWizardStepViewModel +public sealed class FeatureSelectionStepViewModel : IWizardStepViewModel, ISectionEditor { private WizardContext? _context; private readonly bool[] _enabledFlags = new bool[6]; @@ -40,6 +42,11 @@ public sealed class FeatureSelectionStepViewModel : IWizardStepViewModel public string StepId => WizardStepIds.FeatureSelection; public string DisplayTitle => "Feature Selection"; + public string SectionId => StepId; + public string DisplayName => "Enabled Features"; + public string? Category => "Security & Access"; + public bool ShowInMenu => true; + public IReadOnlyList RelevantDoctorChecks => ["Config Schema"]; public bool IsApplicable(WizardContext context) => context.SelectedPosture != DeploymentPosture.Personal; @@ -77,6 +84,9 @@ public void OnEnter(WizardContext context, NavigationDirection direction) if (direction == NavigationDirection.Forward) { + if (TryPrefillFromExisting(context)) + return; + // Set defaults based on posture var allOn = context.SelectedPosture == DeploymentPosture.Team; Array.Fill(_enabledFlags, allOn); @@ -126,6 +136,97 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => _enabledFlags.Any(static v => v) || HasAnyExistingSelection(context) + ? SectionStatus.Configured + : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var enabled = CurrentEnabledFeatureNames(context).ToArray(); + return enabled.Length == 0 ? "All optional features disabled" : string.Join(", ", enabled); + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (FeatureSelectionStepViewModel)editor; + return new SectionContribution( + [ + new SectionFieldAction("Memory.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[0]), + new SectionFieldAction("Search.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[1]), + new SectionFieldAction("SkillSync.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[2]), + new SectionFieldAction("Scheduling.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[3]), + new SectionFieldAction("SubAgents.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[4]), + new SectionFieldAction("Webhooks.Enabled", SectionFieldActionKind.Set, vm._enabledFlags[5]) + ]); + } + + private bool TryPrefillFromExisting(WizardContext context) + { + if (context.ExistingConfig is null) + return false; + + var mapped = new (string Path, int Index)[] + { + ("Memory.Enabled", 0), + ("Search.Enabled", 1), + ("SkillSync.Enabled", 2), + ("Scheduling.Enabled", 3), + ("SubAgents.Enabled", 4), + ("Webhooks.Enabled", 5) + }; + + var foundAny = false; + foreach (var (path, index) in mapped) + { + if (!ConfigFileHelper.TryGetPathValue(context.ExistingConfig, path, out var value) || value is not bool enabled) + continue; + + _enabledFlags[index] = enabled; + foundAny = true; + } + + return foundAny; + } + + private bool HasAnyExistingSelection(WizardContext context) + => CurrentEnabledFeatureNames(context).Any(); + + private IEnumerable CurrentEnabledFeatureNames(WizardContext context) + { + for (var i = 0; i < FeatureNames.Length; i++) + { + if (_enabledFlags[i]) + { + yield return FeatureNames[i]; + continue; + } + + if (context.ExistingConfig is null) + continue; + + var path = i switch + { + 0 => "Memory.Enabled", + 1 => "Search.Enabled", + 2 => "SkillSync.Enabled", + 3 => "Scheduling.Enabled", + 4 => "SubAgents.Enabled", + 5 => "Webhooks.Enabled", + _ => throw new InvalidOperationException("Unexpected feature index.") + }; + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, path, out var value) + && value is bool enabled && enabled) + { + yield return FeatureNames[i]; + } + } + } + public void Dispose() { // Nothing to dispose diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepView.cs index ca8b265fd..58466df75 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepView.cs @@ -23,7 +23,8 @@ public sealed class HealthCheckStepView : IWizardStepView public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) { var vm = (HealthCheckStepViewModel)stepVm; - var items = vm.Results; + // Snapshot: Results is mutated off the UI thread by the async health-check and its timer. + var items = vm.ResultsSnapshot(); var lines = new List(); foreach (var item in items) @@ -40,6 +41,16 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c if (lines.Count == 0) lines.Add(new TextNode(" Press Enter to run health checks...").WithForeground(Color.BrightBlack)); + // Post-flight summary: once the checks finish, nudge toward the bootstrap-vs-config + // split so the operator knows where ongoing settings live (simplify-netclaw-init §6). + if (vm.IsComplete.Value) + { + lines.Add(new TextNode("")); + lines.Add(new TextNode(" Next steps:").WithForeground(Color.Gray)); + lines.Add(new TextNode(" netclaw chat — start talking to your agent").WithForeground(Color.Gray)); + lines.Add(new TextNode(" netclaw config — adjust settings any time").WithForeground(Color.Gray)); + } + var layout = Layouts.Vertical(); foreach (var line in lines) layout.WithChild(line); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs index cb95fc841..945acf20e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/HealthCheckStepViewModel.cs @@ -47,9 +47,51 @@ public HealthCheckStepViewModel( // ── Reactive state ── public ReactiveProperty IsRunning { get; } = new(false); public ReactiveProperty IsComplete { get; } = new(false); + + /// True once the check completed with all probes passing. Drives the + /// post-flight UX: a clean bootstrap shows the "ready" summary and launches chat on + /// Enter; warnings/failures stay on the summary and exit on Enter. + public ReactiveProperty Succeeded { get; } = new(false); public List Results { get; } = []; internal ReactiveProperty ResultVersion { get; } = new(0); + // All Results access is synchronized on the list instance: the async health-check core and its + // daemon-poll timer mutate Results off the UI thread while the render thread reads it (through + // ResultsSnapshot). HealthCheckRunner locks the same object for its Add/UpdateLast. + private void AddResult(HealthCheckItem item) + { + lock (Results) + Results.Add(item); + } + + private void ClearResults() + { + lock (Results) + Results.Clear(); + } + + private void SetLastResult(HealthCheckItem item) + { + lock (Results) + { + if (Results.Count > 0) + Results[^1] = item; + } + } + + private bool LastResultPending() + { + lock (Results) + return Results.Count > 0 && Results[^1].Passed is null; + } + + /// Thread-safe snapshot for the render thread; Results is mutated off the UI thread. + internal IReadOnlyList ResultsSnapshot() + { + lock (Results) + return Results.ToArray(); + } + /// Task that completes when health check finishes. For testing. internal Task? HealthCheckCompletion { get; private set; } @@ -81,7 +123,8 @@ public void OnEnter(WizardContext context, NavigationDirection direction) { IsRunning.Value = false; IsComplete.Value = false; - Results.Clear(); + Succeeded.Value = false; + ClearResults(); NotifyChanged(); } } @@ -115,13 +158,26 @@ public async Task RunWithOrchestrator(WizardOrchestrator orchestrator) } catch (OperationCanceledException) when (overallCts.IsCancellationRequested) { - Results.Add(new HealthCheckItem("Health check timed out", false)); + AddResult(new HealthCheckItem("Health check timed out", false)); IsRunning.Value = false; IsComplete.Value = true; NotifyChanged(); if (_context is not null) _context.StatusMessage.Value = "Setup timed out. Run `netclaw daemon start` to begin."; } + catch (Exception ex) + { + // Any unexpected failure in the health-check core (e.g. an IO error in a step's + // ContributeHealthChecksAsync) must still release the wizard. Leaving IsRunning=true / + // IsComplete=false permanently wedges the step — GoNext gates on !IsRunning && + // !IsComplete, so the operator could neither advance, go back, nor see an error. + AddResult(new HealthCheckItem($"Health check failed: {ex.Message}", false)); + IsRunning.Value = false; + IsComplete.Value = true; + NotifyChanged(); + if (_context is not null) + _context.StatusMessage.Value = "Setup health check failed. Run `netclaw daemon start` to begin."; + } } private Task RunHealthCheckAsync() @@ -129,7 +185,7 @@ private Task RunHealthCheckAsync() // Standalone mode — no orchestrator. Used for testing. IsRunning.Value = true; IsComplete.Value = false; - Results.Clear(); + ClearResults(); NotifyChanged(); IsRunning.Value = false; @@ -142,7 +198,7 @@ private async Task RunHealthCheckCoreAsync(WizardOrchestrator orchestrator, Canc { IsRunning.Value = true; IsComplete.Value = false; - Results.Clear(); + ClearResults(); NotifyChanged(); var runner = new HealthCheckRunner(Results, NotifyChanged); @@ -211,7 +267,7 @@ private async Task RunHealthCheckCoreAsync(WizardOrchestrator orchestrator, Canc { runner.UpdateLast(new HealthCheckItem("Daemon ready", true)); } - else if (Results.Count > 0 && Results[^1].Passed is null) + else if (LastResultPending()) { runner.UpdateLast(new HealthCheckItem(NotReadyMessage, false)); } @@ -222,17 +278,28 @@ private async Task RunHealthCheckCoreAsync(WizardOrchestrator orchestrator, Canc NotifyChanged(); allPassed = runner.AllPassed; - if (allPassed && _context is not null) + Succeeded.Value = allPassed; + if (allPassed) { - _context.StatusMessage.Value = "Setup complete! Launching chat..."; - Navigate?.Invoke("/chat"); + // Validation passed — launch chat automatically rather than gating on a second + // Enter. Mirrors the provider step's async-success auto-advance: this runs on + // the health-check task and drives navigation through the same wired Navigate + // delegate the Enter handler used (it sets the onboarding trigger first). + if (_context is not null) + _context.StatusMessage.Value = "✓ Netclaw is ready — starting chat…"; + LaunchChat(); } else if (_context is not null) { - _context.StatusMessage.Value = "Setup complete with warnings. Run `netclaw daemon start` to begin."; + _context.StatusMessage.Value = + "Setup complete with warnings. Run `netclaw daemon start`, then `netclaw chat`. Adjust settings with `netclaw config`."; } } + /// Launch the chat experience after a successful bootstrap. Routed through + /// the wrapped delegate so the onboarding trigger is set first. + public void LaunchChat() => Navigate?.Invoke("/chat"); + /// /// Applies the freshly-written config and waits for the daemon to be ready on it. /// Writing config is the single restart trigger: a running daemon's @@ -256,6 +323,13 @@ private async Task StartIfNeededAndPollAsync(bool wasRunning, int? generat var startedAt = _timeProvider.GetUtcNow(); var verb = ProgressLabel(wasRunning); + // When the daemon was down and Start() defers to a container supervisor, hold onto + // that reason. If the supervisor never actually brings the daemon up — the marker is + // set but no supervisor is present (e.g. a derived image that kept + // NETCLAW_CONTAINER_SUPERVISOR but replaced the entrypoint) — the readiness poll + // below times out, and this message is what the operator needs instead of a generic + // "did not become ready". + string? supervisorDeferral = null; if (!wasRunning) { // Nothing is running to reload the config, so start it. Guarded: under a @@ -266,14 +340,17 @@ private async Task StartIfNeededAndPollAsync(bool wasRunning, int? generat && !result.Message.Contains("already running", StringComparison.OrdinalIgnoreCase) && !result.Message.Contains("container supervisor", StringComparison.OrdinalIgnoreCase)) { - Results[^1] = new HealthCheckItem( + SetLastResult(new HealthCheckItem( result.CrashLogPath is null ? result.Message : $"{result.Message} See crash log: {result.CrashLogPath}", - false); + false)); NotifyChanged(); return false; } + + if (!result.Success && result.Message.Contains("container supervisor", StringComparison.OrdinalIgnoreCase)) + supervisorDeferral = result.Message; } // Poll until a newer generation is healthy. We never break early on "not @@ -307,12 +384,12 @@ result.CrashLogPath is null var abort = _daemonManager.TryReadStartupFailureFromCrashLog(startedAt, out var abortLogPath); if (abort is not null) { - Results[^1] = new HealthCheckItem($"{abort} See crash log: {abortLogPath}", false); + SetLastResult(new HealthCheckItem($"{abort} See crash log: {abortLogPath}", false)); NotifyChanged(); return false; } - Results[^1] = new HealthCheckItem($"{verb} ({++elapsedSeconds}s)", null); + SetLastResult(new HealthCheckItem($"{verb} ({++elapsedSeconds}s)", null)); NotifyChanged(); await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider, ct); } @@ -320,15 +397,19 @@ result.CrashLogPath is null // Timed out: surface the startup-abort crash-log diagnostic if present, so a // bad-config crash-loop isn't reported as a generic "not ready". var crashFailure = _daemonManager.TryReadStartupFailureFromCrashLog(startedAt, out var crashLogPath); - var failureMessage = (crashFailure, crashLogPath) switch + var failureMessage = (crashFailure, crashLogPath, supervisorDeferral) switch { - (not null, _) => $"{crashFailure} See crash log: {crashLogPath}", - (null, not null) => $"{NotReadyMessage}. See crash log: {crashLogPath}", + (not null, _, _) => $"{crashFailure} See crash log: {crashLogPath}", + (null, not null, _) => $"{NotReadyMessage}. See crash log: {crashLogPath}", + // Marker set but the supervised daemon never came up: surface the actionable + // supervisor reason ("check the container/entrypoint logs — the marker may be + // set without a supervisor present") instead of the generic timeout message. + (null, null, not null) => supervisorDeferral, _ => null }; if (failureMessage is not null) { - Results[^1] = new HealthCheckItem(failureMessage, false); + SetLastResult(new HealthCheckItem(failureMessage, false)); NotifyChanged(); } @@ -357,6 +438,7 @@ public void Dispose() { IsRunning.Dispose(); IsComplete.Dispose(); + Succeeded.Dispose(); ResultVersion.Dispose(); } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepView.cs index 1d47d0a2b..627b9aea2 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepView.cs @@ -15,7 +15,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// /// Termina view for the Identity wizard step. -/// 6 sub-steps: agent name → comm style → user name → timezone → workspaces directory → webhook URL. +/// 4 sub-steps: agent name → comm style → user name → timezone. /// public sealed class IdentityStepView : IWizardStepView { @@ -23,8 +23,6 @@ public sealed class IdentityStepView : IWizardStepView private SelectionListNode? _commStyleList; private TextInputNode? _userNameInput; private TextInputNode? _timezoneInput; - private TextInputNode? _workspacesInput; - private TextInputNode? _webhookUrlInput; private IFocusable? _lastFocusedList; private TextInputBaseNode? _lastFocusedInput; @@ -40,8 +38,6 @@ public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks c 1 => BuildCommStyle(vm, callbacks), 2 => BuildUserName(vm, callbacks), 3 => BuildTimezone(vm, callbacks), - 4 => BuildWorkspacesDirectory(vm, callbacks), - 5 => BuildWebhookUrl(vm, callbacks), _ => Layouts.Empty() }; } @@ -142,54 +138,6 @@ private ILayoutNode BuildTimezone(IdentityStepViewModel vm, StepViewCallbacks ca .WithChild(WizardStepHelpers.BuildTextInputPanel(_timezoneInput, "Timezone")); } - private ILayoutNode BuildWorkspacesDirectory(IdentityStepViewModel vm, StepViewCallbacks callbacks) - { - _workspacesInput = new TextInputNode().WithPlaceholder(vm.WorkspacesDirectory); - _workspacesInput.Text = vm.WorkspacesDirectory; - - _workspacesInput.OnFocused(); - _lastFocusedInput = _workspacesInput; - _lastFocusedList = null; - - _workspacesInput.Submitted - .Subscribe(text => - { - if (!string.IsNullOrWhiteSpace(text)) - vm.WorkspacesDirectory = text; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Projects directory:").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_workspacesInput, "Workspaces")); - } - - private ILayoutNode BuildWebhookUrl(IdentityStepViewModel vm, StepViewCallbacks callbacks) - { - _webhookUrlInput = new TextInputNode() - .WithPlaceholder("https://hooks.slack.com/services/..."); - - if (!string.IsNullOrWhiteSpace(vm.WebhookUrl)) - _webhookUrlInput.Text = vm.WebhookUrl; - - _webhookUrlInput.OnFocused(); - _lastFocusedInput = _webhookUrlInput; - _lastFocusedList = null; - - _webhookUrlInput.Submitted - .Subscribe(text => - { - vm.WebhookUrl = string.IsNullOrWhiteSpace(text) ? null : text; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Notification webhook URL (optional, press Enter to skip):").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_webhookUrlInput, "Webhook")); - } - public bool HandleKeyPress(KeyPressed key) { if (_lastFocusedList is not null) @@ -218,7 +166,5 @@ public void ClearFocusState() _commStyleList = null; _userNameInput = null; _timezoneInput = null; - _workspacesInput = null; - _webhookUrlInput = null; } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs index 86ae8ab62..95e50bf4a 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/IdentityStepViewModel.cs @@ -5,15 +5,22 @@ // ----------------------------------------------------------------------- using System.Reflection; using System.Text.RegularExpressions; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Wizard.Steps; /// -/// Wizard step for configuring agent identity (name, communication style, user profile, webhook, workspaces). -/// 6 sub-steps: agent name → comm style → user name → timezone → workspaces directory → webhook URL. +/// Wizard step for configuring agent identity (name, communication style, user profile). +/// 4 sub-steps: agent name → comm style → user name → timezone. +/// Workspaces directory and notification webhooks are post-install settings owned by +/// netclaw config (Workspaces Directory; Telemetry & Alerting → outbound webhooks), +/// so the first-run wizard does not collect them. /// -public sealed class IdentityStepViewModel : IWizardStepViewModel +[NoDoctorChecks("Identity is synthetic and init-owned. Doctor coverage applies to the underlying config and generated identity files instead.")] +public sealed class IdentityStepViewModel : IWizardStepViewModel, ISectionEditor { private int _currentSubStep; private int _highWaterSubStep; @@ -21,20 +28,22 @@ public sealed class IdentityStepViewModel : IWizardStepViewModel public string StepId => WizardStepIds.Identity; public string DisplayTitle => "Identity"; + public string SectionId => StepId; + public string DisplayName => DisplayTitle; + public string? Category => null; + public bool ShowInMenu => false; + public IReadOnlyList RelevantDoctorChecks => []; // ── State ── public string AgentName { get; set; } = "Netclaw"; public string? CommunicationStyle { get; set; } public string? UserName { get; set; } public string UserTimezone { get; set; } = TimeZoneInfo.Local.Id; - public string WorkspacesDirectory { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".netclaw", "workspaces"); - public string? WebhookUrl { get; set; } public bool IsApplicable(WizardContext context) => true; public int CurrentSubStep => _currentSubStep; - public int SubStepCount => 6; + public int SubStepCount => 4; public string GetHelpText() => _currentSubStep switch { @@ -42,8 +51,6 @@ public sealed class IdentityStepViewModel : IWizardStepViewModel 1 => " How should your assistant communicate?", 2 => " So your assistant knows what to call you.", 3 => " Used for time-aware responses and scheduling.", - 4 => " Where your agent stores and discovers project workspaces. Press Enter to keep the default.", - 5 => " Optional. Receive alerts when MCP servers disconnect or LLM providers fail. Press Enter to skip.", _ => "" }; @@ -71,6 +78,7 @@ public bool TryGoBack() public void OnEnter(WizardContext context, NavigationDirection direction) { _context = context; + PrefillFromExistingConfig(context); if (direction == NavigationDirection.Forward) _currentSubStep = 0; else if (direction == NavigationDirection.Back) @@ -88,19 +96,6 @@ public void ContributeConfig(WizardConfigBuilder builder) UserName = UserName, UserTimezone = UserTimezone }; - - builder.Workspaces = new WorkspacesConfigSection - { - Directory = WorkspacesDirectory - }; - - if (!string.IsNullOrWhiteSpace(WebhookUrl)) - { - builder.Notifications = new NotificationsConfigSection - { - WebhookUrl = WebhookUrl - }; - } } public void ContributeSecrets(WizardSecretsBuilder builder) { } @@ -112,6 +107,33 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => HasPersistedIdentity(context) ? SectionStatus.Configured : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var name = !string.IsNullOrWhiteSpace(AgentName) ? AgentName : ReadString(context, "Identity.AgentName"); + var timezone = !string.IsNullOrWhiteSpace(UserTimezone) ? UserTimezone : ReadString(context, "Identity.UserTimezone"); + return string.IsNullOrWhiteSpace(name) ? "Not configured" : string.IsNullOrWhiteSpace(timezone) ? name : $"{name} ({timezone})"; + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (IdentityStepViewModel)editor; + return new SectionContribution( + [ + new SectionFieldAction("Identity.AgentName", SectionFieldActionKind.Set, vm.AgentName), + new SectionFieldAction("Identity.CommunicationStyle", SectionFieldActionKind.Set, vm.CommunicationStyle ?? "Concise & casual"), + string.IsNullOrWhiteSpace(vm.UserName) + ? new SectionFieldAction("Identity.UserName", SectionFieldActionKind.Delete) + : new SectionFieldAction("Identity.UserName", SectionFieldActionKind.Set, vm.UserName), + new SectionFieldAction("Identity.UserTimezone", SectionFieldActionKind.Set, vm.UserTimezone) + ]); + } + /// /// Write SOUL.md and TOOLING.md identity files. Called during config finalization. /// Reads templates from embedded resources and substitutes placeholders. @@ -148,7 +170,9 @@ public void WriteIdentityFiles(NetclawPaths paths) ["{{AGENTS_DETAIL_DIR}}"] = paths.AgentsDetailDirectory, ["{{TOOLING_DETAIL_DIR}}"] = paths.ToolingDetailDirectory, ["{{SKILLS_DIR}}"] = paths.SkillsDirectory, - ["{{WORKSPACES_DIR}}"] = WorkspacesDirectory + // Workspaces dir is no longer collected in init; use the resolved default + // (configured Workspaces.Directory or {BasePath}/workspaces) for the templates. + ["{{WORKSPACES_DIR}}"] = paths.WorkspacesDirectory }; File.WriteAllText(paths.SoulPath, SubstitutePlaceholders( @@ -290,5 +314,25 @@ private static void SeedAgentFile(string directory, string fileName, string cont File.WriteAllText(path, content); } + private void PrefillFromExistingConfig(WizardContext context) + { + if (context.ExistingConfig is null) + return; + + AgentName = ReadString(context, "Identity.AgentName") ?? AgentName; + CommunicationStyle ??= ReadString(context, "Identity.CommunicationStyle"); + UserName ??= ReadString(context, "Identity.UserName"); + UserTimezone = ReadString(context, "Identity.UserTimezone") ?? UserTimezone; + } + + private static bool HasPersistedIdentity(WizardContext context) + => !string.IsNullOrWhiteSpace(ReadString(context, "Identity.AgentName")); + + private static string? ReadString(WizardContext context, string path) + => context.ExistingConfig is not null + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, path, out var value) + ? value as string + : null; + public void Dispose() { } } diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs index b3dea12ff..a756293cd 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepView.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; using R3; using Termina.Extensions; using Termina.Input; @@ -20,6 +21,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// public sealed class MattermostStepView : IWizardStepView { + private MattermostStepViewModel? _vm; private SelectionListNode? _enabledList; private TextInputNode? _serverUrlInput; private TextInputNode? _botTokenInput; @@ -36,6 +38,7 @@ public sealed class MattermostStepView : IWizardStepView public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) { var vm = (MattermostStepViewModel)stepVm; + _vm = vm; return vm.CurrentSubStep switch { @@ -84,19 +87,32 @@ private ILayoutNode BuildServerUrlSubStep(MattermostStepViewModel vm, StepViewCa { _serverUrlInput = new TextInputNode() .WithPlaceholder("https://mm.example.com"); - - if (!string.IsNullOrWhiteSpace(vm.ServerUrl)) - _serverUrlInput.Text = vm.ServerUrl; + WizardStepHelpers.SeedTextInput(_serverUrlInput, vm.ServerUrlDraft ?? vm.ServerUrl); _serverUrlInput.OnFocused(); _lastFocusedInput = _serverUrlInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_serverUrlInput, StageFocusedInput, callbacks); _serverUrlInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + vm.ServerUrlDraft = null; + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostServerUrlRequired); + return; + } + + if (!ChannelsEditorValidator.IsHttpUrl(text.Trim())) + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostServerUrlAbsoluteHttp); + return; + } + vm.ServerUrl = text.Trim(); + vm.ServerUrlDraft = vm.ServerUrl; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); @@ -111,36 +127,59 @@ private ILayoutNode BuildBotTokenSubStep(MattermostStepViewModel vm, StepViewCal _botTokenInput = new TextInputNode() .AsPassword() .WithPlaceholder("Mattermost bot access token"); + WizardStepHelpers.SeedTextInput(_botTokenInput, vm.BotTokenDraft ?? vm.BotToken); _botTokenInput.OnFocused(); _lastFocusedInput = _botTokenInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_botTokenInput, StageFocusedInput, callbacks); _botTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + vm.BotTokenDraft = null; + if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + { + callbacks.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostBotTokenRequired); + } + + return; + } + vm.BotToken = text; + vm.BotTokenDraft = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Mattermost Bot Token:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_botTokenInput, "Bot Token")); + + if (vm.HasPersistedBotToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildChannelIdsSubStep(MattermostStepViewModel vm, StepViewCallbacks callbacks) { _channelIdsInput = new TextInputNode() .WithPlaceholder("4xp9p3onpins8..., 9rp7q1... (leave blank to skip)"); - - if (!string.IsNullOrWhiteSpace(vm.ChannelIdsInput)) - _channelIdsInput.Text = vm.ChannelIdsInput; + WizardStepHelpers.SeedTextInput(_channelIdsInput, vm.ChannelIdsInput); _channelIdsInput.OnFocused(); _lastFocusedInput = _channelIdsInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_channelIdsInput, StageFocusedInput, callbacks); _channelIdsInput.Submitted .Subscribe(text => @@ -200,13 +239,12 @@ private ILayoutNode BuildAllowedUserIdsSubStep(MattermostStepViewModel vm, StepV { _allowedUserIdsInput = new TextInputNode() .WithPlaceholder("4xp9p3onpins8..., 9rp... (Mattermost user IDs)"); - - if (!string.IsNullOrWhiteSpace(vm.AllowedUserIdsInput)) - _allowedUserIdsInput.Text = vm.AllowedUserIdsInput; + WizardStepHelpers.SeedTextInput(_allowedUserIdsInput, vm.AllowedUserIdsInput); _allowedUserIdsInput.OnFocused(); _lastFocusedInput = _allowedUserIdsInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_allowedUserIdsInput, StageFocusedInput, callbacks); _allowedUserIdsInput.Submitted .Where(text => !string.IsNullOrWhiteSpace(text)) @@ -226,18 +264,25 @@ private ILayoutNode BuildCallbackUrlSubStep(MattermostStepViewModel vm, StepView { _callbackUrlInput = new TextInputNode() .WithPlaceholder("https://netclaw.example.com/api/mattermost/actions (leave blank to skip)"); - - if (!string.IsNullOrWhiteSpace(vm.CallbackUrl)) - _callbackUrlInput.Text = vm.CallbackUrl; + WizardStepHelpers.SeedTextInput(_callbackUrlInput, vm.CallbackUrlDraft ?? vm.CallbackUrl); _callbackUrlInput.OnFocused(); _lastFocusedInput = _callbackUrlInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_callbackUrlInput, StageFocusedInput, callbacks); _callbackUrlInput.Submitted .Subscribe(text => { + if (!string.IsNullOrWhiteSpace(text) && !ChannelsEditorValidator.IsHttpUrl(text.Trim())) + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.MattermostCallbackUrlAbsoluteHttp); + return; + } + vm.CallbackUrl = string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + vm.CallbackUrlDraft = vm.CallbackUrl; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); @@ -265,6 +310,8 @@ public bool HandleKeyPress(KeyPressed key) if (_lastFocusedInput is not null) { _lastFocusedInput.HandleInput(key.KeyInfo); + if (key.KeyInfo.Key != ConsoleKey.Enter) + StageFocusedInput(); return true; } @@ -274,6 +321,24 @@ public bool HandleKeyPress(KeyPressed key) public void HandlePaste(PasteEvent paste) { _lastFocusedInput?.HandlePaste(paste); + StageFocusedInput(); + } + + private void StageFocusedInput() + { + if (_vm is null) + return; + + if (ReferenceEquals(_lastFocusedInput, _serverUrlInput)) + _vm.ServerUrlDraft = _serverUrlInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _botTokenInput)) + _vm.BotTokenDraft = _botTokenInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _channelIdsInput)) + _vm.ChannelIdsInput = _channelIdsInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _allowedUserIdsInput)) + _vm.AllowedUserIdsInput = _allowedUserIdsInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _callbackUrlInput)) + _vm.CallbackUrlDraft = _callbackUrlInput?.Text; } public void ClearFocusState() diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs index cbf03b96f..b2b8dd6af 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/MattermostStepViewModel.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Actors.Channels; +using Netclaw.Cli.Mattermost; using Netclaw.Configuration; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -14,16 +15,20 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// enable -> server URL -> bot token -> channel IDs -> DM enabled -> user access choice -> /// allowed user IDs (conditional) -> callback URL. /// Mattermost is self-hosted, so the server URL is collected up front and there is no -/// auth probe — the wizard validates configuration locally. +/// auth probe. The health-check step resolves channel references to canonical IDs against +/// the live server (mirroring Slack/Discord) so only matchable IDs persist into the ACL; +/// connectivity itself is validated locally. /// public sealed class MattermostStepViewModel : IWizardStepViewModel, IChannelAdapterViewModel { + private readonly IMattermostProbe _mattermostProbe; private int _currentSubStep; private int _highWaterSubStep; private WizardContext? _context; - public MattermostStepViewModel() + public MattermostStepViewModel(IMattermostProbe mattermostProbe) { + _mattermostProbe = mattermostProbe; } public string StepId => WizardStepIds.Mattermost; @@ -42,11 +47,20 @@ bool IChannelAdapterViewModel.AdapterEnabled public string? ServerUrl { get; set; } public string? BotToken { get; set; } + internal string? ServerUrlDraft { get; set; } + internal string? BotTokenDraft { get; set; } + public bool HasPersistedBotToken { get; set; } public string? ChannelIdsInput { get; set; } public bool AllowDirectMessages { get; set; } public bool RestrictToSpecificUsers { get; set; } public string? AllowedUserIdsInput { get; set; } public string? CallbackUrl { get; set; } + internal string? CallbackUrlDraft { get; set; } + + // Most recent channel-reference resolution against the live Mattermost server. Drives both + // the red-flag rendering of unresolved rows and the canonical-ID persistence in + // ContributeConfig / BuildChannelAudiences (names never persist verbatim into the ACL). + internal MattermostChannelResolutionResult? LastChannelResolution { get; set; } internal bool SkipEnableSubStep { get; set; } @@ -161,11 +175,14 @@ internal void ResetConfig() MattermostEnabled = false; ServerUrl = null; BotToken = null; + ServerUrlDraft = null; + BotTokenDraft = null; ChannelIdsInput = null; AllowDirectMessages = false; RestrictToSpecificUsers = false; AllowedUserIdsInput = null; CallbackUrl = null; + CallbackUrlDraft = null; var startSubStep = SkipEnableSubStep ? 1 : 0; _currentSubStep = startSubStep; _highWaterSubStep = startSubStep; @@ -190,19 +207,11 @@ public void OnLeave() if (AllowDirectMessages) { var allowedUsers = ParseUserIds(AllowedUserIdsInput); - var dmAudience = allowedUsers.Count == 1 - ? TrustAudience.Personal - : posture == DeploymentPosture.Personal - ? TrustAudience.Personal - : posture == DeploymentPosture.Team - ? TrustAudience.Team - : TrustAudience.Public; + var dmAudience = ChannelAudienceDefaults.ForDirectMessage(posture, allowedUsers.Count); entries.Add(new ChannelEntry("Mattermost DMs", "dm", dmAudience, isDmRow: true)); } - var channelAudience = posture == DeploymentPosture.Public - ? TrustAudience.Public - : TrustAudience.Team; + var channelAudience = ChannelAudienceDefaults.ForChannel(posture); var channelIds = ParseChannelIds(ChannelIdsInput); foreach (var channelId in channelIds) @@ -216,16 +225,22 @@ public void ContributeConfig(WizardConfigBuilder builder) if (!MattermostEnabled) return; - var channelIds = ParseChannelIds(ChannelIdsInput); var userIds = ParseUserIds(AllowedUserIdsInput); + // Persist only canonical channel IDs the runtime ACL can match. An unresolved channel + // reference (a name/slug the bot can't see) is omitted, not written verbatim — an + // unmatchable entry in AllowedChannelIds is inert and grants nothing. Mirrors Slack/Discord. + var resolvedChannelIds = LastChannelResolution is { Resolved.Count: > 0 } resolution + ? resolution.Resolved.Select(channel => channel.ChannelId).ToList() + : new List(); + builder.Mattermost = new MattermostConfigSection { Enabled = true, ServerUrl = string.IsNullOrWhiteSpace(ServerUrl) ? null : ServerUrl.Trim(), CallbackUrl = string.IsNullOrWhiteSpace(CallbackUrl) ? null : CallbackUrl.Trim(), - DefaultChannelId = channelIds.FirstOrDefault(), - AllowedChannelIds = channelIds.Count > 0 ? channelIds : null, + DefaultChannelId = resolvedChannelIds.FirstOrDefault(), + AllowedChannelIds = resolvedChannelIds.Count > 0 ? resolvedChannelIds : null, AllowDirectMessages = AllowDirectMessages, AllowedUserIds = userIds.Count > 0 ? userIds : null, ChannelAudiences = BuildChannelAudiences() @@ -243,33 +258,53 @@ public void ContributeSecrets(WizardSecretsBuilder builder) }); } - public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) + public async Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) { - runner.Add(new HealthCheckItem("Mattermost configuration", null)); + if (!runner.BeginAdapterCheck("Mattermost", MattermostEnabled, (ServerUrl, "server URL"), (BotToken, "bot token"))) + return; - if (!MattermostEnabled) - { - runner.UpdateLast(new HealthCheckItem("Mattermost configuration (disabled)", true)); - return Task.CompletedTask; - } + // Mattermost is self-hosted with no first-party auth-probe API; the daemon + // verifies connectivity on startup. The wizard validates configuration locally. + runner.UpdateLast(new HealthCheckItem( + $"Mattermost configured (server: {ServerUrl})", true)); + + // Resolve channel references (id / slug / display name) against the live server so the + // persisted allow-list holds canonical channel IDs the runtime ACL can match — an + // unresolved name in AllowedChannelIds is inert. Mirrors Slack/Discord. BeginAdapterCheck + // above already guaranteed ServerUrl and BotToken are present. + var parsedChannelIds = ParseChannelIds(ChannelIdsInput); + if (parsedChannelIds.Count == 0) + return; - if (string.IsNullOrWhiteSpace(ServerUrl)) + runner.Add(new HealthCheckItem("Resolving Mattermost channels", null)); + try { - runner.UpdateLast(new HealthCheckItem("Mattermost configuration (server URL missing)", false)); - return Task.CompletedTask; - } + LastChannelResolution = await _mattermostProbe.ResolveChannelIdsAsync( + ServerUrl!, BotToken!, parsedChannelIds, ct); - if (string.IsNullOrWhiteSpace(BotToken)) + if (LastChannelResolution.ErrorMessage is not null) + { + runner.UpdateLast(new HealthCheckItem( + $"Mattermost channel lookup failed: {LastChannelResolution.ErrorMessage}", false)); + } + else if (LastChannelResolution.Unresolved.Count > 0) + { + var notFound = string.Join(", ", LastChannelResolution.Unresolved); + runner.UpdateLast(new HealthCheckItem( + $"Mattermost channels: resolved {LastChannelResolution.Resolved.Count}/{parsedChannelIds.Count}, not found: {notFound}", + false)); + } + else + { + runner.UpdateLast(new HealthCheckItem( + $"Mattermost channels resolved ({LastChannelResolution.Resolved.Count})", true)); + } + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) { - runner.UpdateLast(new HealthCheckItem("Mattermost configuration (bot token missing)", false)); - return Task.CompletedTask; + runner.UpdateLast(new HealthCheckItem( + "Mattermost channel resolution timed out. Check your network connection.", false)); } - - // Mattermost is self-hosted with no first-party auth-probe API; the daemon - // verifies connectivity on startup. The wizard validates configuration locally. - runner.UpdateLast(new HealthCheckItem( - $"Mattermost configured (server: {ServerUrl})", true)); - return Task.CompletedTask; } private Dictionary? BuildChannelAudiences() @@ -282,11 +317,41 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo var audiences = new Dictionary(StringComparer.Ordinal); foreach (var entry in entries) - audiences[entry.Id] = entry.Audience.ToWireValue(); + { + // Only write an audience under a key the runtime ACL can match — a resolved channel ID + // or the literal "dm" DM key. An unresolved channel reference is a dead key the runtime + // never matches, so omit it instead of silently writing inert ACL config (a + // no-silent-fallback violation on a security path). Mirrors Slack/Discord. + if (TryResolveChannelAudienceKey(entry, out var key)) + audiences[key] = entry.Audience.ToWireValue(); + } return audiences.Count > 0 ? audiences : null; } + private bool TryResolveChannelAudienceKey(ChannelEntry entry, out string key) + { + if (entry.IsDmRow) + { + key = entry.Id; // canonical DM key ("dm") + return true; + } + + key = string.Empty; + if (LastChannelResolution is null) + return false; + + var resolved = LastChannelResolution.Resolved.FirstOrDefault(channel => + string.Equals(channel.ChannelName, entry.Id, StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.ChannelId, entry.Id, StringComparison.Ordinal)); + + if (string.IsNullOrWhiteSpace(resolved?.ChannelId)) + return false; + + key = resolved.ChannelId; + return true; + } + internal static List ParseChannelIds(string? input) => string.IsNullOrWhiteSpace(input) ? [] diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs index 06eb3aab3..a3e2bb34c 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/ProviderStepViewModel.cs @@ -4,9 +4,11 @@ // // ----------------------------------------------------------------------- using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; using Netclaw.Cli.Config; using Netclaw.Cli.Daemon; using Netclaw.Cli.Tui; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; using Netclaw.Providers; @@ -20,7 +22,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Sub-steps: 0=provider selection, 1=auth method, 2=credentials, 3=validation, /// 4=model selection, 5=OAuth device flow, 6=OAuth browser flow. /// -public sealed class ProviderStepViewModel : IWizardStepViewModel +public sealed class ProviderStepViewModel : IWizardStepViewModel, ISectionEditor { private readonly IProviderProbe _probe; private readonly ProviderDescriptorRegistry _registry; @@ -46,6 +48,11 @@ public ProviderStepViewModel( public string StepId => WizardStepIds.Provider; public string DisplayTitle => "LLM Provider"; + public string SectionId => StepId; + public string DisplayName => DisplayTitle; + public string? Category => null; + public bool ShowInMenu => false; + public IReadOnlyList RelevantDoctorChecks => ["Config Schema", "Context Window"]; // ── State ── public string? SelectedProviderType { get; set; } @@ -53,6 +60,7 @@ public ProviderStepViewModel( public string? ApiKeyInput { get; set; } public string? EndpointInput { get; set; } public string? SelectedModelId { get; set; } + public bool HasStoredCredential { get; private set; } public List DiscoveredModels { get; } = []; public OAuthFlowCoordinator OAuth { get; } public ProviderDescriptorRegistry Registry => _registry; @@ -135,6 +143,7 @@ public bool TryGoBack() public void OnEnter(WizardContext context, NavigationDirection direction) { _context = context; + PrefillFromExistingConfig(context); if (direction == NavigationDirection.Back) _currentSubStep = _highWaterSubStep; } @@ -151,20 +160,20 @@ public void StartProbe() public void CancelProbe() { - if (_probeCts is not null) - { - _probeCts.Cancel(); - _probeCts.Dispose(); - _probeCts = null; - } + // Atomically take ownership of the active CTS so a concurrently-completing probe's finally + // cannot also cancel/dispose it (double dispose, or cancelling a newer probe's live CTS). + var cts = Interlocked.Exchange(ref _probeCts, null); + cts?.Cancel(); + cts?.Dispose(); } internal Task? ProbeCompletion { get; private set; } internal async Task ProbeProviderAsync() { - _probeCts = new CancellationTokenSource(); - var ct = _probeCts.Token; + var cts = new CancellationTokenSource(); + _probeCts = cts; + var ct = cts.Token; var providerType = SelectedProviderType ?? "unknown"; var probeEntry = BuildProbeEntry(providerType); @@ -203,7 +212,14 @@ internal async Task ProbeProviderAsync() } finally { - CancelProbe(); + // Tear down only THIS probe's CTS, and only if it is still the active one. A newer probe + // (StartProbe → CancelProbe) may have already replaced and disposed it; claiming the field + // atomically stops this finally from cancelling/disposing the newer probe's live CTS. + if (Interlocked.CompareExchange(ref _probeCts, null, cts) == cts) + { + cts.Cancel(); + cts.Dispose(); + } } DiscoveredModels.Clear(); @@ -359,7 +375,8 @@ public void WriteProviderCredentials(NetclawPaths paths) OAuth.Result, ApiKeyInput, _registry, - SensitiveStringTypeConverter.Protector); + // Protector for this config's keys directory, not the process-wide static service locator. + SecretsProtection.CreateProtector(paths)); } public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) @@ -380,6 +397,147 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => !string.IsNullOrWhiteSpace(SelectedProviderType) || ConfigFileHelper.PathPresent(context.ExistingConfig ?? [], "Providers") + ? SectionStatus.Configured + : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var providerType = SelectedProviderType ?? ReadExistingProviderType(context); + var modelId = SelectedModelId ?? ReadExistingModelId(context); + if (string.IsNullOrWhiteSpace(providerType)) + return "Not configured"; + + return string.IsNullOrWhiteSpace(modelId) ? providerType : $"{providerType} / {modelId}"; + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (ProviderStepViewModel)editor; + if (string.IsNullOrWhiteSpace(vm.SelectedProviderType)) + return SectionContribution.Empty; + + var providerType = vm.SelectedProviderType.ToLowerInvariant(); + var fieldActions = new List + { + new("Providers", SectionFieldActionKind.Set, BuildProvidersDictionary(vm, providerType)), + new("Models.Main.Provider", SectionFieldActionKind.Set, providerType) + }; + + if (string.IsNullOrWhiteSpace(vm.SelectedModelId)) + fieldActions.Add(new SectionFieldAction("Models.Main.ModelId", SectionFieldActionKind.Delete)); + else + fieldActions.Add(new SectionFieldAction("Models.Main.ModelId", SectionFieldActionKind.Set, vm.SelectedModelId)); + + var secretPath = $"Providers.{providerType}"; + var secretActions = new List(); + if (!string.IsNullOrWhiteSpace(vm.ApiKeyInput)) + { + secretActions.Add(new SectionSecretAction($"{secretPath}.ApiKey", SectionSecretActionKind.Set, + new SensitiveString(vm.ApiKeyInput))); + } + else if (vm.HasStoredCredential) + { + secretActions.Add(new SectionSecretAction(secretPath, SectionSecretActionKind.Preserve)); + } + + return new SectionContribution(fieldActions, secretActions); + } + + private void PrefillFromExistingConfig(WizardContext context) + { + if (context.ExistingConfig is null) + return; + + var providerType = ReadExistingProviderType(context); + if (string.IsNullOrWhiteSpace(providerType)) + return; + + SelectedProviderType ??= providerType; + SelectedModelId ??= ReadExistingModelId(context); + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, $"Providers.{providerType}.Endpoint", out var endpoint) + && endpoint is string endpointText) + { + EndpointInput ??= endpointText; + } + + if (ConfigFileHelper.TryGetPathValue(context.ExistingConfig, $"Providers.{providerType}.AuthMethod", out var authMethod) + && authMethod is string authMethodText + && Enum.TryParse(authMethodText, ignoreCase: true, out var parsed)) + { + SelectedAuthMethod = parsed; + } + + HasStoredCredential = ConfigFileHelper.SecretPresent(context.Paths, $"Providers.{providerType}.ApiKey") + || ConfigFileHelper.SecretPresent(context.Paths, $"Providers.{providerType}.OAuthAccessToken"); + } + + private static string? ReadExistingProviderType(WizardContext context) + { + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Models.Main.Provider", out var provider) + || provider is not string providerText) + { + return null; + } + + return providerText; + } + + private static string? ReadExistingModelId(WizardContext context) + => context.ExistingConfig is not null + && ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Models.Main.ModelId", out var model) + ? model as string + : null; + + private Dictionary BuildProvidersDictionary(ProviderStepViewModel vm, string providerType) + { + var providerEntry = new Dictionary + { + [providerType] = BuildProviderEntry(vm, providerType) + }; + + if (_context?.ExistingConfig is not null + && ConfigFileHelper.TryGetPathValue(_context.ExistingConfig, "Providers", out var existing) + && existing is Dictionary existingProviders) + { + foreach (var (key, value) in existingProviders) + { + if (!providerEntry.ContainsKey(key)) + providerEntry[key] = value; + } + } + + return providerEntry; + } + + private Dictionary BuildProviderEntry(ProviderStepViewModel vm, string providerType) + { + var entry = new Dictionary + { + ["Type"] = providerType + }; + + if (vm.SelectedAuthMethod != AuthMethod.None) + entry["AuthMethod"] = vm.SelectedAuthMethod.ToString(); + + var endpoint = !string.IsNullOrWhiteSpace(vm.EndpointInput) + ? vm.EndpointInput + : _registry.TryGet(providerType, out var descriptor) && descriptor.Auth is EndpointOnlyAuth + ? descriptor.DefaultEndpoint + : null; + + if (!string.IsNullOrWhiteSpace(endpoint)) + entry["Endpoint"] = endpoint; + + return entry; + } + public void Dispose() { CancelProbe(); diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SearchStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SearchStepView.cs deleted file mode 100644 index e086f9e1f..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SearchStepView.cs +++ /dev/null @@ -1,160 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; -using R3; -using Termina.Extensions; -using Termina.Input; -using Termina.Layout; -using Termina.Reactive; -using Termina.Rendering; -using Termina.Terminal; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Termina view for the Search wizard step. -/// Sub-step 0: backend selection list. Sub-step 1: credentials input. -/// -public sealed class SearchStepView : IWizardStepView -{ - private IDisposable? _backendList; - private TextInputNode? _braveApiKeyInput; - private TextInputNode? _searxngEndpointInput; - private IFocusable? _lastFocusedList; - private TextInputBaseNode? _lastFocusedInput; - - public string StepId => WizardStepIds.Search; - - public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) - { - var vm = (SearchStepViewModel)stepVm; - - return vm.CurrentSubStep switch - { - 0 => BuildBackendSelection(vm, callbacks), - 1 => BuildCredentialInput(vm, callbacks), - _ => Layouts.Empty() - }; - } - - private ILayoutNode BuildBackendSelection(SearchStepViewModel vm, StepViewCallbacks callbacks) - { - var duckDuckGoOption = new SelectionOption(SearchBackend.DuckDuckGo, - "DuckDuckGo (default — no config needed, may hit bot detection)"); - var braveOption = new SelectionOption(SearchBackend.Brave, - "Brave Search (API key required — reliable, fast)"); - var searxngOption = new SelectionOption(SearchBackend.SearXng, - "SearXNG (self-hosted — endpoint required)"); - - var backendList = Layouts.SelectionList>( - [duckDuckGoOption, braveOption, searxngOption], static o => o.ToString()) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _backendList = backendList; - backendList.OnFocused(); - _lastFocusedList = backendList; - _lastFocusedInput = null; - - backendList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count > 0) - { - vm.SelectedBackend = selected[0].Value; - callbacks.AdvanceStep(); - } - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Choose your web search provider:").WithForeground(Color.White)) - .WithChild(backendList); - } - - private ILayoutNode BuildCredentialInput(SearchStepViewModel vm, StepViewCallbacks callbacks) - { - _lastFocusedList = null; - - if (vm.SelectedBackend == SearchBackend.Brave) - { - _braveApiKeyInput = new TextInputNode() - .AsPassword() - .WithPlaceholder("Enter Brave Search API key..."); - - if (!string.IsNullOrWhiteSpace(vm.BraveApiKey)) - _braveApiKeyInput.Text = vm.BraveApiKey; - - _braveApiKeyInput.OnFocused(); - _lastFocusedInput = _braveApiKeyInput; - - _braveApiKeyInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) - .Subscribe(text => - { - vm.BraveApiKey = text; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Brave Search API key:").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_braveApiKeyInput, "API Key")); - } - - // SearXNG - _searxngEndpointInput = new TextInputNode() - .WithPlaceholder("http://searxng.local:8080"); - - if (!string.IsNullOrWhiteSpace(vm.SearXngEndpoint)) - _searxngEndpointInput.Text = vm.SearXngEndpoint; - - _searxngEndpointInput.OnFocused(); - _lastFocusedInput = _searxngEndpointInput; - - _searxngEndpointInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) - .Subscribe(text => - { - vm.SearXngEndpoint = text; - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" SearXNG endpoint URL:").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_searxngEndpointInput, "Endpoint")); - } - - public bool HandleKeyPress(KeyPressed key) - { - if (_lastFocusedList is not null) - { - _lastFocusedList.HandleInput(key.KeyInfo); - return true; - } - if (_lastFocusedInput is not null) - { - _lastFocusedInput.HandleInput(key.KeyInfo); - return true; - } - return false; - } - - public void HandlePaste(PasteEvent paste) - { - _lastFocusedInput?.HandlePaste(paste); - } - - public void ClearFocusState() - { - _lastFocusedList = null; - _lastFocusedInput = null; - _backendList = null; - _braveApiKeyInput = null; - _searxngEndpointInput = null; - } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SearchStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SearchStepViewModel.cs deleted file mode 100644 index 2166e6b42..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SearchStepViewModel.cs +++ /dev/null @@ -1,98 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Wizard step for selecting the web search backend (DuckDuckGo/Brave/SearXNG) -/// and entering credentials if needed. Two sub-steps: backend selection, then credentials. -/// -public sealed class SearchStepViewModel : IWizardStepViewModel -{ - private int _currentSubStep; - private int _highWaterSubStep; - - public string StepId => WizardStepIds.Search; - public string DisplayTitle => "Web Search"; - - public SearchBackend SelectedBackend { get; set; } = SearchBackend.DuckDuckGo; - public string? BraveApiKey { get; set; } - public string? SearXngEndpoint { get; set; } - - public bool IsApplicable(WizardContext context) => true; - - public int CurrentSubStep => _currentSubStep; - - public int SubStepCount => NeedsCredentials ? 2 : 1; - - private bool NeedsCredentials => SelectedBackend is SearchBackend.Brave or SearchBackend.SearXng; - - public string GetHelpText() => _currentSubStep switch - { - 0 => " DuckDuckGo works without config but may hit bot detection. Brave Search is more reliable.", - 1 when SelectedBackend == SearchBackend.Brave => - " Get a free API key at https://brave.com/search/api/. Stored in secrets.json.", - 1 => " Enter the base URL of your SearXNG instance. JSON format must be enabled in settings.yml.", - _ => "" - }; - - public bool TryAdvance() - { - if (_currentSubStep == 0 && NeedsCredentials) - { - _currentSubStep = 1; - _highWaterSubStep = 1; - return true; - } - return false; // step complete - } - - public bool TryGoBack() - { - if (_currentSubStep > 0) - { - _currentSubStep--; - return true; - } - return false; - } - - public void OnEnter(WizardContext context, NavigationDirection direction) - { - if (direction == NavigationDirection.Back) - _currentSubStep = _highWaterSubStep; - else - _currentSubStep = 0; - } - - public void OnLeave() { } - - public void ContributeConfig(WizardConfigBuilder builder) - { - builder.Search = new SearchConfigSection - { - Backend = SelectedBackend, - SearXngEndpoint = SearXngEndpoint - }; - } - - public void ContributeSecrets(WizardSecretsBuilder builder) - { - if (SelectedBackend == SearchBackend.Brave && !string.IsNullOrWhiteSpace(BraveApiKey)) - { - builder.AddSection("Search", new Dictionary - { - ["BraveApiKey"] = BraveApiKey - }); - } - } - - public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) - => Task.CompletedTask; - - public void Dispose() { } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs index 400979d3a..6e723b743 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SecurityPostureStepViewModel.cs @@ -4,6 +4,8 @@ // // ----------------------------------------------------------------------- using Netclaw.Configuration; +using Netclaw.Cli.Config; +using Netclaw.Cli.Tui.Sections; namespace Netclaw.Cli.Tui.Wizard.Steps; @@ -11,12 +13,17 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// Wizard step for selecting the deployment security posture (Personal/Team/Public). /// Single sub-step, no async operations. /// -public sealed class SecurityPostureStepViewModel : IWizardStepViewModel +public sealed class SecurityPostureStepViewModel : IWizardStepViewModel, ISectionEditor { private WizardContext? _context; public string StepId => WizardStepIds.SecurityPosture; public string DisplayTitle => "Security Posture"; + public string SectionId => StepId; + public string DisplayName => DisplayTitle; + public string? Category => "Security & Access"; + public bool ShowInMenu => true; + public IReadOnlyList RelevantDoctorChecks => ["Security Policy", "Tool Audience Profiles"]; public DeploymentPosture? SelectedPosture { get; set; } @@ -56,9 +63,7 @@ public void OnLeave() public void ContributeConfig(WizardConfigBuilder builder) { var posture = SelectedPosture ?? DeploymentPosture.Personal; - var shellMode = posture == DeploymentPosture.Personal - ? ShellExecutionMode.HostAllowed - : ShellExecutionMode.Off; + var shellMode = ShellModeFor(posture); builder.Security = new SecurityConfigSection { @@ -66,25 +71,10 @@ public void ContributeConfig(WizardConfigBuilder builder) ShellExecutionMode = shellMode }; - var profiles = ToolAudienceProfileDefaults.CreateProfiles(); - - // Personal posture: enable approval gates for shell by default. - // The operator can override this in config if they want unrestricted shell. - if (posture == DeploymentPosture.Personal) - { - profiles.Personal.ApprovalPolicy = new ToolApprovalConfig - { - ToolOverrides = new Dictionary(StringComparer.Ordinal) - { - ["shell_execute"] = ToolApprovalMode.Approval - } - }; - } - builder.Tools = new ToolConfig { ShellMode = shellMode, - AudienceProfiles = profiles + AudienceProfiles = BuildAudienceProfiles(posture) }; } @@ -99,6 +89,81 @@ public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationTo return Task.CompletedTask; } + public SectionStatus GetStatus(WizardContext context) + => context.SelectedPosture.HasValue || SectionEditorAudit.HasExistingConfig(context, "Security.DeploymentPosture") + ? SectionStatus.Configured + : SectionStatus.NotConfigured; + + public string Summary(WizardContext context) + { + var posture = SelectedPosture + ?? context.SelectedPosture + ?? ReadExistingPosture(context); + + return posture?.ToString() ?? "Not configured"; + } + + public IWizardStepViewModel CreateEditor(IServiceProvider services) + => ActivatorUtilities.CreateInstance(services); + + public SectionContribution BuildContribution(IWizardStepViewModel editor) + { + var vm = (SecurityPostureStepViewModel)editor; + var posture = vm.SelectedPosture ?? DeploymentPosture.Personal; + var shellMode = ShellModeFor(posture); + + return new SectionContribution( + [ + new SectionFieldAction("Security.DeploymentPosture", SectionFieldActionKind.Set, posture.ToString()), + new SectionFieldAction("Security.ShellExecutionMode", SectionFieldActionKind.Set, shellMode.ToString()), + new SectionFieldAction("Security.StrictDefaults", SectionFieldActionKind.Set, true), + new SectionFieldAction("Tools", SectionFieldActionKind.Set, BuildToolsDictionary(posture, shellMode)) + ]); + } + + private static DeploymentPosture? ReadExistingPosture(WizardContext context) + { + if (context.ExistingConfig is null + || !ConfigFileHelper.TryGetPathValue(context.ExistingConfig, "Security.DeploymentPosture", out var value)) + { + return null; + } + + return value is string text && Enum.TryParse(text, ignoreCase: true, out var parsed) + ? parsed + : null; + } + + private static Dictionary BuildToolsDictionary(DeploymentPosture posture, ShellExecutionMode shellMode) + => new() + { + ["ShellMode"] = shellMode.ToString(), + ["AudienceProfiles"] = BuildAudienceProfiles(posture) + }; + + private static ShellExecutionMode ShellModeFor(DeploymentPosture posture) + => posture == DeploymentPosture.Personal ? ShellExecutionMode.HostAllowed : ShellExecutionMode.Off; + + // Personal posture gates shell behind an approval prompt by default; the operator can override + // this in config for unrestricted shell. Shared by the typed (ContributeConfig) and section + // (BuildContribution) emission paths so they cannot drift on this default-deny security default. + private static ToolAudienceProfiles BuildAudienceProfiles(DeploymentPosture posture) + { + var profiles = ToolAudienceProfileDefaults.CreateProfiles(); + if (posture == DeploymentPosture.Personal) + { + profiles.Personal.ApprovalPolicy = new ToolApprovalConfig + { + ToolOverrides = new Dictionary(StringComparer.Ordinal) + { + ["shell_execute"] = ToolApprovalMode.Approval + } + }; + } + + return profiles; + } + public void Dispose() { // Nothing to dispose diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SkillFeedsStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SkillFeedsStepView.cs deleted file mode 100644 index 7b1c78dbe..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SkillFeedsStepView.cs +++ /dev/null @@ -1,321 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Cli.Tui; -using R3; -using Termina.Extensions; -using Termina.Input; -using Termina.Layout; -using Termina.Reactive; -using Termina.Rendering; -using Termina.Terminal; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Termina view for the Skill Feeds wizard step. -/// Sub-step 0: Yes/No selection to connect. -/// Sub-step 1: URL text input. -/// Sub-step 2: Probe result (spinner during probe, result/error after). -/// Sub-step 3: Name input (auto-suggested from hostname). -/// Sub-step 4: Add another or continue. -/// -public sealed class SkillFeedsStepView : IWizardStepView -{ - private SelectionListNode? _connectList; - private TextInputNode? _urlInput; - private TextInputNode? _nameInput; - private SelectionListNode? _errorActionList; - private SelectionListNode? _addAnotherList; - private IFocusable? _lastFocusedList; - private TextInputBaseNode? _lastFocusedInput; - private StepViewCallbacks? _callbacks; - private SkillFeedsStepViewModel? _vm; - - public string StepId => WizardStepIds.SkillFeeds; - - public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) - { - _callbacks = callbacks; - _vm = (SkillFeedsStepViewModel)stepVm; - - return _vm.CurrentSubStep switch - { - 0 => BuildConnectPrompt(callbacks), - 1 => BuildUrlInput(callbacks), - 2 => BuildProbeResult(callbacks), - 3 => BuildNameInput(callbacks), - 4 => BuildAddAnotherPrompt(callbacks), - _ => Layouts.Empty() - }; - } - - private ILayoutNode BuildConnectPrompt(StepViewCallbacks callbacks) - { - _lastFocusedInput = null; - - var yesLabel = "Yes — add a skill server URL"; - var noLabel = "No — skip"; - - _connectList = Layouts.SelectionList(yesLabel, noLabel) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _connectList.OnFocused(); - _lastFocusedList = _connectList; - - _connectList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) - return; - - _vm!.SetWantsToConnect(selected[0] == yesLabel); - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - var infoContent = Layouts.Vertical() - .WithChild(new TextNode("Any server implementing the Cloudflare Agents Skills Discovery protocol can distribute skills to Netclaw. Use ours or bring your own:") - .WithForeground(Color.BrightBlack)) - .WithChild(new TextNode("https://github.com/netclaw-dev/skill-server") - .WithForeground(Color.Cyan)); - - return Layouts.Vertical() - .WithChild(new TextNode(" Connect to a private skill server?").WithForeground(Color.White)) - .WithSpacing(1) - .WithChild(new PanelNode() - .WithTitle("ℹ What's a skill server?") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(infoContent)) - .WithSpacing(1) - .WithChild(_connectList); - } - - private ILayoutNode BuildUrlInput(StepViewCallbacks callbacks) - { - _lastFocusedList = null; - - _urlInput = new TextInputNode() - .WithPlaceholder("https://skills.example.com"); - - if (!string.IsNullOrWhiteSpace(_vm!.CurrentUrl)) - _urlInput.Text = _vm.CurrentUrl; - - _urlInput.OnFocused(); - _lastFocusedInput = _urlInput; - - _urlInput.Submitted - .Subscribe(text => - { - if (string.IsNullOrWhiteSpace(text)) - return; - - _vm.SetUrl(text); - _vm.BeginProbe(); - callbacks.AdvanceStep(); - - _ = Task.Run(async () => - { - await _vm.ProbeAsync(CancellationToken.None); - callbacks.InvalidateAndRedraw(); - - if (_vm.ProbeSucceeded) - callbacks.AdvanceStep(); - }); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode(" Enter the skill server URL:").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_urlInput, "URL")); - } - - private ILayoutNode BuildProbeResult(StepViewCallbacks callbacks) - { - _lastFocusedList = null; - _lastFocusedInput = null; - - if (_vm!.IsProbing) - { - return Layouts.Vertical() - .WithChild(SpinnerViews.Labeled($"Discovering skills at {_vm.CurrentUrl} ...", Color.Cyan)); - } - - if (_vm.LastProbeError is not null) - { - var retryLabel = "Try again"; - var editLabel = "Edit URL"; - var skipLabel = "Skip this step"; - - _errorActionList = Layouts.SelectionList(retryLabel, editLabel, skipLabel) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _errorActionList.OnFocused(); - _lastFocusedList = _errorActionList; - - _errorActionList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) return; - var choice = selected[0]; - - if (choice == retryLabel) - { - _vm.BeginProbe(); - callbacks.InvalidateAndRedraw(); - _ = Task.Run(async () => - { - await _vm.ProbeAsync(CancellationToken.None); - callbacks.InvalidateAndRedraw(); - if (_vm.ProbeSucceeded) - callbacks.AdvanceStep(); - }); - } - else if (choice == editLabel) - { - _vm.TryGoBack(); - callbacks.InvalidateAndRedraw(); - } - else - { - _vm.SetWantsToConnect(false); - callbacks.AdvanceStep(); - } - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode($" ✗ Could not reach {_vm.CurrentUrl}").WithForeground(Color.Red)) - .WithChild(new TextNode($" {_vm.LastProbeError}").WithForeground(Color.BrightBlack)) - .WithSpacing(1) - .WithChild(_errorActionList); - } - - // Success — probe callback handles AdvanceStep(); this is a transient render state - return Layouts.Vertical() - .WithChild(new TextNode($" ✓ Connected to {_vm.CurrentUrl}").WithForeground(Color.Green)) - .WithChild(new TextNode($" Found {_vm.LastProbeSkillCount} skills").WithForeground(Color.White)); - } - - private ILayoutNode BuildNameInput(StepViewCallbacks callbacks) - { - _lastFocusedList = null; - - _nameInput = new TextInputNode() - .WithPlaceholder("feed-name"); - - _nameInput.Text = _vm!.CurrentName; - - _nameInput.OnFocused(); - _lastFocusedInput = _nameInput; - - _nameInput.Submitted - .Subscribe(text => - { - if (!string.IsNullOrWhiteSpace(text)) - _vm.SetName(text); - - _vm.SaveCurrentFeed(); - callbacks.AdvanceStep(); - }) - .DisposeWith(callbacks.Subscriptions); - - return Layouts.Vertical() - .WithChild(new TextNode($" ✓ Connected to {_vm.CurrentUrl}").WithForeground(Color.Green)) - .WithChild(new TextNode($" Found {_vm.LastProbeSkillCount} skills").WithForeground(Color.White)) - .WithSpacing(1) - .WithChild(new TextNode(" Feed name (used in config):").WithForeground(Color.White)) - .WithChild(WizardStepHelpers.BuildTextInputPanel(_nameInput, "Name")); - } - - private ILayoutNode BuildAddAnotherPrompt(StepViewCallbacks callbacks) - { - _lastFocusedInput = null; - - var continueLabel = "Continue to next step"; - var addLabel = "Add another skill server"; - - _addAnotherList = Layouts.SelectionList(continueLabel, addLabel) - .WithMode(SelectionMode.Single) - .WithHighlightColors(Color.Black, Color.Cyan); - - _addAnotherList.OnFocused(); - _lastFocusedList = _addAnotherList; - - _addAnotherList.SelectionConfirmed - .Subscribe(selected => - { - if (selected.Count == 0) return; - - if (selected[0] == addLabel) - { - _vm!.StartAddAnother(); - callbacks.InvalidateAndRedraw(); - } - else - { - callbacks.AdvanceStep(); - } - }) - .DisposeWith(callbacks.Subscriptions); - - var layout = Layouts.Vertical() - .WithChild(new TextNode(" Configured feeds:").WithForeground(Color.White)); - - foreach (var feed in _vm!.ConfiguredFeeds) - { - layout = layout.WithChild( - new TextNode($" ✓ {feed.Name} ({feed.SkillCount} skills)") - .WithForeground(Color.Green)); - } - - layout = layout - .WithSpacing(1) - .WithChild(_addAnotherList); - - return layout; - } - - public bool HandleKeyPress(KeyPressed key) - { - if (_vm is null) - return false; - - var keyInfo = key.KeyInfo; - - if (_lastFocusedInput is not null) - { - _lastFocusedInput.HandleInput(keyInfo); - return true; - } - - if (_lastFocusedList is not null) - { - _lastFocusedList.HandleInput(keyInfo); - return true; - } - - return false; - } - - public void HandlePaste(PasteEvent paste) - { - _lastFocusedInput?.HandlePaste(paste); - } - - public void ClearFocusState() - { - _lastFocusedList = null; - _lastFocusedInput = null; - _connectList = null; - _urlInput = null; - _nameInput = null; - _errorActionList = null; - _addAnotherList = null; - } -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SkillFeedsStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SkillFeedsStepViewModel.cs deleted file mode 100644 index 0250f01df..000000000 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SkillFeedsStepViewModel.cs +++ /dev/null @@ -1,249 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2026 - 2026 Petabridge, LLC -// -// ----------------------------------------------------------------------- -using Netclaw.Configuration; -using Netclaw.SkillClient; - -namespace Netclaw.Cli.Tui.Wizard.Steps; - -/// -/// Wizard step for configuring private skill server feeds. -/// Sub-step 0: Yes/No to connect. -/// Sub-step 1: URL text input. -/// Sub-step 2: Probe (async). -/// Sub-step 3: Name input (auto-suggested from hostname). -/// Sub-step 4: Add another or continue. -/// -public sealed class SkillFeedsStepViewModel : IWizardStepViewModel, IDisposable -{ - private int _currentSubStep; - private int _highWaterSubStep; - private bool _wantsToConnect; - - private string _currentUrl = ""; - private string _currentName = ""; - private int _lastProbeSkillCount; - private string? _lastProbeError; - private bool _probing; - - private readonly List _feeds = []; - - public string StepId => WizardStepIds.SkillFeeds; - public string DisplayTitle => "Skill Feeds"; - - public int CurrentSubStep => _currentSubStep; - public bool WantsToConnect => _wantsToConnect; - public string CurrentUrl => _currentUrl; - public string CurrentName => _currentName; - public int LastProbeSkillCount => _lastProbeSkillCount; - public string? LastProbeError => _lastProbeError; - public bool IsProbing => _probing; - public IReadOnlyList ConfiguredFeeds => _feeds; - - public int SubStepCount => _wantsToConnect ? 5 : 1; - - public bool IsApplicable(WizardContext context) => true; - - public void SetWantsToConnect(bool value) - { - _wantsToConnect = value; - } - - public void SetUrl(string url) - { - _currentUrl = url.Trim(); - _currentName = SuggestNameFromUrl(_currentUrl); - } - - public void SetName(string name) - { - _currentName = SanitizeFeedName(name.Trim()); - } - - /// - /// Marks the probe as in-progress synchronously so the render path - /// sees == true before the background task starts. - /// - public void BeginProbe() - { - _probing = true; - _lastProbeError = null; - _lastProbeSkillCount = 0; - } - - public async Task ProbeAsync(CancellationToken ct) - { - _probing = true; - _lastProbeError = null; - _lastProbeSkillCount = 0; - - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); - cts.CancelAfter(TimeSpan.FromSeconds(10)); - - using var client = new SkillServerClient(_currentUrl); - var index = await client.GetRfcIndexAsync(cts.Token); - - if (index is null) - { - _lastProbeError = "Server returned empty response"; - return; - } - - _lastProbeSkillCount = index.Skills.Count; - } - catch (OperationCanceledException) when (!ct.IsCancellationRequested) - { - _lastProbeError = "Connection timed out"; - } - catch (HttpRequestException ex) - { - _lastProbeError = ex.Message; - } - catch (Exception ex) - { - _lastProbeError = ex.Message; - } - finally - { - _probing = false; - } - } - - public bool ProbeSucceeded => _lastProbeError is null && !_probing; - - public void SaveCurrentFeed() - { - if (string.IsNullOrWhiteSpace(_currentName) || string.IsNullOrWhiteSpace(_currentUrl)) - return; - - _feeds.Add(new ConfiguredFeed(_currentName, _currentUrl, _lastProbeSkillCount)); - _currentUrl = ""; - _currentName = ""; - _lastProbeSkillCount = 0; - _lastProbeError = null; - } - - public void StartAddAnother() - { - _currentSubStep = 1; - _highWaterSubStep = 1; - } - - public string GetHelpText() => _currentSubStep switch - { - 0 => " Connect to a private skill server to automatically sync skills.", - 1 => " Enter the base URL of your skill server.", - 2 when _probing => " Discovering skills...", - 2 when _lastProbeError is not null => " Connection failed. Try again, edit URL, or skip.", - 2 => " Connected successfully.", - 3 => " Give this feed a short name for your config file.", - 4 => " Add more feeds or continue to the next step.", - _ => "" - }; - - public bool TryAdvance() - { - if (_currentSubStep == 0 && !_wantsToConnect) - return false; - - if (_currentSubStep < SubStepCount - 1) - { - _currentSubStep++; - if (_currentSubStep > _highWaterSubStep) - _highWaterSubStep = _currentSubStep; - return true; - } - - return false; - } - - public bool TryGoBack() - { - if (_currentSubStep > 0) - { - _currentSubStep--; - return true; - } - - return false; - } - - public void OnEnter(WizardContext context, NavigationDirection direction) - { - if (direction == NavigationDirection.Back) - _currentSubStep = _highWaterSubStep; - else - _currentSubStep = 0; - } - - public void OnLeave() { } - - public void ContributeConfig(WizardConfigBuilder builder) - { - if (_feeds.Count == 0) - return; - - builder.SkillFeedSources = [.. _feeds - .Select(f => new SkillFeedSource - { - Name = f.Name, - Url = f.Url, - Enabled = true - })]; - } - - public void ContributeSecrets(WizardSecretsBuilder builder) { } - - public Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) - => Task.CompletedTask; - - public void Dispose() { } - - internal static string SuggestNameFromUrl(string url) - { - try - { - var uri = new Uri(url); - var host = uri.Host; - - if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) - || host.StartsWith("127.", StringComparison.Ordinal) - || string.Equals(host, "::1", StringComparison.Ordinal)) - { - return "localhost"; - } - - return host - .Replace('.', '-') - .ToLowerInvariant(); - } - catch - { - return "custom"; - } - } - - internal static string SanitizeFeedName(string name) - { - var sanitized = new char[name.Length]; - var len = 0; - - foreach (var c in name) - { - if (char.IsLetterOrDigit(c) || c == '-') - sanitized[len++] = char.ToLowerInvariant(c); - else if (c is ' ' or '_' or '.') - sanitized[len++] = '-'; - } - - // Trim leading/trailing hyphens - var span = sanitized.AsSpan(0, len).Trim('-'); - return span.Length > 0 ? new string(span) : "custom"; - } - - public sealed record ConfiguredFeed(string Name, string Url, int SkillCount); -} diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs index 74e2cc4ba..4041a6a8f 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepView.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Config; using R3; using Termina.Extensions; using Termina.Input; @@ -19,6 +20,7 @@ namespace Netclaw.Cli.Tui.Wizard.Steps; /// public sealed class SlackStepView : IWizardStepView { + private SlackStepViewModel? _vm; private SelectionListNode? _enabledList; private TextInputNode? _botTokenInput; private TextInputNode? _appTokenInput; @@ -34,6 +36,7 @@ public sealed class SlackStepView : IWizardStepView public ILayoutNode BuildContent(IWizardStepViewModel stepVm, StepViewCallbacks callbacks) { var vm = (SlackStepViewModel)stepVm; + _vm = vm; return vm.CurrentSubStep switch { @@ -82,28 +85,52 @@ private ILayoutNode BuildBotTokenSubStep(SlackStepViewModel vm, StepViewCallback _botTokenInput = new TextInputNode() .AsPassword() .WithPlaceholder("xoxb-..."); + WizardStepHelpers.SeedTextInput(_botTokenInput, vm.BotTokenDraft ?? vm.BotToken); _botTokenInput.OnFocused(); _lastFocusedInput = _botTokenInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_botTokenInput, StageFocusedInput, callbacks); _botTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + vm.BotTokenDraft = null; + if (vm.HasPersistedBotToken || !string.IsNullOrWhiteSpace(vm.BotToken)) + { + callbacks.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackBotTokenRequired); + } + + return; + } + if (!text.StartsWith("xoxb-", StringComparison.OrdinalIgnoreCase)) { - callbacks.RequestRedraw(); + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackBotTokenPrefix); return; } vm.BotToken = text; + vm.BotTokenDraft = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Slack Bot Token:").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_botTokenInput, "Bot Token")); + + if (vm.HasPersistedBotToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildAppTokenSubStep(SlackStepViewModel vm, StepViewCallbacks callbacks) @@ -111,41 +138,64 @@ private ILayoutNode BuildAppTokenSubStep(SlackStepViewModel vm, StepViewCallback _appTokenInput = new TextInputNode() .AsPassword() .WithPlaceholder("xapp-..."); + WizardStepHelpers.SeedTextInput(_appTokenInput, vm.AppTokenDraft ?? vm.AppToken); _appTokenInput.OnFocused(); _lastFocusedInput = _appTokenInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_appTokenInput, StageFocusedInput, callbacks); _appTokenInput.Submitted - .Where(text => !string.IsNullOrWhiteSpace(text)) .Subscribe(text => { + if (string.IsNullOrWhiteSpace(text)) + { + vm.AppTokenDraft = null; + if (vm.HasPersistedAppToken || !string.IsNullOrWhiteSpace(vm.AppToken)) + { + callbacks.ClearStatusMessage(); + callbacks.AdvanceStep(); + } + else + { + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackAppTokenRequired); + } + + return; + } + if (!text.StartsWith("xapp-", StringComparison.OrdinalIgnoreCase)) { - callbacks.RequestRedraw(); + callbacks.ShowValidationError(ChannelsEditorValidationMessages.SlackAppTokenPrefix); return; } vm.AppToken = text; + vm.AppTokenDraft = text; + callbacks.ClearStatusMessage(); callbacks.AdvanceStep(); }) .DisposeWith(callbacks.Subscriptions); - return Layouts.Vertical() + var layout = Layouts.Vertical() .WithChild(new TextNode(" Slack App Token (Socket Mode):").WithForeground(Color.White)) .WithChild(WizardStepHelpers.BuildTextInputPanel(_appTokenInput, "App Token")); + + if (vm.HasPersistedAppToken) + layout = layout.WithChild(new TextNode(" (configured - leave blank to keep)").WithForeground(Color.BrightBlack)); + + return layout; } private ILayoutNode BuildChannelNamesSubStep(SlackStepViewModel vm, StepViewCallbacks callbacks) { _channelNamesInput = new TextInputNode() .WithPlaceholder("general, dev, random (leave blank to skip)"); - - if (!string.IsNullOrWhiteSpace(vm.ChannelNamesInput)) - _channelNamesInput.Text = vm.ChannelNamesInput; + WizardStepHelpers.SeedTextInput(_channelNamesInput, vm.ChannelNamesInput); _channelNamesInput.OnFocused(); _lastFocusedInput = _channelNamesInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_channelNamesInput, StageFocusedInput, callbacks); _channelNamesInput.Submitted .Subscribe(text => @@ -205,13 +255,12 @@ private ILayoutNode BuildAllowedUserIdsSubStep(SlackStepViewModel vm, StepViewCa { _allowedUserIdsInput = new TextInputNode() .WithPlaceholder("U01ABC123, U02DEF456 (Slack user IDs, comma-separated)"); - - if (!string.IsNullOrWhiteSpace(vm.AllowedUserIdsInput)) - _allowedUserIdsInput.Text = vm.AllowedUserIdsInput; + WizardStepHelpers.SeedTextInput(_allowedUserIdsInput, vm.AllowedUserIdsInput); _allowedUserIdsInput.OnFocused(); _lastFocusedInput = _allowedUserIdsInput; _lastFocusedList = null; + WizardStepHelpers.SyncInputToViewModel(_allowedUserIdsInput, StageFocusedInput, callbacks); _allowedUserIdsInput.Submitted .Where(text => !string.IsNullOrWhiteSpace(text)) @@ -237,6 +286,8 @@ public bool HandleKeyPress(KeyPressed key) if (_lastFocusedInput is not null) { _lastFocusedInput.HandleInput(key.KeyInfo); + if (key.KeyInfo.Key != ConsoleKey.Enter) + StageFocusedInput(); return true; } return false; @@ -245,6 +296,22 @@ public bool HandleKeyPress(KeyPressed key) public void HandlePaste(PasteEvent paste) { _lastFocusedInput?.HandlePaste(paste); + StageFocusedInput(); + } + + private void StageFocusedInput() + { + if (_vm is null) + return; + + if (ReferenceEquals(_lastFocusedInput, _botTokenInput)) + _vm.BotTokenDraft = _botTokenInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _appTokenInput)) + _vm.AppTokenDraft = _appTokenInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _channelNamesInput)) + _vm.ChannelNamesInput = _channelNamesInput?.Text; + else if (ReferenceEquals(_lastFocusedInput, _allowedUserIdsInput)) + _vm.AllowedUserIdsInput = _allowedUserIdsInput?.Text; } public void ClearFocusState() diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs index 57904dd32..e14755139 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/SlackStepViewModel.cs @@ -44,6 +44,10 @@ bool IChannelAdapterViewModel.AdapterEnabled ParseChannelNames(ChannelNamesInput).Count; public string? BotToken { get; set; } public string? AppToken { get; set; } + internal string? BotTokenDraft { get; set; } + internal string? AppTokenDraft { get; set; } + public bool HasPersistedBotToken { get; set; } + public bool HasPersistedAppToken { get; set; } public string? ChannelNamesInput { get; set; } public bool AllowDirectMessages { get; set; } public bool RestrictToSpecificUsers { get; set; } @@ -132,6 +136,8 @@ internal void ResetConfig() SlackEnabled = false; BotToken = null; AppToken = null; + BotTokenDraft = null; + AppTokenDraft = null; ChannelNamesInput = null; AllowDirectMessages = false; RestrictToSpecificUsers = false; @@ -158,19 +164,11 @@ public void OnLeave() if (AllowDirectMessages) { var allowedUsers = ParseUserIds(AllowedUserIdsInput); - var dmAudience = allowedUsers.Count == 1 - ? TrustAudience.Personal - : posture == DeploymentPosture.Personal - ? TrustAudience.Personal - : posture == DeploymentPosture.Team - ? TrustAudience.Team - : TrustAudience.Public; + var dmAudience = ChannelAudienceDefaults.ForDirectMessage(posture, allowedUsers.Count); entries.Add(new ChannelEntry("DMs", "dm", dmAudience, isDmRow: true)); } - var channelAudience = posture == DeploymentPosture.Public - ? TrustAudience.Public - : TrustAudience.Team; + var channelAudience = ChannelAudienceDefaults.ForChannel(posture); if (!string.IsNullOrWhiteSpace(ChannelNamesInput)) { @@ -227,19 +225,8 @@ public void ContributeSecrets(WizardSecretsBuilder builder) public async Task ContributeHealthChecksAsync(HealthCheckRunner runner, CancellationToken ct) { - runner.Add(new HealthCheckItem("Slack configuration", null)); - - if (!SlackEnabled) - { - runner.UpdateLast(new HealthCheckItem("Slack configuration (disabled)", true)); + if (!runner.BeginAdapterCheck("Slack", SlackEnabled, (BotToken, "bot token"))) return; - } - - if (string.IsNullOrWhiteSpace(BotToken)) - { - runner.UpdateLast(new HealthCheckItem("Slack configuration (bot token missing)", false)); - return; - } // Probe Slack auth bool slackAuthOk; @@ -320,20 +307,40 @@ public async Task ContributeHealthChecksAsync(HealthCheckRunner runner, Cancella var audiences = new Dictionary(StringComparer.Ordinal); foreach (var entry in slackEntries) - audiences[ResolveChannelAudienceKey(entry)] = entry.Audience.ToWireValue(); + { + // Only write an audience under a canonical key the Slack runtime ACL can match — a + // resolved channel ID, or the literal "dm" DM key. An unresolved channel NAME is a dead + // key the runtime never matches, so omit it instead of silently writing inert ACL config + // (a no-silent-fallback violation on a security path). The health-check phase already + // surfaces unresolved channels to the operator. + if (TryResolveChannelAudienceKey(entry, out var key)) + audiences[key] = entry.Audience.ToWireValue(); + } return audiences.Count > 0 ? audiences : null; } - private string ResolveChannelAudienceKey(ChannelEntry entry) + private bool TryResolveChannelAudienceKey(ChannelEntry entry, out string key) { - if (entry.IsDmRow || LastChannelResolution is null) - return entry.Id; + if (entry.IsDmRow) + { + key = entry.Id; // canonical DM key ("dm") + return true; + } - var resolved = LastChannelResolution.Resolved.FirstOrDefault( - channel => string.Equals(channel.Name, entry.Id, StringComparison.OrdinalIgnoreCase)); + key = string.Empty; + if (LastChannelResolution is null) + return false; + + var resolved = LastChannelResolution.Resolved.FirstOrDefault(channel => + string.Equals(channel.Name, entry.Id, StringComparison.OrdinalIgnoreCase) || + string.Equals(channel.Id, entry.Id, StringComparison.Ordinal)); + + if (string.IsNullOrWhiteSpace(resolved?.Id)) + return false; - return string.IsNullOrWhiteSpace(resolved?.Id) ? entry.Id : resolved.Id; + key = resolved.Id; + return true; } internal static IReadOnlyList ParseChannelNames(string? input) diff --git a/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs b/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs index 4b00531dc..383e8d46e 100644 --- a/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs +++ b/src/Netclaw.Cli/Tui/Wizard/Steps/WizardStepHelpers.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using R3; +using Netclaw.Cli.Tui; using Termina.Extensions; using Termina.Layout; using Termina.Reactive; @@ -46,12 +47,26 @@ internal static (SelectionListNode> List, ILayoutNode Layo } internal static ILayoutNode BuildTextInputPanel(TextInputNode input, string title) - => new PanelNode() - .WithTitle(title) - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Gray) - .WithContent(input) - .Height(3); + => NetclawTuiChrome.BuildTextInputPanel(input, title); + + internal static void SeedTextInput(TextInputNode input, string? text) + { + input.Text = text ?? string.Empty; + if (!string.IsNullOrEmpty(input.Text)) + input.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.End, shift: false, alt: false, control: false)); + } + + /// + /// Syncs a text input back to its view-model on every text change. Termina auto-routes + /// bracketed paste straight into the focused node and consumes the event, so the page's + /// PasteEvent handler never sees it; the node is also rebuilt and re-seeded from + /// the view-model on every render. Without an immediate sync an auto-routed paste lands + /// only in the node and is wiped by the next reseed. Subscribing to TextChanged + /// captures keystrokes and pastes alike the instant they happen. Wire this AFTER seeding + /// so the seed's own change does not run the staging callback against a half-built view. + /// + internal static void SyncInputToViewModel(TextInputBaseNode input, Action stage, StepViewCallbacks callbacks) + => input.TextChanged.Subscribe(_ => stage()).DisposeWith(callbacks.Subscriptions); internal static List ParseUserIds(string? input) => string.IsNullOrWhiteSpace(input) diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs index a8878e391..b62227b86 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardConfigBuilder.cs @@ -10,6 +10,7 @@ using Netclaw.Cli.Json; using Netclaw.Cli.Mcp; using Netclaw.Cli.Secrets; +using Netclaw.Cli.Tui.Sections; using Netclaw.Configuration; using Netclaw.Configuration.Secrets; @@ -22,10 +23,13 @@ namespace Netclaw.Cli.Tui.Wizard; public sealed class WizardConfigBuilder { private readonly NetclawPaths _paths; + private readonly Dictionary _existingConfig; + private readonly List _sectionContributions = []; public WizardConfigBuilder(NetclawPaths paths) { _paths = paths; + _existingConfig = ConfigFileHelper.LoadJsonDict(paths.NetclawConfigPath); } // ── Typed sections populated by steps ── @@ -59,9 +63,8 @@ public void WriteConfigFile() _paths.EnsureDirectoriesExist(); PreserveExistingUpdateChannel(); var config = BuildConfigDictionary(); - - File.WriteAllText(_paths.NetclawConfigPath, - JsonSerializer.Serialize(config, JsonDefaults.ConfigFile)); + ApplyEditorStateContributions(); + ConfigFileHelper.WriteConfigFile(_paths.NetclawConfigPath, config); } private void PreserveExistingUpdateChannel() @@ -84,19 +87,31 @@ private void PreserveExistingUpdateChannel() Daemon = prev with { UpdateChannel = existing.UpdateChannel }; } + private static bool DaemonUpdateChannelIsStable(Dictionary daemon) + => daemon.TryGetValue("UpdateChannel", out var value) + && (value switch + { + JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(), + string s => s, + _ => null + }) is { } channel + && string.Equals(channel, "stable", StringComparison.OrdinalIgnoreCase); + /// /// Assemble the non-secret config dictionary from typed sections. /// internal Dictionary BuildConfigDictionary() { - var config = new Dictionary - { - ["configVersion"] = 1 - }; + var config = _existingConfig.Count == 0 + ? new Dictionary() + : new Dictionary(_existingConfig, StringComparer.Ordinal); + + config["configVersion"] = 1; // Provider section if (Provider is not null) { + var providers = ConfigFileHelper.GetOrCreateSection(config, "Providers"); var providerEntry = new Dictionary { ["Type"] = Provider.TypeKey @@ -104,29 +119,26 @@ internal Dictionary BuildConfigDictionary() if (Provider.AuthMethod != AuthMethod.None) providerEntry["AuthMethod"] = Provider.AuthMethod.ToString(); + else + providerEntry.Remove("AuthMethod"); if (!string.IsNullOrWhiteSpace(Provider.Endpoint)) providerEntry["Endpoint"] = Provider.Endpoint; - config["Providers"] = new Dictionary - { - [Provider.TypeKey] = providerEntry - }; + providers[Provider.TypeKey] = providerEntry; } // Models section if (Model is not null) { - config["Models"] = new Dictionary - { - ["Main"] = ModelEntryWriter.BuildModelEntry( - Model.Provider, - Model.ModelId, - Model.Provenance, - Model.ContextWindow, - Model.InputModalities, - Model.OutputModalities) - }; + var models = ConfigFileHelper.GetOrCreateSection(config, "Models"); + models["Main"] = ModelEntryWriter.BuildModelEntry( + Model.Provider, + Model.ModelId, + Model.Provenance, + Model.ContextWindow, + Model.InputModalities, + Model.OutputModalities); } // Slack section @@ -225,17 +237,22 @@ internal Dictionary BuildConfigDictionary() } // Search section - if (Search is not null && Search.Backend != SearchBackend.DuckDuckGo) + if (Search is not null) { - var searchSection = new Dictionary + if (Search.Backend == SearchBackend.DuckDuckGo) { - ["Backend"] = Search.Backend.ToWireValue() - }; - - if (Search.Backend == SearchBackend.SearXng && !string.IsNullOrWhiteSpace(Search.SearXngEndpoint)) - searchSection["SearXngEndpoint"] = Search.SearXngEndpoint; + config.Remove("Search"); + } + else + { + var searchSection = ConfigFileHelper.GetOrCreateSection(config, "Search"); + searchSection["Backend"] = Search.Backend.ToWireValue(); - config["Search"] = searchSection; + if (Search.Backend == SearchBackend.SearXng && !string.IsNullOrWhiteSpace(Search.SearXngEndpoint)) + searchSection["SearXngEndpoint"] = Search.SearXngEndpoint; + else + searchSection.Remove("SearXngEndpoint"); + } } // Security section @@ -259,6 +276,22 @@ internal Dictionary BuildConfigDictionary() }; } + if (Identity is not null) + { + config["Identity"] = new Dictionary + { + ["AgentName"] = Identity.AgentName, + ["CommunicationStyle"] = Identity.CommunicationStyle, + ["UserTimezone"] = Identity.UserTimezone + }; + + if (!string.IsNullOrWhiteSpace(Identity.UserName) + && config["Identity"] is Dictionary identity) + { + identity["UserName"] = Identity.UserName; + } + } + // Skill sync config["SkillSync"] = new Dictionary { @@ -349,6 +382,20 @@ internal Dictionary BuildConfigDictionary() if (daemonSection.Count > 0) config["Daemon"] = daemonSection; } + else if (ConfigFileHelper.GetSectionOrNull(config, "Daemon") is { } existingDaemon + && DaemonUpdateChannelIsStable(existingDaemon)) + { + // BuildConfigDictionary seeds from the existing config to preserve unrelated + // sections, but a default `stable` UpdateChannel must not be persisted + // (PreserveExistingUpdateChannel deliberately leaves the typed Daemon null for + // it). Strip it, and drop the Daemon section only if nothing else remains — + // a Daemon carrying real fields (exposure, host, proxies) stays preserved. + existingDaemon.Remove("UpdateChannel"); + if (existingDaemon.Count == 0) + config.Remove("Daemon"); + else + config["Daemon"] = existingDaemon; + } // Webhooks section — only written when enabled (disabled = default, omit) if (Webhooks is { Enabled: true }) @@ -386,6 +433,7 @@ internal Dictionary BuildConfigDictionary() MergeEnabledFlag(config, "Webhooks", FeatureSelections.WebhooksEnabled); } + ApplySectionContributions(config); return config; } @@ -407,6 +455,23 @@ private static void MergeEnabledFlag(Dictionary config, string s }; } } + + internal void ApplyContribution(SectionContribution contribution) + { + _sectionContributions.Add(contribution); + } + + private static void ApplyContribution(Dictionary config, SectionContribution contribution) + => ConfigEditorSession.ApplyFieldActions(config, contribution); + + private void ApplySectionContributions(Dictionary config) + { + foreach (var contribution in _sectionContributions) + ApplyContribution(config, contribution); + } + + private void ApplyEditorStateContributions() + => ConfigEditorSession.ApplyEditorStateActions(_paths, _sectionContributions); } /// @@ -416,10 +481,15 @@ public sealed class WizardSecretsBuilder { private readonly NetclawPaths _paths; private readonly Dictionary _secrets = []; + private readonly Dictionary _existingSecrets; + private readonly List _sectionContributions = []; + private readonly bool _secretsFileExists; public WizardSecretsBuilder(NetclawPaths paths) { _paths = paths; + _secretsFileExists = File.Exists(paths.SecretsPath); + _existingSecrets = ConfigFileHelper.LoadJsonDict(paths.SecretsPath); } internal NetclawPaths Paths => _paths; @@ -436,34 +506,51 @@ public void AddSection(string key, Dictionary section) /// Write secrets.json if any secrets were contributed. public void WriteSecretsFile() { - if (_secrets.Count == 0) + var hasDirectSecrets = _secrets.Count > 0; + if (!hasDirectSecrets && _sectionContributions.Count == 0) return; - if (File.Exists(_paths.SecretsPath)) - { - var existingText = File.ReadAllText(_paths.SecretsPath); - var existingNode = JsonNode.Parse(existingText)?.AsObject(); - if (existingNode is not null) - { - foreach (var (key, value) in _secrets) - { - var segments = SecretsJsonUpdater.ParseKeyPath(key); - var node = JsonSerializer.SerializeToNode(value, JsonDefaults.ConfigFile); - if (node is JsonObject obj) - SecretsJsonUpdater.MergeObject(existingNode, segments, obj); - else - SecretsJsonUpdater.UpsertNode(existingNode, segments, node); - } + var merged = _existingSecrets.Count == 0 + ? new Dictionary() + : new Dictionary(_existingSecrets, StringComparer.Ordinal); - SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), - protector: SensitiveStringTypeConverter.Protector); - return; - } + var contributionChanged = false; + foreach (var contribution in _sectionContributions) + contributionChanged |= ConfigEditorSession.ApplySecretActions(merged, contribution); + + if (!hasDirectSecrets && !contributionChanged) + return; + + var existingNode = JsonSerializer.SerializeToNode(merged, JsonDefaults.ConfigFile)?.AsObject() + ?? []; + + foreach (var (key, value) in _secrets) + { + var segments = SecretsJsonUpdater.ParseKeyPath(key); + var node = JsonSerializer.SerializeToNode(value, JsonDefaults.ConfigFile); + if (node is JsonObject obj) + SecretsJsonUpdater.MergeObject(existingNode, segments, obj); + else + SecretsJsonUpdater.UpsertNode(existingNode, segments, node); } - SecretsFileWriter.Write(_paths.SecretsPath, _secrets, - options: JsonDefaults.ConfigFile, protector: SensitiveStringTypeConverter.Protector); + if (hasDirectSecrets || contributionChanged && (_secretsFileExists || HasUserSecretData(merged))) + { + // Encrypt with the protector for THIS config's keys directory (the same one the + // read/decrypt path derives) rather than the process-wide SensitiveStringTypeConverter + // .Protector static — that global is an ambient hook for the framework-instantiated + // converters only; reaching for it here as a service locator is what let a parallel test + // leak a foreign protector and break the encrypt/decrypt round-trip. + SecretsFileWriter.Write(_paths.SecretsPath, existingNode.ToJsonString(JsonDefaults.ConfigFile), + protector: SecretsProtection.CreateProtector(_paths)); + } } + + internal void ApplyContribution(SectionContribution contribution) + => _sectionContributions.Add(contribution); + + private static bool HasUserSecretData(Dictionary secrets) + => secrets.Keys.Any(static key => !string.Equals(key, "configVersion", StringComparison.Ordinal)); } // ── Typed config section records ── diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs b/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs index 2d56ef8a8..58ca159cf 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardContext.cs @@ -57,12 +57,14 @@ public sealed class WizardContext : IDisposable public required Action RequestRedraw { get; init; } /// - /// Null for fresh init. When populated, steps should pre-populate their - /// fields from the existing config. (Deferred — not implemented yet.) + /// Null for fresh init. When populated, steps may pre-populate their + /// non-secret fields from the existing on-disk config. /// - /// Re-edit UX intent: when existing config is detected, the wizard should - /// offer "Start fresh" vs "Modify existing". "Start fresh" does NOT wipe - /// existing files until the health check/validate stage completes successfully. + /// Secrets are intentionally excluded from this snapshot. Secret-bearing UI + /// must use presence-only checks against secrets.json and keep fields blank. + /// + /// Re-edit UX intent: this supports init-owned re-entry for shared leaves, + /// not init as the long-term settings editor. /// public Dictionary? ExistingConfig { get; init; } diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs b/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs index 8b83e731c..e455835e8 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardOrchestrator.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Cli.Tui.Wizard.Steps; +using Netclaw.Cli.Tui.Sections; using R3; namespace Netclaw.Cli.Tui.Wizard; @@ -19,11 +20,18 @@ public sealed class WizardOrchestrator : IDisposable private readonly WizardContext _context; private List _activeSteps; private int _currentIndex; + private readonly bool _singleStepMode; public WizardOrchestrator(IReadOnlyList steps, WizardContext context) + : this(steps, context, singleStepMode: false) + { + } + + public WizardOrchestrator(IReadOnlyList steps, WizardContext context, bool singleStepMode) { _allSteps = steps; _context = context; + _singleStepMode = singleStepMode; _activeSteps = BuildInitialActiveSteps(); if (_activeSteps.Count > 0) @@ -86,6 +94,9 @@ public bool GoNext() current.OnLeave(); _activeSteps = RebuildActiveSteps(); + if (_singleStepMode) + return false; + var nextIndex = currentIdx + 1; if (nextIndex >= _activeSteps.Count) return false; // already at the end @@ -125,6 +136,9 @@ public bool GoBack() if (currentIdx <= 0) return false; // at the very beginning + if (_singleStepMode) + return false; + current.OnLeave(); _activeSteps = RebuildActiveSteps(); @@ -152,8 +166,21 @@ public void WriteConfig() foreach (var step in _activeSteps) { + // Two-phase emission. ContributeConfig populates the typed section objects (the base + // section shape, also covered directly by WizardConfigBuilder tests). Then — for steps + // that are ISectionEditor — BuildContribution's field actions are applied LAST and win + // for every key they set, so BuildContribution is authoritative for those keys. The two + // must stay in agreement (e.g. via the shared helpers in SecurityPostureStepViewModel) + // so the clobbered typed write is a genuine no-op rather than a silent divergence. step.ContributeConfig(configBuilder); step.ContributeSecrets(secretsBuilder); + + if (step is ISectionEditor sectionEditor) + { + var contribution = sectionEditor.BuildContribution(step); + configBuilder.ApplyContribution(contribution); + secretsBuilder.ApplyContribution(contribution); + } } configBuilder.WriteConfigFile(); diff --git a/src/Netclaw.Cli/Tui/Wizard/WizardStepIds.cs b/src/Netclaw.Cli/Tui/Wizard/WizardStepIds.cs index 51359ee3b..ce201391f 100644 --- a/src/Netclaw.Cli/Tui/Wizard/WizardStepIds.cs +++ b/src/Netclaw.Cli/Tui/Wizard/WizardStepIds.cs @@ -11,12 +11,7 @@ internal static class WizardStepIds public const string SecurityPosture = "security-posture"; public const string FeatureSelection = "feature-selection"; public const string ChannelPicker = "channel-picker"; - public const string Channels = "channels"; - public const string Search = "search"; - public const string BrowserAutomation = "browser-automation"; public const string Identity = "identity"; - public const string ExternalSkills = "external-skills"; - public const string SkillFeeds = "skill-feeds"; public const string ExposureMode = "exposure-mode"; public const string HealthCheck = "health-check"; public const string Slack = "slack"; diff --git a/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs new file mode 100644 index 000000000..91f741278 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Workflow/ActiveSelectionList.cs @@ -0,0 +1,149 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Workflow; + +internal sealed class ActiveSelectionList +{ + private readonly IReadOnlyList _options; + private readonly Func _labelSelector; + private readonly Func _activeSelector; + private readonly Func? _statusSelector; + private readonly Action? _confirmed; + private readonly Action? _changed; + private readonly Action? _toggled; + private readonly int _labelPadWidth; + private readonly DynamicLayoutNode _layout; + + public ActiveSelectionList( + IReadOnlyList options, + Func labelSelector, + Func activeSelector, + Func? statusSelector = null, + Action? confirmed = null, + Action? changed = null, + int focusedIndex = 0, + int labelPadWidth = 0, + Action? toggled = null) + { + _options = options; + _labelSelector = labelSelector; + _activeSelector = activeSelector; + _statusSelector = statusSelector; + _confirmed = confirmed; + _changed = changed; + _toggled = toggled; + _labelPadWidth = labelPadWidth; + FocusedIndex = ClampIndex(focusedIndex); + _layout = new DynamicLayoutNode(BuildRows); + } + + public int FocusedIndex { get; private set; } + + public T FocusedOption => _options[FocusedIndex]; + + public ILayoutNode AsLayout() => _layout; + + public void FocusFirst(Func predicate) + { + var index = _options + .Select((option, idx) => (option, idx)) + .FirstOrDefault(entry => predicate(entry.option)) + .idx; + + SetFocusedIndex(index, notify: false); + } + + public void SetFocusedIndex(int index, bool notify = true) + { + var next = ClampIndex(index); + if (next == FocusedIndex) + return; + + FocusedIndex = next; + if (notify) + Invalidate(); + else + _layout.Invalidate(); + } + + public bool HandleInput(ConsoleKeyInfo keyInfo) + { + if (_options.Count == 0) + return false; + + switch (keyInfo.Key) + { + case ConsoleKey.UpArrow: + Move(-1); + return true; + case ConsoleKey.DownArrow: + Move(1); + return true; + case ConsoleKey.Enter: + _confirmed?.Invoke(FocusedOption); + return true; + case ConsoleKey.Spacebar when _toggled is not null: + _toggled(FocusedOption); + Invalidate(); + return true; + default: + return false; + } + } + + public static string BuildLegend(string activeLabel, string? statusLabel = null) + => statusLabel is null + ? $"[x] {activeLabel}" + : $"[x] {activeLabel} ✓ {statusLabel}"; + + private void Move(int delta) => SetFocusedIndex(FocusedIndex + delta); + + private int ClampIndex(int index) + => _options.Count == 0 + ? 0 + : Math.Clamp(index, 0, _options.Count - 1); + + private void Invalidate() + { + _layout.Invalidate(); + _changed?.Invoke(); + } + + private ILayoutNode BuildRows() + { + var content = Layouts.Vertical(); + var clampedFocusedIndex = ClampIndex(FocusedIndex); + for (var i = 0; i < _options.Count; i++) + { + var option = _options[i]; + var isFocused = i == clampedFocusedIndex; + var isActive = _activeSelector(option); + var prefix = isFocused ? ">" : " "; + var checkbox = isActive ? "[x]" : "[ ]"; + var label = _labelSelector(option); + if (_labelPadWidth > 0) + label = label.PadRight(_labelPadWidth); + + var line = $" {prefix} {checkbox} {label}"; + var status = _statusSelector?.Invoke(option); + if (status is not null) + line += $" {status}"; + + var node = new TextNode(line).WithForeground(isFocused ? Color.Cyan : Color.White); + if (isActive) + node.Bold(); + + content.WithChild(node.Height(1)); + } + + return content; + } +} diff --git a/src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs b/src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs new file mode 100644 index 000000000..defb0af07 --- /dev/null +++ b/src/Netclaw.Cli/Tui/Workflow/WorkflowViewComponents.cs @@ -0,0 +1,113 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui.Wizard.Steps; +using Termina.Layout; +using Termina.Rendering; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui.Workflow; + +/// +/// Narrow, reusable workflow-view building blocks for short setup-oriented flows. +/// These intentionally stay presentational and do not own navigation or validation. +/// +internal static class WorkflowViewComponents +{ + internal static ILayoutNode BuildSelectionScreen( + string heading, + ILayoutNode selector, + string? legend = null, + string? supportText = null, + Color? supportColor = null) + { + var layout = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {heading}").WithForeground(Color.White)) + .WithChild(selector); + + if (!string.IsNullOrWhiteSpace(legend)) + { + layout = layout.WithChild(new TextNode($" {legend}") + .WithForeground(Color.Gray)); + } + + if (!string.IsNullOrWhiteSpace(supportText)) + { + foreach (var line in SplitLines(supportText)) + { + layout = layout.WithChild(new TextNode($" {line}") + .WithForeground(supportColor ?? Color.Gray)); + } + } + + return layout; + } + + internal static ILayoutNode BuildEntryScreen( + string title, + string fieldLabel, + TextInputNode input, + string hint, + string? error = null) + { + var layout = Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {title}").WithForeground(Color.White)) + .WithChild(new TextNode($" {fieldLabel}").WithForeground(Color.White)) + .WithChild(WizardStepHelpers.BuildTextInputPanel(input, fieldLabel)) + .WithChild(new TextNode($" {hint}").WithForeground(Color.Gray)); + + if (!string.IsNullOrWhiteSpace(error)) + { + layout = layout.WithChild(new TextNode($" ✗ {error}").WithForeground(Color.Red)); + } + + return layout; + } + + internal static ILayoutNode BuildValidatingScreen( + string heading, + string message, + string? supportText = null) + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {heading}").WithForeground(Color.White)) + .WithChild(new TextNode($" {message}").WithForeground(Color.Yellow)) + .WithChild(string.IsNullOrWhiteSpace(supportText) + ? Layouts.Empty() + : new TextNode($" {supportText}").WithForeground(Color.Gray)); + + internal static ILayoutNode BuildSavedScreen(string successText, string nextStepText) + => Layouts.Vertical() + .WithSpacing(1) + .WithChild(new TextNode($" {successText}").WithForeground(Color.Green)) + .WithChild(new TextNode($" {nextStepText}").WithForeground(Color.Gray)); + + internal static ILayoutNode BuildNoticeScreen( + string title, + IEnumerable bodyLines, + ILayoutNode confirmation, + Color? titleColor = null) + { + var layout = Layouts.Vertical() + .WithChild(new TextNode($" {title}").WithForeground(titleColor ?? Color.Cyan)) + .WithSpacing(1); + + foreach (var line in bodyLines) + { + layout = layout.WithChild(new TextNode($" {line}").WithForeground(Color.BrightBlack)); + } + + return layout + .WithSpacing(1) + .WithChild(confirmation); + } + + private static IEnumerable SplitLines(string text) + => text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(static line => line.TrimEnd()); +} diff --git a/src/Netclaw.Configuration.Tests/AtomicFileTests.cs b/src/Netclaw.Configuration.Tests/AtomicFileTests.cs new file mode 100644 index 000000000..eda9f3bac --- /dev/null +++ b/src/Netclaw.Configuration.Tests/AtomicFileTests.cs @@ -0,0 +1,75 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Tests.Utilities; +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class AtomicFileTests : IDisposable +{ + private readonly DisposableTempDir _dir = new(); + + public void Dispose() => _dir.Dispose(); + + [Fact] + public void WriteAllText_RoundTripsAndLeavesNoTempFile() + { + var path = Path.Combine(_dir.Path, "f.json"); + + AtomicFile.WriteAllText(path, "payload"); + + Assert.Equal("payload", File.ReadAllText(path)); + // A successful write leaves only the destination — no lingering .tmp-* sibling. + Assert.Single(Directory.GetFiles(_dir.Path)); + } + + [Fact] + public void WriteAllText_OverwritesExistingDestination() + { + var path = Path.Combine(_dir.Path, "f.json"); + AtomicFile.WriteAllText(path, "A"); + + AtomicFile.WriteAllText(path, "B"); + + Assert.Equal("B", File.ReadAllText(path)); + } + + [Fact] + public void WriteAllText_FailureBeforeRename_LeavesPriorFileIntactAndCleansTemp() + { + var path = Path.Combine(_dir.Path, "f.json"); + File.WriteAllText(path, "ORIGINAL"); + + // The harden callback runs after the temp is written but before the rename; throwing there + // models any failure in that window. The destination must be untouched and the temp removed. + var ex = Assert.Throws(() => + AtomicFile.WriteAllText(path, "NEW", _ => throw new InvalidOperationException("boom"))); + + Assert.Equal("boom", ex.Message); + Assert.Equal("ORIGINAL", File.ReadAllText(path)); + Assert.Empty(Directory.GetFiles(_dir.Path, "*.tmp-*")); + } + + [Fact] + public void WriteAllText_HardensTempBeforeRename() + { + var path = Path.Combine(_dir.Path, "f.json"); + string? hardenedPath = null; + var existedWhenHardened = false; + + AtomicFile.WriteAllText(path, "x", p => + { + hardenedPath = p; + existedWhenHardened = File.Exists(p); + }); + + Assert.NotNull(hardenedPath); + Assert.True(existedWhenHardened); // the temp existed when permissions were applied + Assert.NotEqual(path, hardenedPath); // perms applied to the temp, not the destination + Assert.False(File.Exists(hardenedPath)); // the temp was renamed away afterward + } +} diff --git a/src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs b/src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs new file mode 100644 index 000000000..d11ab8353 --- /dev/null +++ b/src/Netclaw.Configuration.Tests/ConfigValueMetadataProviderTests.cs @@ -0,0 +1,43 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Xunit; + +namespace Netclaw.Configuration.Tests; + +public sealed class ConfigValueMetadataProviderTests +{ + [Fact] + public void Search_brave_api_key_metadata_marks_secret_and_secrets_store() + { + var metadata = ConfigValueMetadataProvider.Get(nameof(SearchConfig.BraveApiKey)); + + Assert.Equal("Search.BraveApiKey", metadata.Key); + Assert.Equal(ConfigPersistStore.SecretsJson, metadata.PersistTo); + Assert.True(metadata.IsSecret); + Assert.Equal(typeof(SensitiveString), metadata.ValueType); + } + + [Fact] + public void Search_backend_metadata_marks_config_store() + { + var metadata = ConfigValueMetadataProvider.Get(nameof(SearchConfig.Backend)); + + Assert.Equal("Search.Backend", metadata.Key); + Assert.Equal(ConfigPersistStore.NetclawJson, metadata.PersistTo); + Assert.False(metadata.IsSecret); + Assert.Equal(typeof(SearchBackend), metadata.ValueType); + } + + [Fact] + public void Mcp_oauth_tokens_metadata_marks_sidecar_store() + { + var metadata = ConfigValueMetadataProvider.Get(nameof(McpOAuthTokenSet.AccessToken)); + + Assert.Equal("AccessToken", metadata.Key); + Assert.Equal(ConfigPersistStore.McpOAuthTokens, metadata.PersistTo); + Assert.True(metadata.IsSecret); + } +} diff --git a/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs b/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs index 7f826852a..1cd9e3c18 100644 --- a/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs +++ b/src/Netclaw.Configuration.Tests/ToolAudienceProfileDefaultsTests.cs @@ -14,56 +14,85 @@ namespace Netclaw.Configuration.Tests; /// public sealed class ToolAudienceProfileDefaultsTests { - [Fact] - public void Public_default_grants_read_list_and_attach_only() + public static TheoryData DefaultAllowlists => new() { - var publicProfile = ToolAudienceProfileDefaults.CreatePublic(); - - Assert.Equal( - ["file_read", "file_list", "attach_file"], - publicProfile.AllowedTools); - Assert.DoesNotContain("file_write", publicProfile.AllowedTools); - Assert.DoesNotContain("file_edit", publicProfile.AllowedTools); - } + { + TrustAudience.Public, + [ + ToolAudienceProfileToolCatalog.FileRead, + ToolAudienceProfileToolCatalog.FileList, + ToolAudienceProfileToolCatalog.AttachFile + ] + }, + { + TrustAudience.Team, + [ + ToolAudienceProfileToolCatalog.FileRead, + ToolAudienceProfileToolCatalog.FileList, + ToolAudienceProfileToolCatalog.FileWrite, + ToolAudienceProfileToolCatalog.FileEdit, + ToolAudienceProfileToolCatalog.AttachFile, + ToolAudienceProfileToolCatalog.WebSearch, + ToolAudienceProfileToolCatalog.WebFetch, + ToolAudienceProfileToolCatalog.SkillManage, + ToolAudienceProfileToolCatalog.SetReminder, + ToolAudienceProfileToolCatalog.ListReminders, + ToolAudienceProfileToolCatalog.CancelReminder, + ToolAudienceProfileToolCatalog.GetReminderHistory, + ToolAudienceProfileToolCatalog.SetWorkingDirectory + ] + } + }; - [Fact] - public void Team_default_grants_file_web_scheduling_and_skill_tools() + public static TheoryData DefaultExcludedTools => new() { - var team = ToolAudienceProfileDefaults.CreateTeam().AllowedTools; + { + TrustAudience.Public, + [ + ToolAudienceProfileToolCatalog.FileWrite, + ToolAudienceProfileToolCatalog.FileEdit, + ToolAudienceProfileToolCatalog.WebSearch, + ToolAudienceProfileToolCatalog.WebFetch + ] + }, + { + TrustAudience.Team, + [ + ToolAudienceProfileToolCatalog.ShellExecute, + ToolAudienceProfileToolCatalog.SetWebhook, + ToolAudienceProfileToolCatalog.ListWebhooks, + ToolAudienceProfileToolCatalog.DeleteWebhook + ] + } + }; - Assert.Contains("file_read", team); - Assert.Contains("file_list", team); - Assert.Contains("file_write", team); - Assert.Contains("file_edit", team); - Assert.Contains("attach_file", team); - Assert.Contains("web_search", team); - Assert.Contains("web_fetch", team); - Assert.Contains("skill_manage", team); - Assert.Contains("set_reminder", team); - Assert.Contains("list_reminders", team); - Assert.Contains("cancel_reminder", team); - Assert.Contains("get_reminder_history", team); - Assert.Contains("set_working_directory", team); + [Theory] + [MemberData(nameof(DefaultAllowlists))] + public void Default_allowlists_match_expected_catalog_tools(TrustAudience audience, string[] expectedTools) + { + Assert.Equal(expectedTools, GetDefaultCatalogTools(audience)); + Assert.Equal(expectedTools, GetDefaultProfile(audience).AllowedTools); } [Fact] - public void Public_default_excludes_outbound_web_tools() + public void Profile_managed_catalog_covers_default_team_shell_and_webhook_tools() { - var publicProfile = ToolAudienceProfileDefaults.CreatePublic(); + var profileManaged = ToolAudienceProfileToolCatalog.ProfileManagedTools; - Assert.DoesNotContain("web_search", publicProfile.AllowedTools); - Assert.DoesNotContain("web_fetch", publicProfile.AllowedTools); + Assert.All(ToolAudienceProfileToolCatalog.TeamDefaultAllowedTools, + tool => Assert.Contains(tool, profileManaged)); + Assert.Contains(ToolAudienceProfileToolCatalog.ShellExecute, profileManaged); + Assert.All(ToolAudienceProfileToolCatalog.WebhookTools, + tool => Assert.Contains(tool, profileManaged)); } - [Fact] - public void Team_default_excludes_shell_and_webhook_tools() + [Theory] + [MemberData(nameof(DefaultExcludedTools))] + public void Default_allowlists_exclude_restricted_tools(TrustAudience audience, string[] excludedTools) { - var team = ToolAudienceProfileDefaults.CreateTeam().AllowedTools; + var allowedTools = GetDefaultProfile(audience).AllowedTools; - Assert.DoesNotContain("shell_execute", team); - Assert.DoesNotContain("set_webhook", team); - Assert.DoesNotContain("list_webhooks", team); - Assert.DoesNotContain("delete_webhook", team); + Assert.All(excludedTools, tool => Assert.DoesNotContain(tool, allowedTools)); } [Fact] @@ -87,4 +116,21 @@ public void Default_grants_are_monotonic_across_audiences() // Team ⊆ Personal — Personal grants every tool via ToolsMode.All. Assert.Equal(ToolProfileMode.All, ToolAudienceProfileDefaults.CreatePersonal().ToolsMode); } + + private static ToolAudienceProfile GetDefaultProfile(TrustAudience audience) + => audience switch + { + TrustAudience.Public => ToolAudienceProfileDefaults.CreatePublic(), + TrustAudience.Team => ToolAudienceProfileDefaults.CreateTeam(), + TrustAudience.Personal => ToolAudienceProfileDefaults.CreatePersonal(), + _ => throw new ArgumentOutOfRangeException(nameof(audience), audience, null) + }; + + private static IReadOnlyList GetDefaultCatalogTools(TrustAudience audience) + => audience switch + { + TrustAudience.Public => ToolAudienceProfileToolCatalog.PublicDefaultAllowedTools, + TrustAudience.Team => ToolAudienceProfileToolCatalog.TeamDefaultAllowedTools, + _ => throw new ArgumentOutOfRangeException(nameof(audience), audience, null) + }; } diff --git a/src/Netclaw.Configuration/AtomicFile.cs b/src/Netclaw.Configuration/AtomicFile.cs new file mode 100644 index 000000000..75fbd00a5 --- /dev/null +++ b/src/Netclaw.Configuration/AtomicFile.cs @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Runtime.InteropServices; + +namespace Netclaw.Configuration; + +/// +/// Atomic file writes for config, secrets, and the paired-device registry. Content is written +/// to a sibling temporary file, flushed to disk, optionally permission-hardened, and then renamed +/// over the destination. The rename is atomic on POSIX (rename(2)) and Windows +/// (MoveFileEx replace), so a crash, an interrupted write, or a concurrent reader can never +/// observe a partially-written or truncated file — the destination is always either the old content +/// or the complete new content. Last-writer-wins between two concurrent writers is acceptable +/// (callers that must not race serialize their writes separately); this type only guarantees that +/// no write ever corrupts the file. +/// +public static class AtomicFile +{ + /// + /// Write to atomically. Parent directories + /// are created as needed. When is supplied it runs on + /// the temporary file BEFORE the rename, so the destination is never momentarily exposed with + /// looser permissions than the final file. + /// + public static void WriteAllText(string path, string contents, Action? hardenTempPermissions = null) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + // Temp lives in the same directory as the destination so File.Move is a same-filesystem + // atomic rename rather than a copy+delete (which would not be atomic across volumes). + var temp = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + using (var writer = new StreamWriter(stream)) + { + writer.Write(contents); + writer.Flush(); + stream.Flush(flushToDisk: true); // fsync the new content before it replaces the old file + } + + hardenTempPermissions?.Invoke(temp); + File.Move(temp, path, overwrite: true); + } + catch + { + // Failure before the rename leaves the destination untouched; clean up the temp so a + // partial write never lingers next to the real file. Cleanup is best-effort — a delete + // failure must not mask the original write exception, which we rethrow. + TryDeleteTemp(temp); + throw; + } + } + + // Deletes a leftover temp file, returning whether it succeeded. The expected IO/access failures + // are turned into a false result rather than propagating, so a failed cleanup never masks a more + // important exception that is already in flight at the call site. + private static bool TryDeleteTemp(string temp) + { + if (!File.Exists(temp)) + return true; + + try + { + File.Delete(temp); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Restrict a file to owner-only read/write (chmod 600) on Linux/macOS; a no-op on Windows, + /// which relies on user-profile ACLs. Pass as the harden callback to + /// when writing secrets.json or devices.json so those files are never group/world-readable. + /// + public static void HardenOwnerOnly(string path) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && File.Exists(path)) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } +} diff --git a/src/Netclaw.Configuration/ConfigValueAttribute.cs b/src/Netclaw.Configuration/ConfigValueAttribute.cs new file mode 100644 index 000000000..5468e0d7a --- /dev/null +++ b/src/Netclaw.Configuration/ConfigValueAttribute.cs @@ -0,0 +1,69 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Reflection; + +namespace Netclaw.Configuration; + +/// +/// Declares the logical configuration key and persisted home for a runtime config property. +/// These annotations are passive metadata for editoring and persistence helpers only; they do +/// not replace Netclaw's existing runtime IConfiguration overlay behavior. +/// +public enum ConfigPersistStore +{ + NetclawJson, + SecretsJson, + McpOAuthTokens, +} + +/// +/// Passive metadata describing where a runtime config value is persisted. +/// +[AttributeUsage(AttributeTargets.Property, Inherited = false)] +public sealed class ConfigValueAttribute : Attribute +{ + public required string Key { get; init; } + + public ConfigPersistStore PersistTo { get; init; } = ConfigPersistStore.NetclawJson; +} + +/// +/// Reflected metadata for a runtime config property annotated with . +/// +public sealed record ConfigValueMetadata( + string PropertyName, + string Key, + ConfigPersistStore PersistTo, + Type ValueType, + bool IsSecret); + +/// +/// Reflection helper for passive config metadata. +/// +public static class ConfigValueMetadataProvider +{ + public static ConfigValueMetadata Get(string propertyName) + => Get(typeof(TConfig), propertyName); + + public static ConfigValueMetadata Get(Type configType, string propertyName) + { + var property = configType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException( + $"Property '{propertyName}' was not found on config type '{configType.FullName}'."); + + var attribute = property.GetCustomAttribute() + ?? throw new InvalidOperationException( + $"Property '{configType.FullName}.{propertyName}' is missing [{nameof(ConfigValueAttribute)}]."); + + var valueType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + return new ConfigValueMetadata( + PropertyName: property.Name, + Key: attribute.Key, + PersistTo: attribute.PersistTo, + ValueType: valueType, + IsSecret: valueType == typeof(SensitiveString)); + } +} diff --git a/src/Netclaw.Configuration/McpOAuthTokenSet.cs b/src/Netclaw.Configuration/McpOAuthTokenSet.cs index dbd7cd352..f65499e14 100644 --- a/src/Netclaw.Configuration/McpOAuthTokenSet.cs +++ b/src/Netclaw.Configuration/McpOAuthTokenSet.cs @@ -12,12 +12,15 @@ namespace Netclaw.Configuration; public sealed class McpOAuthTokenSet { /// The current access token. + [ConfigValue(Key = "AccessToken", PersistTo = ConfigPersistStore.McpOAuthTokens)] public SensitiveString AccessToken { get; set; } = null!; /// Refresh token for obtaining new access tokens (optional). + [ConfigValue(Key = "RefreshToken", PersistTo = ConfigPersistStore.McpOAuthTokens)] public SensitiveString? RefreshToken { get; set; } /// When the access token expires (null = unknown/never). + [ConfigValue(Key = "ExpiresAt", PersistTo = ConfigPersistStore.McpOAuthTokens)] public DateTimeOffset? ExpiresAt { get; set; } /// Resolved client ID (from DCR or static config). diff --git a/src/Netclaw.Configuration/SearchConfig.cs b/src/Netclaw.Configuration/SearchConfig.cs index ae25e3b5e..6626585cd 100644 --- a/src/Netclaw.Configuration/SearchConfig.cs +++ b/src/Netclaw.Configuration/SearchConfig.cs @@ -20,17 +20,20 @@ public sealed class SearchConfig /// /// Search backend identifier. /// + [ConfigValue(Key = "Search.Backend", PersistTo = ConfigPersistStore.NetclawJson)] public SearchBackend Backend { get; set; } = SearchBackend.DuckDuckGo; /// /// Brave Search API subscription token. Required when Backend is "brave". /// Stored in secrets.json under Search.BraveApiKey. /// + [ConfigValue(Key = "Search.BraveApiKey", PersistTo = ConfigPersistStore.SecretsJson)] public SensitiveString? BraveApiKey { get; set; } /// /// SearXNG instance base URL (e.g., "http://searxng.local:8080"). /// Required when Backend is "searxng". /// + [ConfigValue(Key = "Search.SearXngEndpoint", PersistTo = ConfigPersistStore.NetclawJson)] public string? SearXngEndpoint { get; set; } } diff --git a/src/Netclaw.Configuration/SecretsFileWriter.cs b/src/Netclaw.Configuration/SecretsFileWriter.cs index f7c6ea67a..699e4d2e7 100644 --- a/src/Netclaw.Configuration/SecretsFileWriter.cs +++ b/src/Netclaw.Configuration/SecretsFileWriter.cs @@ -3,7 +3,6 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- -using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Nodes; using Netclaw.Configuration.Secrets; @@ -33,12 +32,9 @@ public static void Write(string secretsPath, string json, ISecretsProtector? pro if (protector is not null) json = EncryptJsonLeaves(json, protector); - var dir = Path.GetDirectoryName(secretsPath); - if (dir is not null) - Directory.CreateDirectory(dir); - - File.WriteAllText(secretsPath, json); - SetOwnerOnlyPermissions(secretsPath); + // Atomic rename, with owner-only perms applied to the temp BEFORE it becomes the + // destination so secrets.json is never momentarily world-readable. + AtomicFile.WriteAllText(secretsPath, json, hardenTempPermissions: SetOwnerOnlyPermissions); } /// @@ -210,11 +206,5 @@ private static void CountNode(JsonNode node, ref int encrypted, ref int plaintex /// /// Set owner-only permissions (chmod 600) on Unix. No-op on Windows. /// - internal static void SetOwnerOnlyPermissions(string path) - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && File.Exists(path)) - { - File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); - } - } + internal static void SetOwnerOnlyPermissions(string path) => AtomicFile.HardenOwnerOnly(path); } diff --git a/src/Netclaw.Configuration/ToolAudienceProfiles.cs b/src/Netclaw.Configuration/ToolAudienceProfiles.cs index d579af27d..9c6123c6c 100644 --- a/src/Netclaw.Configuration/ToolAudienceProfiles.cs +++ b/src/Netclaw.Configuration/ToolAudienceProfiles.cs @@ -127,6 +127,56 @@ private static void ValidateProfile(string name, ToolAudienceProfile profile, Li ]; } +public static class ToolAudienceProfileToolCatalog +{ + public const string ShellExecute = "shell_execute"; + public const string FileRead = "file_read"; + public const string FileList = "file_list"; + public const string AttachFile = "attach_file"; + public const string FileWrite = "file_write"; + public const string FileEdit = "file_edit"; + public const string WebSearch = "web_search"; + public const string WebFetch = "web_fetch"; + public const string SkillManage = "skill_manage"; + public const string SetWebhook = "set_webhook"; + public const string ListWebhooks = "list_webhooks"; + public const string DeleteWebhook = "delete_webhook"; + public const string SetReminder = "set_reminder"; + public const string ListReminders = "list_reminders"; + public const string CancelReminder = "cancel_reminder"; + public const string GetReminderHistory = "get_reminder_history"; + public const string SetWorkingDirectory = "set_working_directory"; + + public static IReadOnlyList FileTools { get; } = [FileRead, FileList, FileWrite, FileEdit, AttachFile]; + public static IReadOnlyList WebTools { get; } = [WebSearch, WebFetch]; + public static IReadOnlyList SkillTools { get; } = [SkillManage]; + public static IReadOnlyList WebhookTools { get; } = [SetWebhook, ListWebhooks, DeleteWebhook]; + public static IReadOnlyList SchedulingTools { get; } = [SetReminder, ListReminders, CancelReminder, GetReminderHistory]; + public static IReadOnlyList WorkingDirectoryTools { get; } = [SetWorkingDirectory]; + + public static IReadOnlyList PublicDefaultAllowedTools { get; } = [FileRead, FileList, AttachFile]; + + public static IReadOnlyList TeamDefaultAllowedTools { get; } = + [ + .. FileTools, + .. WebTools, + .. SkillTools, + .. SchedulingTools, + .. WorkingDirectoryTools + ]; + + public static IReadOnlyList ProfileManagedTools { get; } = + [ + .. TeamDefaultAllowedTools, + .. WebhookTools, + ShellExecute + ]; + + private static readonly HashSet ProfileManagedToolSet = new(ProfileManagedTools, StringComparer.Ordinal); + + public static bool IsProfileManaged(string toolName) => ProfileManagedToolSet.Contains(toolName); +} + public static class ToolAudienceProfileDefaults { public const string SessionDirectoryToken = "{session_dir}"; @@ -150,7 +200,7 @@ public static class ToolAudienceProfileDefaults // session-directory scope rather than an unusable profile. public static ToolAudienceProfile CreatePublic() => new() { - AllowedTools = ["file_read", "file_list", "attach_file"], + AllowedTools = [.. ToolAudienceProfileToolCatalog.PublicDefaultAllowedTools], ReadFiles = CreateSessionScopedFilesystemAccess(), WriteFiles = CreateSessionScopedFilesystemAccess(), AttachFiles = CreateSessionScopedFilesystemAccess(), @@ -163,13 +213,7 @@ public static class ToolAudienceProfileDefaults // Monotonic invariant: Public ⊆ Team ⊆ Personal. public static ToolAudienceProfile CreateTeam() => new() { - AllowedTools = - [ - "file_read", "file_list", "file_write", "file_edit", "attach_file", - "web_search", "web_fetch", "skill_manage", "set_reminder", - "list_reminders", "cancel_reminder", "get_reminder_history", - "set_working_directory" - ], + AllowedTools = [.. ToolAudienceProfileToolCatalog.TeamDefaultAllowedTools], ReadFiles = CreateSessionScopedFilesystemAccess(), WriteFiles = CreateSessionScopedFilesystemAccess(), AttachFiles = CreateSessionScopedFilesystemAccess(), diff --git a/src/Netclaw.Configuration/TrustContextPolicy.cs b/src/Netclaw.Configuration/TrustContextPolicy.cs index 5783408de..5d68ad6a2 100644 --- a/src/Netclaw.Configuration/TrustContextPolicy.cs +++ b/src/Netclaw.Configuration/TrustContextPolicy.cs @@ -41,6 +41,34 @@ public enum DeploymentPosture Personal } +/// +/// Canonical default for a newly added channel or DM row, derived from +/// the deployment posture. Used by both the init wizard's channel steps and the netclaw config +/// channels editor so a channel added in either surface lands at the same trust tier. An unmapped +/// posture falls back to — the most restrictive audience — to +/// preserve the default-deny posture if ever gains a value. +/// +public static class ChannelAudienceDefaults +{ + /// Default audience for a regular channel: a Public posture publishes, otherwise Team. + public static TrustAudience ForChannel(DeploymentPosture posture) + => posture == DeploymentPosture.Public ? TrustAudience.Public : TrustAudience.Team; + + /// + /// Default audience for the DM row: a single allow-listed user is always Personal; otherwise the + /// audience tracks the posture (Public→Public, Team→Team, Personal/unmapped→Personal). + /// + public static TrustAudience ForDirectMessage(DeploymentPosture posture, int allowedUserCount) + => allowedUserCount == 1 + ? TrustAudience.Personal + : posture switch + { + DeploymentPosture.Public => TrustAudience.Public, + DeploymentPosture.Team => TrustAudience.Team, + _ => TrustAudience.Personal, + }; +} + /// /// Classification of the principal currently contacting the bot. /// diff --git a/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs b/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs index db36ccd95..32cd37778 100644 --- a/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs +++ b/src/Netclaw.Providers/OAuth/OAuthTokenPersistence.cs @@ -100,7 +100,10 @@ private static void PersistTokenExpiry(NetclawPaths paths, string providerName, else configProvider.Remove("OAuthTokenExpiry"); - File.WriteAllText(paths.NetclawConfigPath, + // Atomic write (temp + rename) so a crash/power-loss between truncate and write cannot leave + // netclaw.json empty or partial — IConfiguration silently drops every section on a torn read. + // Matches the AtomicFile seam ConfigFileHelper.WriteConfigFile uses for the same file. + AtomicFile.WriteAllText(paths.NetclawConfigPath, configRoot.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } @@ -130,8 +133,10 @@ private static void PersistTokenExpiry(NetclawPaths paths, string providerName, if (string.IsNullOrWhiteSpace(accessTokenStr)) return null; - // Transparent decrypt via SensitiveStringTypeConverter.Protector - var protector = SensitiveStringTypeConverter.Protector; + // Decrypt with the protector for this config's keys directory rather than the process-wide + // SensitiveStringTypeConverter.Protector static (an ambient hook reserved for the + // framework-instantiated converters, not a general service locator). + ISecretsProtector? protector = SecretsProtection.CreateProtector(paths); if (protector is not null && ISecretsProtector.IsEncrypted(accessTokenStr)) accessTokenStr = protector.Unprotect(accessTokenStr); diff --git a/tests/smoke/assertions/config-audience.sh b/tests/smoke/assertions/config-audience.sh new file mode 100755 index 000000000..efe530845 --- /dev/null +++ b/tests/smoke/assertions/config-audience.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# config-audience.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-audience: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Tools.AudienceProfiles.Team.ToolsMode' 'Allowlist' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("file_read") != null' 'true' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("web_search") != null' 'true' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("web_fetch") != null' 'true' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.McpServersMode' 'Allowlist' "$config_json" || : +assert_field '(.Tools.AudienceProfiles.Team.AllowedMcpServers | length)' '0' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.McpServerToolGrants' 'null' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.ApprovalPolicy' 'null' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-audience: assertions passed." diff --git a/tests/smoke/assertions/config-back-nav.sh b/tests/smoke/assertions/config-back-nav.sh new file mode 100755 index 000000000..89e2a8053 --- /dev/null +++ b/tests/smoke/assertions/config-back-nav.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# config-back-nav.tape post-tape assertion. +# +# The tape's Wait+Screen anchors are the primary regression detectors: each +# "Settings Areas" anchor after an Esc proves the embedded Provider/Model +# manager returned to the dashboard instead of quitting the config app. If the +# app had quit, vhs would fail those anchors against the shell prompt and exit +# non-zero. This script intentionally does nothing further. + +set -euo pipefail +echo "config-back-nav: no post-tape assertion (vhs exit code is the test)" diff --git a/tests/smoke/assertions/config-channels.sh b/tests/smoke/assertions/config-channels.sh new file mode 100755 index 000000000..cb00ac4b0 --- /dev/null +++ b/tests/smoke/assertions/config-channels.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# config-channels.tape post-tape assertion. +# +# Validates the Channels editor saved Slack management changes while preserving secrets. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 +SECRETS_PATH="${NETCLAW_HOME}/config/secrets.json" + +echo "config-channels: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +if [[ ! -f "$SECRETS_PATH" ]]; then + echo "FAIL: ${SECRETS_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" +secrets_json="$(cat "$SECRETS_PATH")" + +assert_field '.Slack.Enabled' 'true' "$config_json" || : +assert_field '(.Slack.AllowedChannelIds | length)' '3' "$config_json" || : +assert_field '.Slack.AllowedChannelIds[0]' 'C01' "$config_json" || : +assert_field '.Slack.AllowedChannelIds[1]' 'C02' "$config_json" || : +assert_field '.Slack.AllowedChannelIds[2]' 'C09' "$config_json" || : +assert_field '.Slack.DefaultChannelId' 'C01' "$config_json" || : +assert_field '(.Slack.AllowedUserIds | length)' '1' "$config_json" || : +assert_field '.Slack.AllowedUserIds[0]' 'U09' "$config_json" || : +assert_field '.Slack.AllowDirectMessages' 'false' "$config_json" || : +assert_field '.Slack.ChannelAudiences.C01' 'public' "$config_json" || : +assert_field '.Slack.ChannelAudiences.C02' 'team' "$config_json" || : +assert_field '.Slack.ChannelAudiences.C09' 'team' "$config_json" || : +assert_field '(.Slack.BotToken | startswith("ENC:"))' 'true' "$secrets_json" || : +assert_field '(.Slack.AppToken | startswith("ENC:"))' 'true' "$secrets_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + printf -- '--- secrets.json contents ---\n%s\n' "$secrets_json" >&2 + exit 1 +fi + +echo "config-channels: assertions passed." diff --git a/tests/smoke/assertions/config-exposure.sh b/tests/smoke/assertions/config-exposure.sh new file mode 100755 index 000000000..8af706af7 --- /dev/null +++ b/tests/smoke/assertions/config-exposure.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# config-exposure.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-exposure: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" +editor_state_path="${NETCLAW_HOME}/config/editor-state.json" + +assert_field '.Daemon.ExposureMode' 'local' "$config_json" || : +assert_field '.Daemon.Host' 'null' "$config_json" || : +assert_field '.Daemon.Port' '5299' "$config_json" || : +assert_field '.Daemon.DisableSelfUpdate' 'true' "$config_json" || : +assert_field '.Daemon.TrustedProxies' 'null' "$config_json" || : + +if [[ ! -f "$editor_state_path" ]]; then + echo "FAIL: ${editor_state_path} does not exist." >&2 + assert_fail=1 +else + editor_state_json="$(cat "$editor_state_path")" + assert_field '.Sections["exposure-mode"]["ReverseProxy.TrustedProxies"][0]' '10.0.0.0/24' "$editor_state_json" || : +fi + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-exposure: assertions passed." diff --git a/tests/smoke/assertions/config-features.sh b/tests/smoke/assertions/config-features.sh new file mode 100755 index 000000000..c344da9ef --- /dev/null +++ b/tests/smoke/assertions/config-features.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# config-features.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-features: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Memory.Enabled' 'true' "$config_json" || : +assert_field '.Search.Enabled' 'true' "$config_json" || : +assert_field '.Search.Backend' 'duckduckgo' "$config_json" || : +assert_field '.SkillSync.Enabled' 'true' "$config_json" || : +assert_field '.Scheduling.Enabled' 'false' "$config_json" || : +assert_field '.SubAgents.Enabled' 'true' "$config_json" || : +assert_field '.Webhooks.Enabled' 'false' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-features: assertions passed." diff --git a/tests/smoke/assertions/config-ops-surfaces.sh b/tests/smoke/assertions/config-ops-surfaces.sh new file mode 100755 index 000000000..374aa9b48 --- /dev/null +++ b/tests/smoke/assertions/config-ops-surfaces.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# config-ops-surfaces.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-ops-surfaces: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Telemetry.Enabled' 'true' "$config_json" || : +assert_field '.Telemetry.Otlp.Endpoint' 'http://127.0.0.1:4318' "$config_json" || : +assert_field '.Notifications.Webhooks[0].Url' 'https://hooks.slack.com/services/T000/B000/SECRET' "$config_json" || : +assert_field '.Notifications.Webhooks[0].Format' 'Slack' "$config_json" || : +assert_field '.Notifications.DeduplicationWindowSeconds' '300' "$config_json" || : +assert_field '.Notifications.MaxRetries' '2' "$config_json" || : +assert_field '.Notifications.TimeoutSeconds' '10' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-ops-surfaces: assertions passed." diff --git a/tests/smoke/assertions/config-posture.sh b/tests/smoke/assertions/config-posture.sh new file mode 100755 index 000000000..ac17ba46f --- /dev/null +++ b/tests/smoke/assertions/config-posture.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# config-posture.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-posture: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Security.DeploymentPosture' 'Team' "$config_json" || : +assert_field '.Security.ShellExecutionMode' 'Off' "$config_json" || : +assert_field '.Security.StrictDefaults' 'true' "$config_json" || : +assert_field '.Tools.ShellMode' 'Off' "$config_json" || : +assert_field '.Tools.AudienceProfiles.Team.AllowedTools | index("web_search") != null' 'true' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-posture: assertions passed." diff --git a/tests/smoke/assertions/config-search.sh b/tests/smoke/assertions/config-search.sh new file mode 100755 index 000000000..0057c2a23 --- /dev/null +++ b/tests/smoke/assertions/config-search.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# config-search.tape post-tape assertion. +# +# Validates the Search workflow persisted the expected SearXNG backend after +# dynamic validation failed and the operator chose save anyway, and that no +# Brave API key leaked into the main config file. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-search: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +assert_field '.Search.Backend' 'searxng' "$config_json" || : +assert_field '.Search.SearXngEndpoint' 'https://search.test.local' "$config_json" || : +assert_field '(.Search | has("BraveApiKey"))' 'false' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-search: assertions passed." diff --git a/tests/smoke/assertions/config-skill-picker.sh b/tests/smoke/assertions/config-skill-picker.sh new file mode 100755 index 000000000..8e66a6535 --- /dev/null +++ b/tests/smoke/assertions/config-skill-picker.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# config-skill-picker.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-skill-picker: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +# The picker derives the source Name from the folder basename and writes an absolute +# path under the per-tape HOME (a random temp dir not exported here), so check the +# basename Name exactly and the Path by suffix. +assert_field '.ExternalSkills.Sources[0].Name' 'netclaw-smoke-skill-picker' "$config_json" || : +assert_field '(.ExternalSkills.Sources[0].Path | endswith("/netclaw-smoke-skill-picker"))' 'true' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-skill-picker: assertions passed." diff --git a/tests/smoke/assertions/config-surfaces.sh b/tests/smoke/assertions/config-surfaces.sh new file mode 100755 index 000000000..9519463fd --- /dev/null +++ b/tests/smoke/assertions/config-surfaces.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# config-surfaces.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-surfaces: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +# Enabling Inbound Webhooks before any routes exist is the intended setup order: +# the toggle persists Enabled=true and the advisory just points at `netclaw webhooks +# set`. The gateway fails closed (404) per route until routes are authored. +assert_field '.Webhooks.Enabled' 'true' "$config_json" || : +assert_field '.Webhooks.ExecutionTimeoutSeconds' '45' "$config_json" || : +assert_field '(.McpServers // {} | has("browser_playwright"))' 'false' "$config_json" || : +assert_field '(.McpServers // {} | has("browser_chrome_devtools"))' 'false' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-surfaces: assertions passed." diff --git a/tests/smoke/assertions/config-workspaces-picker.sh b/tests/smoke/assertions/config-workspaces-picker.sh new file mode 100755 index 000000000..d1b814300 --- /dev/null +++ b/tests/smoke/assertions/config-workspaces-picker.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# config-workspaces-picker.tape post-tape assertion. + +set -euo pipefail + +. "$(dirname "$0")/_lib.sh" + +assert_fail=0 + +echo "config-workspaces-picker: reading produced config..." +if [[ ! -f "$CONFIG_PATH" ]]; then + echo "FAIL: ${CONFIG_PATH} does not exist." >&2 + exit 1 +fi + +config_json="$(read_config_json)" + +# The picker writes an absolute path under the per-tape HOME (a random temp dir not +# exported to this assertion), so check the suffix — the operator chose the "picked" +# subdir of the seeded $HOME/ws workspaces tree. +assert_field '(.Workspaces.Directory | endswith("/ws/picked"))' 'true' "$config_json" || : + +if (( assert_fail )); then + printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 + exit 1 +fi + +echo "config-workspaces-picker: assertions passed." diff --git a/tests/smoke/assertions/init-wizard-reverse-proxy.sh b/tests/smoke/assertions/init-wizard-reverse-proxy.sh deleted file mode 100755 index 5aa051d80..000000000 --- a/tests/smoke/assertions/init-wizard-reverse-proxy.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -# init-wizard-reverse-proxy.tape post-tape assertion. -# -# Validates that the wizard surfaced reverse-proxy as an exposure mode and -# produced a startable config: -# 1) config/netclaw.json exists and parses as JSON -# 2) `netclaw doctor` does not report errors (exit 0 = clean, exit 2 = WARN ok) -# 3) Daemon section contains ExposureMode=reverse-proxy, Host=0.0.0.0, -# and TrustedProxies[0]=10.0.0.0/24 — what the tape typed. -# 4) Bootstrap device file exists (reverse-proxy is non-local so the -# wizard must seed at least one paired device). - -set -euo pipefail - -. "$(dirname "$0")/_lib.sh" - -assert_fail=0 - -echo "init-wizard-reverse-proxy: reading produced config..." -if [[ ! -f "$CONFIG_PATH" ]]; then - echo "FAIL: ${CONFIG_PATH} does not exist after wizard run." >&2 - ls -la "$NETCLAW_HOME" 2>&1 >&2 || true - exit 1 -fi - -config_json="$(read_config_json)" -if ! printf '%s' "$config_json" | jq empty >/dev/null 2>&1; then - echo "FAIL: ${CONFIG_PATH} is not valid JSON." >&2 - printf '%s\n' "$config_json" >&2 - exit 1 -fi - -echo "init-wizard-reverse-proxy: running 'netclaw doctor'..." -doctor_status=0 -"$NETCLAW_SMOKE_CLI" doctor || doctor_status=$? -if [[ $doctor_status -eq 1 ]]; then - echo "FAIL: netclaw doctor reported errors (exit 1)." >&2 - exit 1 -fi -if [[ $doctor_status -ne 0 && $doctor_status -ne 2 ]]; then - echo "FAIL: netclaw doctor exited with unexpected status $doctor_status." >&2 - exit 1 -fi - -echo "init-wizard-reverse-proxy: checking Daemon section..." -assert_field '.Daemon.ExposureMode' 'reverse-proxy' "$config_json" || : -assert_field '.Daemon.Host' '0.0.0.0' "$config_json" || : -assert_field '.Daemon.TrustedProxies[0]' '10.0.0.0/24' "$config_json" || : - -echo "init-wizard-reverse-proxy: confirming bootstrap device was seeded..." -devices_path="${NETCLAW_HOME}/config/devices.json" -if [[ ! -f "$devices_path" ]]; then - echo "FAIL: ${devices_path} not written — bootstrap device missing." >&2 - assert_fail=1 -else - device_count="$(jq 'length' "$devices_path" 2>/dev/null || echo 0)" - if [[ "$device_count" -lt 1 ]]; then - echo "FAIL: ${devices_path} contains no paired devices." >&2 - assert_fail=1 - else - echo " ok ${devices_path} has $device_count device(s)" - fi -fi - -if (( assert_fail )); then - printf -- '--- netclaw.json contents ---\n%s\n' "$config_json" >&2 - exit 1 -fi - -echo "init-wizard-reverse-proxy: assertions passed." diff --git a/tests/smoke/screenshots/config-search-brave-entry.approved.png b/tests/smoke/screenshots/config-search-brave-entry.approved.png new file mode 100644 index 000000000..83871b9de Binary files /dev/null and b/tests/smoke/screenshots/config-search-brave-entry.approved.png differ diff --git a/tests/smoke/screenshots/config-search-saved.approved.png b/tests/smoke/screenshots/config-search-saved.approved.png new file mode 100644 index 000000000..b54a63b05 Binary files /dev/null and b/tests/smoke/screenshots/config-search-saved.approved.png differ diff --git a/tests/smoke/screenshots/config-search-selection.approved.png b/tests/smoke/screenshots/config-search-selection.approved.png new file mode 100644 index 000000000..eb534c21c Binary files /dev/null and b/tests/smoke/screenshots/config-search-selection.approved.png differ diff --git a/tests/smoke/screenshots/help.approved.png b/tests/smoke/screenshots/help.approved.png index bb29b2efb..bef9e7ff5 100644 Binary files a/tests/smoke/screenshots/help.approved.png and b/tests/smoke/screenshots/help.approved.png differ diff --git a/tests/smoke/screenshots/wizard-provider-picker.approved.png b/tests/smoke/screenshots/wizard-provider-picker.approved.png index 9f87a71c7..14800cd59 100644 Binary files a/tests/smoke/screenshots/wizard-provider-picker.approved.png and b/tests/smoke/screenshots/wizard-provider-picker.approved.png differ diff --git a/tests/smoke/screenshots/wizard-security-posture.approved.png b/tests/smoke/screenshots/wizard-security-posture.approved.png index 94d8ad0d3..69e78a32b 100644 Binary files a/tests/smoke/screenshots/wizard-security-posture.approved.png and b/tests/smoke/screenshots/wizard-security-posture.approved.png differ diff --git a/tests/smoke/tapes/README.md b/tests/smoke/tapes/README.md index 89a336f4d..56271a712 100644 --- a/tests/smoke/tapes/README.md +++ b/tests/smoke/tapes/README.md @@ -70,8 +70,11 @@ that breaks them. `Wait+Screen /…/` for an anchor in the next view. 6. **Pair each non-trivial tape with an assertion.** Place a sibling - script at `tests/smoke/assertions/.sh`. The wrapper - invokes it with `NETCLAW_HOME` and `NETCLAW_SMOKE_CLI` exported. + script at `tests/smoke/assertions/.sh`. The wrapper + invokes it with `NETCLAW_HOME` and `NETCLAW_SMOKE_CLI` exported. + Config-writing tapes (`init-wizard`, `provider-add`, + `provider-rename`, and `config-*`) require an executable assertion; + the harness fails if it is missing or not executable. ## Anatomy diff --git a/tests/smoke/tapes/config-audience.tape b/tests/smoke/tapes/config-audience.tape new file mode 100644 index 000000000..5cecb08db --- /dev/null +++ b/tests/smoke/tapes/config-audience.tape @@ -0,0 +1,59 @@ +# config-audience.tape - edit Audience Profiles from netclaw config. +# +# Exercises: +# netclaw config -> Security & Access -> Audience Profiles -> Team -> reset +# and verifies reset restores the full posture baseline, including hidden MCP / +# approval settings that are not recreated in this editor. + +Output "/tmp/tape-config-audience.gif" + +# Seed Team config with customized visible tools plus hidden MCP/approval fields. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "export posture=Team mode=Allowlist mcp_mode=All deny=Deny approval=Approval" +Enter +Type "export file_read=file_read file_list=file_list attach_file=attach_file" +Enter +Type "export memorizer=memorizer search_memories=search_memories get=get shell_execute=shell_execute" +Enter +Type "jq -n '{configVersion:1,Security:{DeploymentPosture:$ENV.posture},Tools:{AudienceProfiles:{Team:{ToolsMode:$ENV.mode,AllowedTools:[$ENV.file_read,$ENV.file_list,$ENV.attach_file],McpServersMode:$ENV.mcp_mode,AllowedMcpServers:[$ENV.memorizer],McpServerToolGrants:{($ENV.memorizer):[$ENV.search_memories,$ENV.get]},ApprovalPolicy:{DefaultMode:$ENV.deny,ToolOverrides:{($ENV.shell_execute):$ENV.approval}}}}}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Open Audience Profiles, edit Team, then reset the full profile. +Down 2 +Enter +Wait+Screen@10s /System default posture: Team/ +Wait+Screen@10s /\* Team/ +Down +Enter +Wait+Screen@10s /Audience Profile: Team/ +Down +Space +Wait+Screen@10s /\[✓\] Web/ +Down 7 +Wait+Screen@10s /Reset overrides/ +Enter +Wait+Screen@10s /Team overrides reset to the Team posture baseline/ +Escape +Wait+Screen@10s /System default posture: Team/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_AUDIENCE_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_AUDIENCE_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-back-nav.tape b/tests/smoke/tapes/config-back-nav.tape new file mode 100644 index 000000000..c8762e33d --- /dev/null +++ b/tests/smoke/tapes/config-back-nav.tape @@ -0,0 +1,45 @@ +# config-back-nav.tape — regression guard for the embedded-manager back-out bug. +# +# `netclaw config` → Inference Providers → Esc must return to the dashboard +# (NOT quit the whole config app). Same for Models. Before the fix, the routed +# Provider/Model managers called Shutdown() alongside the queued /config +# navigation, which cancelled the run loop before the nav was processed — so +# backing out of either manager exited the entire config TUI instead of +# returning to the Settings Areas dashboard. The "Settings Areas" anchors after +# each Esc are the regression detectors: if the app quit, they would time out +# against the restored shell prompt. +# +# Nav-only tape: no config is mutated, so there is no semantic post-assertion +# (config-back-nav.sh is a no-op; the Wait+Screen anchors are the test). + +Output "/tmp/tape-config-back-nav.gif" + +# ─── Seed minimal installed config so the dashboard renders ───────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "jq -n '{configVersion:1}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Inference Providers → Esc → back to dashboard ────────────────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +# "Inference Providers" is the first row, selected by default. +Enter +Wait+Screen@10s /Provider Manager/ +Escape +# Regression: must land back on the dashboard, not the shell prompt. +Wait+Screen@10s /Settings Areas/ + +# ─── Models → Esc → back to dashboard ─────────────────────────────────────── +Down +Enter +Wait+Screen@10s /Model Manager/ +Escape +Wait+Screen@10s /Settings Areas/ + +# ─── Exit cleanly ─────────────────────────────────────────────────────────── +Ctrl+Q +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ diff --git a/tests/smoke/tapes/config-channels.tape b/tests/smoke/tapes/config-channels.tape new file mode 100644 index 000000000..a1fdeb005 --- /dev/null +++ b/tests/smoke/tapes/config-channels.tape @@ -0,0 +1,94 @@ +# config-channels.tape - edit Channels from netclaw config. +# +# Exercises: +# netclaw config -> Channels -> configured Slack management menu +# -> channel permission edit -> add channel -> allowed users -> autosave. +# Verifies configured Slack does not re-prompt for credentials during re-entry. + +Output "/tmp/tape-config-channels.gif" + +# Seed Slack config so `netclaw config` can render the channel management flow. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "c1=C01 c2=C02 u1=U01; jq -n --arg c1 $c1 --arg c2 $c2 --arg u1 $u1 --arg aud team '{configVersion:1,Slack:{Enabled:true,SocketMode:true,AllowedChannelIds:[$c1,$c2],AllowedUserIds:[$u1],AllowDirectMessages:false,ChannelAudiences:{($c1):$aud,($c2):$aud}}}' > $NETCLAW_HOME/config/netclaw.json" +Enter +Type "bot=xoxb-test app=xapp-test; jq -n --arg bot $bot --arg app $app '{configVersion:1,Slack:{BotToken:$bot,AppToken:$app}}' > $NETCLAW_HOME/config/secrets.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Channels is row 3 in the dashboard list. +Down 2 +Enter +Wait+Screen@10s /Which channels would you like to connect/ +Wait+Screen@10s /Slack/ +Wait+Screen@10s /2 channels, 1 user/ + +# Re-enter configured Slack. This should open management, not token prompts. +Enter +Wait+Screen@10s /Slack is configured/ +Wait+Screen@10s /Manage channels and permissions/ + +# Edit the first channel audience from Team to Public. +Enter +Wait+Screen@10s /Channels & Permissions/ +Wait+Screen@10s /C01/ +Right +Wait+Screen@10s /Public/ + +# Add a new channel without touching credentials, then use the explicit done row. +Type "a" +Wait+Screen@10s /Add Channel/ +Type "C09" +Enter +Wait+Screen@10s /Added C09/ +Wait+Screen@10s /C09/ +Wait+Screen@10s /Done adding channels/ +Down 2 +Enter +Wait+Screen@10s /What would you like to do/ + +# Update allowed users from the management menu. Completed actions autosave. +Down 2 +Enter +Wait+Screen@10s /User IDs/ +Right 32 +Backspace 32 +Type "U09" +Enter +Wait+Screen@10s /Allowed users saved/ + +# Rotate credentials using typed input, not paste. +Down 2 +Enter +Wait+Screen@10s /Slack > Credentials/ +Type "xoxb-smoke-typed" +Tab +Type "xapp-smoke-typed" +Enter +Wait+Screen@10s /Credential changes saved/ + +# Return to picker and select the explicit done row. No save key is required. +Escape +Wait+Screen@10s /Which channels would you like to connect/ +Wait+Screen@10s /3 channels/ +Wait+Screen@10s /Done adding channels/ + +Down 3 +Enter +Wait+Screen@10s /Settings Areas/ + +Ctrl+Q + +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_CHANNELS_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_CHANNELS_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-exposure.tape b/tests/smoke/tapes/config-exposure.tape new file mode 100644 index 000000000..16330176a --- /dev/null +++ b/tests/smoke/tapes/config-exposure.tape @@ -0,0 +1,94 @@ +# config-exposure.tape — edit Exposure Mode from netclaw config. +# +# Exposure Mode is a post-install Security & Access leaf, not an init-wizard +# step. This tape exercises the configured route: +# netclaw config -> Security & Access -> Exposure Mode +# verifies the reverse-proxy branch, then returns to Local. + +Output "/tmp/tape-config-exposure.gif" + +# ─── Seed minimal installed config ─────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "jq -n '{configVersion:1, Daemon:{Port:5299, DisableSelfUpdate:true}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch config dashboard ───────────────────────────────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Root dashboard order: Inference Providers, Models, Channels, Inbound Webhooks, +# Skill Sources, Search, Browser Automation, Telemetry & Alerting, Security & Access. +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Security & Access order: Security Posture, Enabled Features, Audience Profiles, +# Exposure Mode. +Down 3 +Enter +Wait+Screen@10s /How will this Netclaw daemon be accessed/ + +# Select Reverse Proxy (second option). +Down +Enter + +# Accept default reverse-proxy bind address. +Wait+Screen@10s /Reverse proxy: bind address/ +Enter + +# Enter one trusted proxy CIDR. +Wait+Screen@10s /Reverse proxy: trusted proxies/ +Type "10.0.0.0/24" +Enter + +# Confirm notice and save Reverse Proxy. +Wait+Screen@10s /Reverse proxy configured/ +Wait+Screen@5s /http:\/\/0\.0\.0\.0:[0-9]+/ +Enter +Wait+Screen@10s /Reverse Proxy exposure mode saved/ + +# Exit and prove the non-local write semantically before returning to Local. +Enter +Wait+Screen@10s /Security & Access/ +Ctrl+Q +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "REVERSE_PROXY=reverse-proxy BIND_HOST=0.0.0.0 PROXY_CIDR=10.0.0.0/24 jq -e '.Daemon.ExposureMode == env.REVERSE_PROXY and .Daemon.Host == env.BIND_HOST and .Daemon.TrustedProxies[0] == env.PROXY_CIDR' $NETCLAW_HOME/config/netclaw.json" +Enter +Wait+Screen@5s /true/ +Type "echo CONFIG_EXPOSURE_REVERSE_PROXY_ASSERT=$?" +Enter +Wait+Screen@5s /CONFIG_EXPOSURE_REVERSE_PROXY_ASSERT=0/ + +# Reopen Exposure Mode and return to Local. Reopening must not preserve the +# one-shot saved screen. +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 8 +Enter +Wait+Screen@10s /Security & Access/ +Down 3 +Enter +Wait+Screen@10s /How will this Netclaw daemon be accessed/ +Up +Enter +Wait+Screen@10s /Local exposure mode saved/ +Enter +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_EXPOSURE_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_EXPOSURE_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-features.tape b/tests/smoke/tapes/config-features.tape new file mode 100644 index 000000000..d04511709 --- /dev/null +++ b/tests/smoke/tapes/config-features.tape @@ -0,0 +1,53 @@ +# config-features.tape — edit Enabled Features from netclaw config. +# +# Exercises: +# netclaw config -> Security & Access -> Enabled Features +# and verifies feature toggle persistence through the shared config editor save path. + +Output "/tmp/tape-config-features.gif" + +# ─── Seed minimal installed config ─────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "posture=Team; backend=duckduckgo; jq -n --arg posture $posture --arg backend $backend '{configVersion:1,Security:{DeploymentPosture:$posture},Memory:{Enabled:true},Search:{Enabled:false,Backend:$backend},SkillSync:{Enabled:true},Scheduling:{Enabled:false},SubAgents:{Enabled:true},Webhooks:{Enabled:false}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch config dashboard ───────────────────────────────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Root dashboard order: Inference Providers, Models, Channels, Inbound Webhooks, +# Skill Sources, Search, Browser Automation, Telemetry & Alerting, Security & Access. +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Security & Access order: Security Posture, Enabled Features, Audience Profiles, +# Exposure Mode. +Down +Enter +Wait+Screen@10s /Toggle global runtime features/ +Wait+Screen@5s /\[✓\] Memory/ +Wait+Screen@5s /\[ \] Search/ + +# Enable Search; inline toggles save immediately. +Down +Space +Wait+Screen@5s /\[✓\] Search/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +# VHS's screen scraper needs a beat to pick up the restored main buffer after the +# TUI tears down the alternate screen (CSI ?1049l); without it /TAPE\$/ matches the +# stale pre-restore buffer and the next keystroke is eaten by the exiting TUI. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_FEATURES_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_FEATURES_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-ops-surfaces.tape b/tests/smoke/tapes/config-ops-surfaces.tape new file mode 100644 index 000000000..449e80f50 --- /dev/null +++ b/tests/smoke/tapes/config-ops-surfaces.tape @@ -0,0 +1,66 @@ +# config-ops-surfaces.tape - exercise Task 1.6 config areas. +# +# Covers: +# - Telemetry & Alerting telemetry + outbound webhook save path +# +# The Skill Sources "add a local folder" save path moved to config-skill-picker.tape: +# that screen is now a Termina FilePickerNode, whose terminal scroll region the vhs +# emulator does not reset on exit, so a second netclaw launch in the same recording +# never renders. Each directory-picker flow therefore gets its own single-launch tape. +# +# Post-tape assertion validates the persisted config semantically. + +Output "/tmp/tape-config-ops-surfaces.gif" + +# Seed minimal installed config. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "jq -n '{configVersion:1}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# Telemetry & Alerting: enable OTLP, set the endpoint, then add an outbound +# Slack webhook through the multi-webhook list editor. +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 7 +Enter +Wait+Screen@10s /Telemetry & Alerting/ +Wait+Screen@5s /Delivery-policy tuning/ + +# Row 0: toggle telemetry on (autosaves). +Space +Wait+Screen@10s /Telemetry enabled state saved/ + +# Row 1: edit and save the OTLP endpoint. +Down +Type "http://127.0.0.1:4318" +Enter +Wait+Screen@10s /Telemetry & Alerting settings saved/ + +# Row 2 ("+ Add webhook"): open the add form, enter a Slack URL, and save. +Down +Wait+Screen@5s /Add webhook/ +Enter +Wait+Screen@10s /Add outbound webhook/ +Down +Type "https://hooks.slack.com/services/T000/B000/SECRET" +Wait+Screen@5s /Slack \(auto-detected/ +Enter +Wait+Screen@10s /Saved/ +Wait+Screen@5s /Outbound Webhooks/ +Ctrl+Q +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "OTLP_ENDPOINT=http://127.0.0.1:4318 WEBHOOK_FORMAT=Slack jq -e '.Telemetry.Enabled == true and .Telemetry.Otlp.Endpoint == env.OTLP_ENDPOINT and .Notifications.Webhooks[0].Format == env.WEBHOOK_FORMAT' $NETCLAW_HOME/config/netclaw.json" +Enter +Wait+Screen@5s /true/ + +Type "echo CONFIG_OPS_SURFACES_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_OPS_SURFACES_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-posture.tape b/tests/smoke/tapes/config-posture.tape new file mode 100644 index 000000000..67db263a5 --- /dev/null +++ b/tests/smoke/tapes/config-posture.tape @@ -0,0 +1,41 @@ +# config-posture.tape - edit Security Posture from netclaw config. +# +# Exercises: +# netclaw config -> Security & Access -> Security Posture +# and verifies posture persistence plus automatic continuation into Enabled Features. + +Output "/tmp/tape-config-posture.gif" + +# Seed minimal installed config with Personal posture. +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "posture=Personal; jq -n --arg posture $posture '{configVersion:1,Security:{DeploymentPosture:$posture}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 8 +Enter +Wait+Screen@10s /Security & Access/ + +# Open Security Posture, switch Personal -> Team, then land in Enabled Features. +Enter +Wait+Screen@10s /Current posture: Personal/ +Down +Enter +Wait+Screen@10s /Toggle global runtime features/ +Escape +Wait+Screen@10s /Security & Access/ +Ctrl+Q + +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_POSTURE_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_POSTURE_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-search.tape b/tests/smoke/tapes/config-search.tape new file mode 100644 index 000000000..906d1bf39 --- /dev/null +++ b/tests/smoke/tapes/config-search.tape @@ -0,0 +1,71 @@ +# config-search.tape — drive `netclaw config` into the Search workflow. +# +# Covers: +# - dashboard -> Search launcher workflow +# - provider selection -> entry state +# - static validation failure preserves typed input +# - dynamic validation failure opens explicit override flow +# - save-anyway persists and returns cleanly +# +# Post-tape assertion validates the Search section persisted to netclaw.json. + +Output "/tmp/tape-config-search.gif" + +# ─── Seed minimal config so `netclaw config` can launch ──────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "backend=duckduckgo; jq -n --arg backend $backend '{configVersion:1,Search:{Backend:$backend}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch ──────────────────────────────────────────────────────────────── +Type "netclaw config" +Enter + +# ─── Dashboard -> Search ────────────────────────────────────────────────── +Wait+Screen@10s /Settings Areas/ +# Search is row 6 in the dashboard list. +Down 5 +Enter + +# ─── Search provider selection ──────────────────────────────────────────── +Wait+Screen@10s /Search/ +Wait+Screen@5s /DuckDuckGo works without setup/ + +# ─── Select Brave and confirm static validation blocks progression ───────── +Down +Enter +Wait+Screen@10s /Brave Search requires an API key/ +Wait+Screen@5s /Stored in secrets.json/ +Enter +Wait+Screen@10s /Brave requires an API key/ +Escape + +# ─── Select SearXNG and complete happy path ─────────────────────────────── +Down +Enter +Wait+Screen@10s /Enter the base URL of your SearXNG instance/ +Type "https://search.test.local" +Enter +# Do NOT anchor on the "Validating Search configuration..." spinner: it is a +# transient probe state that can clear faster than vhs's screen-poll interval, +# so the wait hard-times-out even though validation ran. Anchor on the stable +# post-validation state instead (mirrors tapes/screenshots/config-search.tape). +Wait+Screen@10s /Search Validation Warning/ +Down 2 +Enter +Wait+Screen@10s /validated and saved/ +Enter +Wait+Screen@10s /Settings Areas/ + +# ─── Back out to shell ──────────────────────────────────────────────────── +Ctrl+Q +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_SEARCH_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_SEARCH_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-skill-picker.tape b/tests/smoke/tapes/config-skill-picker.tape new file mode 100644 index 000000000..a03cbaf7c --- /dev/null +++ b/tests/smoke/tapes/config-skill-picker.tape @@ -0,0 +1,38 @@ +# config-skill-picker.tape — Skill Sources "add a local folder" picker flow. +# +# The directory picker (Termina FilePickerNode) leaves a scroll region the vhs +# terminal emulator does not reset on exit, which breaks a SECOND netclaw launch +# in the same recording. So this picker flow lives in its own tape and ends right +# after the save — the post-tape assertion validates the persisted config. + +Output "/tmp/tape-config-skill-picker.gif" + +# Seed minimal installed config and a local skill directory under HOME — the +# picker opens at HOME, so the seeded folder is the lone entry to choose. +Type "mkdir -p $NETCLAW_HOME/config $HOME/netclaw-smoke-skill-picker" +Enter +Type "jq -n '{configVersion:1}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 4 +Enter +Wait+Screen@10s /Skill Sources/ +Wait+Screen@5s /Places Netclaw loads skills from/ +Enter +Wait+Screen@10s /Add a local skill folder/ +# The picker opens at $HOME; the seeded folder is highlighted. Space chooses it. +Wait+Screen@5s /netclaw-smoke-skill-picker/ +Space +Wait+Screen@10s /Allow symlinks inside this folder/ +Enter +Wait+Screen@10s /Review local folder source/ +Enter +Wait+Screen@10s /Added local skill folder/ + +# End the tape immediately (no second launch). The post-tape assertion verifies the config. +Ctrl+Q +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-surfaces.tape b/tests/smoke/tapes/config-surfaces.tape new file mode 100644 index 000000000..4d50272d9 --- /dev/null +++ b/tests/smoke/tapes/config-surfaces.tape @@ -0,0 +1,67 @@ +# config-surfaces.tape — exercise Task 1.5 config areas. +# +# Covers: +# - Inbound Webhooks timeout editing and enable-first advisory (no routes yet) +# - Browser Automation guidance-only path without shelling out from the TUI +# +# The Workspaces Directory save path moved to config-workspaces-picker.tape: that +# screen is now a Termina FilePickerNode, whose terminal scroll region the vhs +# emulator does not reset on exit, so a second netclaw launch in the same recording +# never renders. Each directory-picker flow therefore gets its own single-launch tape. +# +# Post-tape assertion validates the persisted config semantically. + +Output "/tmp/tape-config-surfaces.gif" + +# ─── Seed minimal installed config ────────────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "jq -n '{configVersion:1}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Inbound Webhooks timeout save and enable-first advisory ──────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 3 +Enter +Wait+Screen@10s /Inbound Webhooks/ +Wait+Screen@5s /Route authoring/ +Down +Type "45" +Enter +Wait+Screen@10s /Inbound Webhooks settings saved/ +Up +Space +Wait+Screen@10s /Add at least one route/ +Ctrl+Q +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +# Enabling with no routes is the intended setup order: the toggle persists +# Enabled=true and the advisory just points at `netclaw webhooks set`. +Type "jq -e '.Webhooks.Enabled == true and .Webhooks.ExecutionTimeoutSeconds == 45' $NETCLAW_HOME/config/netclaw.json" +Enter +Wait+Screen@5s /true/ + +# ─── Browser Automation guidance-only path ───────────────────────────────── +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ +Down 6 +Enter +Wait+Screen@10s /Browser Automation/ +Wait+Screen@10s /Manual install guidance/ +Wait+Screen@10s /MCP permissions/ +Ctrl+Q +# Alt-screen restore guard (CSI ?1049l): give vhs a beat to catch the restored main buffer. +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CONFIG_SURFACES_EXIT=$?" +Enter +Wait+Screen@5s /CONFIG_SURFACES_EXIT=0/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/config-workspaces-picker.tape b/tests/smoke/tapes/config-workspaces-picker.tape new file mode 100644 index 000000000..4455d0add --- /dev/null +++ b/tests/smoke/tapes/config-workspaces-picker.tape @@ -0,0 +1,36 @@ +# config-workspaces-picker.tape — Workspaces Directory picker flow. +# +# The directory picker (Termina FilePickerNode) leaves a scroll region the vhs +# terminal emulator does not reset on exit, which breaks a SECOND netclaw launch +# in the same recording. So this picker flow lives in its own tape and ends right +# after the save — the post-tape assertion validates the persisted config. + +Output "/tmp/tape-config-workspaces-picker.gif" + +# Seed installed config + a workspaces tree to pick from. Workspaces.Directory +# points at an existing dir so the picker opens there; pick the lone "picked" subdir. +Type "export WS=$HOME/ws" +Enter +Type "mkdir -p $NETCLAW_HOME/config $WS/picked" +Enter +Type "jq -n '{configVersion:1, Workspaces:{Directory: env.WS}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +Type "netclaw config" +Enter +Wait+Screen@10s /Settings Areas/ + +# Workspaces Directory is after Security & Access. +Down 9 +Enter +Wait+Screen@10s /Workspaces Directory/ +Wait+Screen@5s /Choose the workspaces directory/ +# The picker opens at $HOME/ws; the lone "picked" subdir is highlighted. Space chooses it. +Wait+Screen@5s /picked/ +Space +Wait+Screen@10s /Workspaces Directory saved/ + +# End the tape immediately (no second launch). The post-tape assertion verifies the config. +Ctrl+Q +Type "exit" +Enter diff --git a/tests/smoke/tapes/init-existing.tape b/tests/smoke/tapes/init-existing.tape new file mode 100644 index 000000000..6c1e5e182 --- /dev/null +++ b/tests/smoke/tapes/init-existing.tape @@ -0,0 +1,53 @@ +# init-existing.tape — existing-install menu + double-confirmed start-over. +# +# Seeds a config so `netclaw init` opens the existing-install action menu +# instead of the bootstrap wizard (simplify-netclaw-init §3-4). Drives into +# the start-over scope + first confirmation, backs out without confirming, +# then cancels — proving the menu renders, the reset is double-confirmed, and +# config is untouched until both confirmations pass. +# +# No escaped double-quotes (VHS v0.11 limitation); $NETCLAW_HOME has no spaces. + +Output "/tmp/tape-init-existing.gif" + +# ─── Seed an existing install ─────────────────────────────────────── +Type "mkdir -p $NETCLAW_HOME/config && echo {} > $NETCLAW_HOME/config/netclaw.json" +Enter +Wait+Screen@5s /TAPE\$/ + +# ─── Launch → existing-install menu (not the wizard) ──────────────── +Type "netclaw init" +Enter +Wait+Screen@15s /Existing Netclaw install detected/ + +# Move to "Start over from scratch" (3rd item) and open the scope chooser. +Down 2 +Enter +Wait+Screen@10s /choose a scope/ + +# "Full reset" (2nd) → first confirmation. +Down +Enter +Wait+Screen@10s /confirmation 1 of 2/ + +# Default selection is Cancel, and Esc backs out without deleting anything. +Escape +Wait+Screen@10s /choose a scope/ + +# Cancel (3rd) returns to the menu. +Down 2 +Enter +Wait+Screen@10s /Existing Netclaw install detected/ + +# Cancel (4th) exits init with config untouched. +Down 3 +Enter +Wait+Screen@10s /TAPE\$/ + +# The config must still exist — we never confirmed a reset. +Type "test -f $NETCLAW_HOME/config/netclaw.json && echo MENU_OK" +Enter +Wait+Screen@5s /MENU_OK/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/init-wizard-reverse-proxy.tape b/tests/smoke/tapes/init-wizard-reverse-proxy.tape deleted file mode 100644 index dcbe19376..000000000 --- a/tests/smoke/tapes/init-wizard-reverse-proxy.tape +++ /dev/null @@ -1,148 +0,0 @@ -# init-wizard-reverse-proxy.tape — Personal posture, Ollama, ReverseProxy exposure. -# -# Variant of init-wizard.tape that exercises the reverse-proxy branch of the -# Network Exposure step. Covers: -# - selecting "Reverse Proxy" from the exposure mode list (second option) -# - the bind-address text input (kept at the 0.0.0.0 default) -# - the trusted-proxies text input (one CIDR entered) -# - the medium-risk notice screen that shows the serving URL -# -# The post-tape assertion (init-wizard-reverse-proxy.sh) jq-checks the produced -# Daemon section and runs `netclaw doctor`. -# -# Synchronization rule (per tapes/README.md): no literal Sleep for step -# synchronization; every step has a Wait+Screen anchor pulled from the -# matching *StepView.cs. - -Output "/tmp/tape-init-wizard-reverse-proxy.gif" - -# ─── Launch ────────────────────────────────────────────────────────── -Type "netclaw init" -Enter - -# ─── Step 1: Provider ─────────────────────────────────────────────── -Wait+Screen@10s /Choose your LLM provider:/ -# Provider list ordering is alphabetical by TypeKey: -# anthropic, github-copilot, ollama, openai, openai-compatible, openrouter -# Two Downs from the Anthropic default land on Ollama. -Down 2 -Enter - -Wait+Screen@10s /endpoint:/ -Right 32 -Backspace 32 -Type "http://localhost:11434" -Enter - -Wait+Screen@45s /Select a model/ -# Termina list-key-handler wiring beat — see init-wizard.tape:46 -Sleep 1s -Down -Enter - -# ─── Step 2: Security Posture ──────────────────────────────────────── -Wait+Screen@10s /Who will interact with this Netclaw instance/ -Enter - -# ─── Step 3: Channel Picker ────────────────────────────────────────── -Wait+Screen@10s /Which channels would you like to connect/ -Type "d" - -# ─── Step 4: Web Search ───────────────────────────────────────────── -Wait+Screen@10s /Choose your web search provider/ -Enter - -# ─── Step 5: Browser Automation ────────────────────────────────────── -Wait+Screen@10s /Enable browser automation/ -Enter - -# ─── Step 6: Identity ─────────────────────────────────────────────── -Wait+Screen@10s /Agent name:/ -Enter - -Wait+Screen@10s /Communication style:/ -Enter - -Wait+Screen@10s /Your name:/ -Type "SmokeTester" -Enter - -Wait+Screen@10s /Your timezone:/ -Enter - -Wait+Screen@10s /Projects directory:/ -Enter - -Wait+Screen@10s /Notification webhook URL/ -Enter - -# ─── Step 8: Skill Feeds ──────────────────────────────────────────── -Wait+Screen@10s /Connect to a private skill server/ -Down -Enter - -# ─── Step 9: Network Exposure (reverse proxy branch) ──────────────── -Wait+Screen@10s /How will this Netclaw daemon be accessed/ -# Mode list (ExposureModeStepView.BuildModeSelection): -# 0: Local — loopback only, safest (recommended) ← default -# 1: Reverse Proxy — behind nginx, Caddy, Traefik, ... ← target -# 2: Tailscale Serve -# 3: Tailscale Funnel -# 4: Cloudflare Tunnel -Down -Enter - -# Sub-step 1: bind address. Default placeholder is 0.0.0.0; accept it. -Wait+Screen@10s /Reverse proxy: bind address/ -Enter - -# Sub-step 2: trusted proxies. Empty list is rejected by the ViewModel gate; -# enter one CIDR so the wizard can advance. -Wait+Screen@10s /Reverse proxy: trusted proxies/ -Type "10.0.0.0/24" -Enter - -# Sub-step 3: notice with serving URL. -Wait+Screen@10s /Reverse proxy configured/ -# Serving URL line is rendered as "Daemon will listen on: http://0.0.0.0:5199" -Wait+Screen@5s /http:\/\/0\.0\.0\.0:5199/ -Enter - -# Sub-step 4: webhook toggle. -# Option order is [No (default), Yes]. Down+Enter selects "Yes — accept inbound -# webhook requests" so the produced netclaw.json gets a Webhooks section. -# (The assertion script does not validate the webhook choice; both branches -# work for the smoke run. We deliberately exercise the non-default branch to -# prove the toggle plumbing.) -Wait+Screen@10s /Should this daemon accept inbound webhooks/ -Down -Enter - -# ─── Step 10: Health Check ────────────────────────────────────────── -Wait+Screen@10s /Press Enter to run health checks/ -Enter - -# Daemon starts on 0.0.0.0:5199 but in reverse-proxy mode the CLI cannot -# auto-auth back to it via loopback (loopback auto-auth is intentionally -# disabled for reverse-proxy to prevent a forwarded-header from inheriting -# operator privileges). The wizard's chat-page handshake therefore gets 401 -# and the wizard exits to the shell instead of opening the TUI — that's -# correct behavior, not a bug. -# -# Wait for either terminal state: -# (a) chat-page ready bar (if a future change ever wires post-init differently) -# (b) the shell prompt re-appearing post-wizard-exit, with `netclaw init` -# still visible as the last-typed command -Wait+Screen@180s /(Ready \| qwen2:0\.5b|TAPE\$ netclaw init)/ -# No-op if we're already at the shell; quits the TUI if (a) above. -Ctrl+Q - -Wait+Screen@15s /TAPE\$ / - -# Sanity: re-check at the prompt that init reported success. -Type "echo INIT_EXIT=$?" -Enter -Wait+Screen@5s /INIT_EXIT=/ - -Type "exit" -Enter diff --git a/tests/smoke/tapes/init-wizard.tape b/tests/smoke/tapes/init-wizard.tape index 073d964c9..60eb7cde0 100644 --- a/tests/smoke/tapes/init-wizard.tape +++ b/tests/smoke/tapes/init-wizard.tape @@ -1,14 +1,15 @@ -# init-wizard.tape — Personal-posture, Ollama, full wizard walkthrough. +# init-wizard.tape — Personal-posture, Ollama, simplified bootstrap walkthrough. # -# This is the regression-catcher: the netclaw/knit interactive-wizard -# bug lived inside this flow. Every interactive prompt in the wizard -# is exercised here, and the post-tape assertion validates the -# produced config against the embedded JSON schema. +# This is the regression-catcher for the simplified `netclaw init` flow +# (simplify-netclaw-init): Provider → Identity → Security Posture → +# (Enabled Features, skipped for Personal) → Health Check. Channels, Search, +# Browser Automation, and Skill Sources moved to `netclaw config` and are no +# longer part of bootstrap. The post-tape assertion validates the produced +# config against the embedded JSON schema. # -# Synchronization: every step waits on a stable substring from the -# step's view source (see src/Netclaw.Cli/Tui/Wizard/Steps/*StepView.cs). -# Do NOT introduce literal `Sleep` — tighten the regex if you hit a -# false positive. +# Synchronization: every step waits on a stable substring from the step's +# view source (see src/Netclaw.Cli/Tui/Wizard/Steps/*StepView.cs). Do NOT +# introduce literal `Sleep` — tighten the regex if you hit a false positive. # # Outputs only a debug GIF; tapes are not authored for screenshots. @@ -18,59 +19,35 @@ Output "/tmp/tape-init-wizard.gif" Type "netclaw init" Enter -# ─── Step 1: Provider ─────────────────────────────────────────────── +# ─── Step 1 of 4: Provider ────────────────────────────────────────── Wait+Screen@10s /Choose your LLM provider:/ # Provider list ordering is alphabetical by TypeKey: # anthropic, github-copilot, ollama, openai, openai-compatible, openrouter -# Display order matches: Anthropic (default), then github-copilot, then Ollama. # Two Downs from the Anthropic default land on Ollama. Down 2 Enter -# Endpoint input (default http://localhost:11434 — native Ollama is at -# http://localhost:11434, which is also the default). +# Endpoint input (default http://localhost:11434). Wait+Screen@10s /endpoint:/ # Clear the default value. VHS has no End/Home — push cursor right past -# the existing text (Right N is a no-op when already at end), then -# Backspace enough to clear. Default is "http://localhost:11434" (22 chars). +# the existing text, then Backspace enough to clear (default is 22 chars). Right 32 Backspace 32 Type "http://localhost:11434" Enter -# Wait for connection probe + model discovery. The "Connected! Found N models" -# success message auto-advances to the model list quickly enough that vhs -# can miss it; wait directly for the model selection header instead. +# Wait for connection probe + model discovery. The success message +# auto-advances to the model list quickly enough that vhs can miss it; +# wait directly for the model selection header instead. Wait+Screen@45s /Select a model/ -# The model list renders right after the network probe; Termina needs a -# beat to wire up the list's key handler. Without this pause the Down is -# dropped and the wizard selects the first model (all-minilm) instead. +# Termina needs a beat to wire up the list's key handler after the probe. Sleep 1s # Models are alphabetical: all-minilm first, qwen2 second. Move to qwen2:0.5b. Down Enter -# ─── Step 2: Security Posture ──────────────────────────────────────── -Wait+Screen@10s /Who will interact with this Netclaw instance/ -# Personal is the first / default-highlighted option. -Enter - -# ─── Step 3: Channel Picker ────────────────────────────────────────── -Wait+Screen@10s /Which channels would you like to connect/ -# 'd' = done without selecting any channels (skip channel setup). -Type "d" - -# ─── Step 4: Web Search ───────────────────────────────────────────── -Wait+Screen@10s /Choose your web search provider/ -# DuckDuckGo is the first / default option. -Enter - -# ─── Step 5: Browser Automation ────────────────────────────────────── -Wait+Screen@10s /Enable browser automation/ -# "No" is the first / default option. -Enter - -# ─── Step 6: Identity ─────────────────────────────────────────────── +# ─── Step 2 of 4: Identity ────────────────────────────────────────── +# Identity now immediately follows Provider in the bootstrap flow. Wait+Screen@10s /Agent name:/ Enter @@ -84,49 +61,26 @@ Enter Wait+Screen@10s /Your timezone:/ Enter -Wait+Screen@10s /Projects directory:/ -Enter - -Wait+Screen@10s /Notification webhook URL/ -Enter - -# ─── Step 7: External Skills (skipped in smoke) ───────────────────── -# ExternalSkillsStep.IsApplicable returns false when _detectedSources.Count == 0. -# The smoke host has no Claude Code etc., so the wizard goes straight -# from Identity → SkillFeeds with no External Skills prompts. If that ever -# changes (smoke host starts seeding Claude Code), we'll need to handle -# the "External skill directories detected" multi-select + custom dir input. - -# ─── Step 8: Skill Feeds ──────────────────────────────────────────── -Wait+Screen@10s /Connect to a private skill server/ -# Default is "Yes, connect"; we want "No — skip" (Down + Enter). -Down -Enter +# Identity ends at timezone — workspaces directory and notification webhooks are +# post-install settings owned by `netclaw config`, no longer collected in init. -# ─── Step 9: Network Exposure ─────────────────────────────────────── -Wait+Screen@10s /How will this Netclaw daemon be accessed/ -# "Local — loopback only" is the first / default option. -Enter - -Wait+Screen@10s /Should this daemon accept inbound webhooks/ -# "No" — second option. -Down +# ─── Step 3 of 4: Security Posture ────────────────────────────────── +Wait+Screen@10s /Who will interact with this Netclaw instance/ +# Personal is the first / default-highlighted option. Personal skips the +# Enabled Features step and goes straight to the health check. Enter -# ─── Step 10: Health Check ────────────────────────────────────────── +# ─── Step 4 of 4: Health Check ────────────────────────────────────── Wait+Screen@10s /Press Enter to run health checks/ Enter -# Health checks contact ollama, validate config, run doctor probes. -# On success the wizard auto-navigates to the chat page (the daemon is -# running by this point and the user lands inside the TUI). Wait for -# the chat status bar to show the configured model, then quit the TUI -# with Ctrl+Q to drop back to the shell. The post-tape assertion runs -# `netclaw doctor` against the config the wizard wrote, which is the -# real signal — vhs only proves we got this far. -# -# Generous timeout: this step covers a daemon cold-start, an Ollama -# model load, and the chat-page transition — slow on a loaded CI runner. +# Health checks contact ollama, write config, validate, and start the daemon. +# On a clean bootstrap, once validation passes the wizard launches chat +# automatically — there is no Enter gate / post-flight summary to confirm. +# Anchor on a health item that renders during the run (it stays on screen +# through the daemon-start poll), then wait for the chat screen to take over. +# Generous timeout: daemon cold-start + Ollama model load. +Wait+Screen@240s /Configuration written/ Wait+Screen@240s /Ready \| qwen2:0.5b/ Ctrl+Q diff --git a/tests/smoke/tapes/preamble.tape b/tests/smoke/tapes/preamble.tape index cfb0c177f..c62943f3f 100644 --- a/tests/smoke/tapes/preamble.tape +++ b/tests/smoke/tapes/preamble.tape @@ -42,10 +42,12 @@ Hide # The host vhs-spawned bash may carry an unhelpful PS1; we set our own # below so anchors are deterministic. -Type "rm -rf __NETCLAW_HOME__ && mkdir -p __NETCLAW_HOME__" +Type "rm -rf __NETCLAW_HOME__ __NETCLAW_USER_HOME__ && mkdir -p __NETCLAW_HOME__ __NETCLAW_USER_HOME__" Enter Type "export PATH=__NETCLAW_BIN_DIR__:$PATH" Enter +Type "export HOME=__NETCLAW_USER_HOME__" +Enter Type "export NETCLAW_HOME=__NETCLAW_HOME__" Enter Type "export NETCLAW_DAEMON_PATH=__NETCLAW_DAEMON__" diff --git a/tests/smoke/tapes/screenshots/config-search.tape b/tests/smoke/tapes/screenshots/config-search.tape new file mode 100644 index 000000000..45049ad2e --- /dev/null +++ b/tests/smoke/tapes/screenshots/config-search.tape @@ -0,0 +1,57 @@ +# config-search.tape (screenshot) — capture the Search workflow screens. +# +# Frames captured: +# shot-config-search-selection +# shot-config-search-brave-entry +# shot-config-search-saved + +Output "/tmp/tape-shot-config-search.gif" + +# ─── Seed minimal config so `netclaw config` can launch ──────────────────── +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "backend=duckduckgo; jq -n --arg backend $backend '{configVersion:1,Search:{Backend:$backend}}' > $NETCLAW_HOME/config/netclaw.json" +Enter + +# ─── Launch ──────────────────────────────────────────────────────────────── +Type "netclaw config" +Enter + +Wait+Screen@10s /Settings Areas/ +Down 5 +Enter + +# ─── Frame 1: Provider selection ─────────────────────────────────────────── +Wait+Screen@10s /Choose the backend Netclaw uses for web search/ +Sleep 1s +Screenshot "/tmp/shot-config-search-selection.png" +Sleep 1s + +# ─── Frame 2: Brave entry state ──────────────────────────────────────────── +Down +Enter +Wait+Screen@10s /Brave Search requires an API key/ +Sleep 1s +Screenshot "/tmp/shot-config-search-brave-entry.png" +Sleep 1s + +# ─── Frame 3: Saved state ────────────────────────────────────────────────── +Escape +Down +Enter +Wait+Screen@10s /Enter the base URL of your SearXNG instance/ +Type "https://search.test.local" +Enter +Wait+Screen@10s /Search Validation Warning/ +Down 2 +Enter +Wait+Screen@10s /validated and saved/ +Sleep 1s +Screenshot "/tmp/shot-config-search-saved.png" +Sleep 1s + +Ctrl+Q +Wait+Screen@10s /TAPE\$/ + +Type "exit" +Enter diff --git a/tests/smoke/tapes/screenshots/wizard-screens.tape b/tests/smoke/tapes/screenshots/wizard-screens.tape index 032148527..854f0209d 100644 --- a/tests/smoke/tapes/screenshots/wizard-screens.tape +++ b/tests/smoke/tapes/screenshots/wizard-screens.tape @@ -58,6 +58,24 @@ Wait+Screen@45s /Select a model/ Down Enter +# ─── Identity (navigated through, not screenshotted) ───────────────── +# Identity immediately follows Provider in the current bootstrap flow, so its four +# substeps must be walked to reach the posture screen. These frames are deliberately +# NOT screenshotted — the text-input caret blink makes them non-byte-stable (see the +# header). Navigation + anchors mirror tapes/init-wizard.tape. +Wait+Screen@10s /Agent name:/ +Enter + +Wait+Screen@10s /Communication style:/ +Enter + +Wait+Screen@10s /Your name:/ +Type "SmokeTester" +Enter + +Wait+Screen@10s /Your timezone:/ +Enter + # ─── Frame 2: Security posture ─────────────────────────────────────── Wait+Screen@10s /Who will interact with this Netclaw instance/ Sleep 1s