diff --git a/ROADMAP.md b/ROADMAP.md index f9580acfe..7a6159882 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -910,7 +910,7 @@ graph LR | Registries — easier search + add-server | Done | P1 | 21/24 (88%) | [070-registry-easy-upstream-add](./specs/070-registry-easy-upstream-add/) | | | Scanner simplification (deterministic default, opt-in deep scan) | Done | P1 | 38/42 (90%) | [077-scanner-simplification](./specs/077-scanner-simplification/) | | | Deferred-schema serialization for the direct tools/list surface (spec 102) | Done | P1 | 89/89 (100%) | [102-schema-deferred](./specs/102-schema-deferred/) | #1063 | -| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | Done | P1 | 94/113 (83%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | | +| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | Done | P1 | 111/113 (98%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | | | Tray↔core decoupling: socket/REST API only, no config-file reads | Done | P2 | — | | | | Spec 107 server edition SSO front door hardened for real IdPs | Done | P2 | 126/126 (100%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | | @@ -938,7 +938,7 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [003-tool-annotations-webui](./specs/003-tool-annotations-webui/) | `in-flight` | 37/64 (58%) | | [004-management-health-refactor](./specs/004-management-health-refactor/) | `in-flight` | 73/101 (72%) | | [005-rest-management-integration](./specs/005-rest-management-integration/) | `shipped` | 45/45 (100%) | -| [006-oauth-extra-params](./specs/006-oauth-extra-params/) | `in-flight` | 43/65 (66%) | +| [006-oauth-extra-params](./specs/006-oauth-extra-params/) | `in-flight` | 44/65 (68%) | | [007-oauth-e2e-testing](./specs/007-oauth-e2e-testing/) | `in-flight` | 94/103 (91%) | | [008-oauth-token-refresh](./specs/008-oauth-token-refresh/) | `in-flight` | 57/64 (89%) | | [009-proactive-oauth-refresh](./specs/009-proactive-oauth-refresh/) | `in-flight` | 47/87 (54%) | @@ -1032,9 +1032,9 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—` | [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) | | [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) | | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — | -| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 94/113 (83%) | +| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `shipped` | 111/113 (98%) | | [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) | | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) | -| [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 23/153 (15%) | -| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 4/180 (2%) | +| [108-profiles-v3](./specs/108-profiles-v3/) | `in-flight` | 33/153 (22%) | +| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `in-flight` | 32/180 (18%) | | [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) | diff --git a/specs/006-oauth-extra-params/tasks.md b/specs/006-oauth-extra-params/tasks.md index 13fd31223..eee1a6800 100644 --- a/specs/006-oauth-extra-params/tasks.md +++ b/specs/006-oauth-extra-params/tasks.md @@ -133,7 +133,7 @@ Go monorepo structure: - [ ] T043 [P] [US3] Update runAuthLoginClientMode to display configuration preview in cmd/mcpproxy/auth_cmd.go - [ ] T044 [P] [US3] Add pre-browser-open summary (provider, scopes, PKCE, extra_params) in cmd/mcpproxy/auth_cmd.go -- [ ] T045 [P] [US3] Add authorization URL display with all parameters visible in cmd/mcpproxy/auth_cmd.go +- [x] T045 [P] [US3] Add authorization URL display with all parameters visible in cmd/mcpproxy/auth_cmd.go - [ ] T046 [P] [US3] Add post-success verification summary in cmd/mcpproxy/auth_cmd.go #### Debug Logging diff --git a/specs/105-agent-scope-hardening/tasks.md b/specs/105-agent-scope-hardening/tasks.md index 5ae50a7a1..bc82d6ac4 100644 --- a/specs/105-agent-scope-hardening/tasks.md +++ b/specs/105-agent-scope-hardening/tasks.md @@ -11,9 +11,9 @@ ## Phase 1: Setup (shared across PRs) -- [ ] T001 Add `agentCtx(allowed []string, perms []string, pin string) context.Context` and `adminCtx()` helpers with the `["*"]`-means-unrestricted rule documented, in `internal/server/scope_fixture_test.go` (new; consumed by every PR's tests; empty `AllowedServers` = deny-all) -- [ ] T002 [P] Add `startCountingUpstream(t, tools ...toolSpec) (*countingUpstream)` generalising `startCountingTargetTierUpstream` (`internal/server/mcp_call_tool_target_tier_test.go:335-411`) so any test can assert zero upstream calls, in `internal/server/scope_fixture_test.go` -- [ ] T003 [P] Amend spec text per plan §"Spec text amendments" (FR-008 empty-raw-name analogue; FR-003/004 D1 pinned zero-reach decision; SC-007 restated as the gate on FR-005/008/010 + inverted-assertion list; FR-009 unresolved identity refused for every caller per Edge Case `spec.md:107`; FR-002 anonymous callers; SC-005 two new named administrator exceptions — FR-005 profile-scoped admin on shared-index fallback (D3) and FR-001 caller-kind-first (D5)) in `specs/105-agent-scope-hardening/spec.md` +- [x] T001 Add `agentCtx(allowed []string, perms []string, pin string) context.Context` and `adminCtx()` helpers with the `["*"]`-means-unrestricted rule documented, in `internal/server/scope_fixture_test.go` (new; consumed by every PR's tests; empty `AllowedServers` = deny-all) +- [x] T002 [P] Add `startCountingUpstream(t, tools ...toolSpec) (*countingUpstream)` generalising `startCountingTargetTierUpstream` (`internal/server/mcp_call_tool_target_tier_test.go:335-411`) so any test can assert zero upstream calls, in `internal/server/scope_fixture_test.go` +- [x] T003 [P] Amend spec text per plan §"Spec text amendments" (FR-008 empty-raw-name analogue; FR-003/004 D1 pinned zero-reach decision; SC-007 restated as the gate on FR-005/008/010 + inverted-assertion list; FR-009 unresolved identity refused for every caller per Edge Case `spec.md:107`; FR-002 anonymous callers; SC-005 two new named administrator exceptions — FR-005 profile-scoped admin on shared-index fallback (D3) and FR-001 caller-kind-first (D5)) in `specs/105-agent-scope-hardening/spec.md` --- @@ -24,26 +24,26 @@ ### Failing tests (write first, confirm RED against the merge base) -- [ ] T004 [US2] FR009-G1: `setupQuarantineRuntime` manual trust, baseline `erase` approved, discover `[erase, ns:erase]` → expect `BlockedTools["ns:erase"]` and a pending record keyed `ns:erase`; full-tier `a`-only `call_tool_read a:ns:erase` → `TOOL_QUARANTINED`, zero upstream — `internal/runtime/tool_quarantine_identity_test.go` (new) + `internal/server/mcp_call_tool_target_tier_test.go` -- [ ] T005 [P] [US2] FR009-G2: `applyDifferentialToolUpdate` with `[erase, ns:erase]` → `GetToolsByServer` length 2; `SaveToolApproval(ns:erase, Disabled)` survives a rerun (HEAD deletes it) — `internal/runtime/lifecycle_identity_test.go` (new) + `internal/index/bleve_rawname_test.go` (new: distinct docIDs) -- [ ] T006 [P] [US2] FR009-G3: seed `{a, erase, pending}`, delete `ns:erase` record; full-tier ctx: `makeDirectModeHandler` entry `ns:erase` → IsError + zero upstream; `preflightApprovalReader.ToolApproval` → non-nil pending — `internal/server/mcp_direct_callability_test.go` + `internal/server/preflight_glue_test.go` -- [ ] T007 [P] [US2] FR009-G4: counting upstream `[erase]`; full-tier `a`-only AND admin `call_tool_read a:ghost` → `Permission denied`, zero upstream (D4: every caller; SC-005 named exception); sandbox `call_tool('a','ghost')` → `PERMISSION_DENIED` envelope; unknown-server branch unchanged (control) — `internal/server/mcp_call_tool_target_tier_test.go` + `internal/server/mcp_code_execution_scope_test.go` (new) -- [ ] T008 [P] [US2] FR009-G5: manual-trust server, quarantine on, StateView has `ns:erase`, storage has no record → refused; only `{a, erase, approved}` seeded → `a:ns:erase` refused; `quarantine_enabled=false` → unchanged — `internal/server/tool_gate_test.go` -- [ ] T009 [P] [US2] FR009-G6: generated tables at the spec shape (`spec.md:132`): retrieve **54 cells** = 3 permission sets × 3 target tiers × 3 `call_tool_*` variants × strict-intent on/off, pre-classified allowed / insufficient-permission / intent-mismatch; plus direct (permission set × target tier, driven through the registered handler in A — G's T104a re-drives the same table through `HandleMessage`, D15) and nested (via real sandbox, envelope asserted) tables; paired-name rows (`erase` approved, `ns:erase` config-denied via `disabled_tools` / unapproved); `{read,destructive}` rows; `auth.AdminContext()` control rows; counting oracle on every cell — `internal/server/scope_target_tier_matrix_test.go` (new) -- [ ] T010 [P] [US2] FR009-G7: regression cell `{read,destructive}` × write target refused on all three paths (passes today; pins the exact-match rule) — `internal/server/scope_target_tier_matrix_test.go` +- [x] T004 [US2] FR009-G1: `setupQuarantineRuntime` manual trust, baseline `erase` approved, discover `[erase, ns:erase]` → expect `BlockedTools["ns:erase"]` and a pending record keyed `ns:erase`; full-tier `a`-only `call_tool_read a:ns:erase` → `TOOL_QUARANTINED`, zero upstream — `internal/runtime/tool_quarantine_identity_test.go` (new) + `internal/server/mcp_call_tool_target_tier_test.go` +- [x] T005 [P] [US2] FR009-G2: `applyDifferentialToolUpdate` with `[erase, ns:erase]` → `GetToolsByServer` length 2; `SaveToolApproval(ns:erase, Disabled)` survives a rerun (HEAD deletes it) — `internal/runtime/lifecycle_identity_test.go` (new) + `internal/index/bleve_rawname_test.go` (new: distinct docIDs) +- [x] T006 [P] [US2] FR009-G3: seed `{a, erase, pending}`, delete `ns:erase` record; full-tier ctx: `makeDirectModeHandler` entry `ns:erase` → IsError + zero upstream; `preflightApprovalReader.ToolApproval` → non-nil pending — `internal/server/mcp_direct_callability_test.go` + `internal/server/preflight_glue_test.go` +- [x] T007 [P] [US2] FR009-G4: counting upstream `[erase]`; full-tier `a`-only AND admin `call_tool_read a:ghost` → `Permission denied`, zero upstream (D4: every caller; SC-005 named exception); sandbox `call_tool('a','ghost')` → `PERMISSION_DENIED` envelope; unknown-server branch unchanged (control) — `internal/server/mcp_call_tool_target_tier_test.go` + `internal/server/mcp_code_execution_scope_test.go` (new) +- [x] T008 [P] [US2] FR009-G5: manual-trust server, quarantine on, StateView has `ns:erase`, storage has no record → refused; only `{a, erase, approved}` seeded → `a:ns:erase` refused; `quarantine_enabled=false` → unchanged — `internal/server/tool_gate_test.go` +- [x] T009 [P] [US2] FR009-G6: generated tables at the spec shape (`spec.md:132`): retrieve **54 cells** = 3 permission sets × 3 target tiers × 3 `call_tool_*` variants × strict-intent on/off, pre-classified allowed / insufficient-permission / intent-mismatch; plus direct (permission set × target tier, driven through the registered handler in A — G's T104a re-drives the same table through `HandleMessage`, D15) and nested (via real sandbox, envelope asserted) tables; paired-name rows (`erase` approved, `ns:erase` config-denied via `disabled_tools` / unapproved); `{read,destructive}` rows; `auth.AdminContext()` control rows; counting oracle on every cell — `internal/server/scope_target_tier_matrix_test.go` (new) +- [x] T010 [P] [US2] FR009-G7: regression cell `{read,destructive}` × write target refused on all three paths (passes today; pins the exact-match rule) — `internal/server/scope_target_tier_matrix_test.go` ### Implementation -- [ ] T011 [US2] Add `RawName` to `config.ToolMetadata` with `CanonicalToolName`/`RawToolName` helpers in `internal/config/tool_identity.go` (new) and populate it from upstream tool names in `internal/upstream/core/client.go:394-397` -- [ ] T012 [US2] Producers use `RawName`: `checkToolApprovals` keys records by raw name (`internal/runtime/tool_quarantine.go:443-616,1216-1229`), `applyDifferentialToolUpdate`/`newToolsMap` stop collapsing and stop deleting exact-name records (`internal/runtime/lifecycle.go:673-790,975-980`); legacy collapsed record approves only its own raw name (one-shot, documented in code comment) -- [ ] T013 [US2] Bleve docID = `server:` + raw name (`internal/index/bleve.go:158-183,356-372,433`), `RawName` derived on read from the docID (not the stored `tool_name` / `full_tool_name`, which stay byte-identical scored fields — SC-005). **No index rebuild trigger** (adversarial review, 2026-09-14): the raw-keyed differential update self-heals a pre-upgrade collapsed doc on the first discovery (`TestApplyDifferentialToolUpdate_HealsPreUpgradeCollapsedDoc`), so a global wipe gated on the storage schema counter was pure cost and fired on every start -- [ ] T014 [US2] Reader: `lookupToolApproval` exact-wins + no-record-under-active-gate ⇒ pending (`internal/server/tool_gate.go:96-227`); direct callability and preflight read through it (`internal/server/mcp_direct_callability.go:136,192-201,253`, `internal/server/preflight_glue.go:400-411`); `ClassifyTool` consults the gate before returning Ready on nil approval (`internal/preflight/classify.go:98-100`) -- [ ] T015 [US2] Unresolved identity on a known server refuses **every** caller on retrieve (`internal/server/mcp.go:2283-2323`) and nested (`internal/server/mcp_code_execution.go:1251-1268` → explicit unresolved sentinel in the `ToolAnnotationFunc` contract, `internal/jsruntime/runtime.go:401-409` refuses on it); the unknown-server branch (`mcp_code_execution.go:987-997`) is untouched (D4); seed StateView in fixtures that register an upstream without one (`mcp_call_tool_trim_test.go:57`) -- [ ] T016 [US2] Docs: "Target tool tier" paragraph (exact-match permissions, annotation-less → read, unresolved → refused for scoped callers) replacing the "destructive implies both" claim in `docs/features/agent-tokens.md:140-150` +- [x] T011 [US2] Add `RawName` to `config.ToolMetadata` with `CanonicalToolName`/`RawToolName` helpers in `internal/config/tool_identity.go` (new) and populate it from upstream tool names in `internal/upstream/core/client.go:394-397` +- [x] T012 [US2] Producers use `RawName`: `checkToolApprovals` keys records by raw name (`internal/runtime/tool_quarantine.go:443-616,1216-1229`), `applyDifferentialToolUpdate`/`newToolsMap` stop collapsing and stop deleting exact-name records (`internal/runtime/lifecycle.go:673-790,975-980`); legacy collapsed record approves only its own raw name (one-shot, documented in code comment) +- [x] T013 [US2] Bleve docID = `server:` + raw name (`internal/index/bleve.go:158-183,356-372,433`), `RawName` derived on read from the docID (not the stored `tool_name` / `full_tool_name`, which stay byte-identical scored fields — SC-005). **No index rebuild trigger** (adversarial review, 2026-09-14): the raw-keyed differential update self-heals a pre-upgrade collapsed doc on the first discovery (`TestApplyDifferentialToolUpdate_HealsPreUpgradeCollapsedDoc`), so a global wipe gated on the storage schema counter was pure cost and fired on every start +- [x] T014 [US2] Reader: `lookupToolApproval` exact-wins + no-record-under-active-gate ⇒ pending (`internal/server/tool_gate.go:96-227`); direct callability and preflight read through it (`internal/server/mcp_direct_callability.go:136,192-201,253`, `internal/server/preflight_glue.go:400-411`); `ClassifyTool` consults the gate before returning Ready on nil approval (`internal/preflight/classify.go:98-100`) +- [x] T015 [US2] Unresolved identity on a known server refuses **every** caller on retrieve (`internal/server/mcp.go:2283-2323`) and nested (`internal/server/mcp_code_execution.go:1251-1268` → explicit unresolved sentinel in the `ToolAnnotationFunc` contract, `internal/jsruntime/runtime.go:401-409` refuses on it); the unknown-server branch (`mcp_code_execution.go:987-997`) is untouched (D4); seed StateView in fixtures that register an upstream without one (`mcp_call_tool_trim_test.go:57`) +- [x] T016 [US2] Docs: "Target tool tier" paragraph (exact-match permissions, annotation-less → read, unresolved → refused for scoped callers) replacing the "destructive implies both" claim in `docs/features/agent-tokens.md:140-150` ### Inverted pinned tests (gap-map §7) -- [ ] T017 [US2] Invert `internal/server/mcp_call_tool_target_tier_test.go:210-223,244-253,474` (ghost tool now refused for scoped callers); rebuild `internal/server/mcp_routing_test.go:306-447` direct cells on the real fixture with the counting oracle; resolve `extractToolName` callers in the three test files that still use it +- [x] T017 [US2] Invert `internal/server/mcp_call_tool_target_tier_test.go:210-223,244-253,474` (ghost tool now refused for scoped callers); rebuild `internal/server/mcp_routing_test.go:306-447` direct cells on the real fixture with the counting oracle; resolve `extractToolName` callers in the three test files that still use it ### Verification diff --git a/specs/108-profiles-v3/tasks.md b/specs/108-profiles-v3/tasks.md index d0b60a9e9..e04b0efb4 100644 --- a/specs/108-profiles-v3/tasks.md +++ b/specs/108-profiles-v3/tasks.md @@ -26,21 +26,21 @@ description: "Task list for Spec 108 — Profiles v3" **Goal**: v3 fields parse and validate; one pure decision predicate; nothing enforced yet, and the FR-009a gate keeps v3 policy fields rejected outside tests so no release admits an unenforced policy. **Independent test**: config + unit tests only. ### Failing tests -- [ ] T004 [P] [US1] `internal/config/profiles_v3_test.go`: legacy profile round-trips byte-identically; each v3 field parses; every fatal/warning row of data-model §1 returns the exact message; `anonymous_profile` unknown → warning (under the FR-009a test override); `IsLegacy()` is true for a `{name, servers, title, description}` profile and false as soon as any one of the six policy fields is set; **FR-009 guard**: reflection over `ProfileConfig`'s JSON tags equals exactly the FR-001 field set (fails on any added field, e.g. an owner/user/team/tenant field), run under the default and `-tags server` builds; **`switchable_to` round trip**: a profile saved with `switchable_to: []` through the real config save path (`Config.MarshalJSON`) reloads with a non-nil empty list and `IsLegacy()` false, and one saved without the field reloads nil (legacy) +- [x] T004 [P] [US1] `internal/config/profiles_v3_test.go`: legacy profile round-trips byte-identically; each v3 field parses; every fatal/warning row of data-model §1 returns the exact message; `anonymous_profile` unknown → warning (under the FR-009a test override); `IsLegacy()` is true for a `{name, servers, title, description}` profile and false as soon as any one of the six policy fields is set; **FR-009 guard**: reflection over `ProfileConfig`'s JSON tags equals exactly the FR-001 field set (fails on any added field, e.g. an owner/user/team/tenant field), run under the default and `-tags server` builds; **`switchable_to` round trip**: a profile saved with `switchable_to: []` through the real config save path (`Config.MarshalJSON`) reloads with a non-nil empty list and `IsLegacy()` false, and one saved without the field reloads nil (legacy) - [ ] T004a [P] [US1] `internal/config/profiles_rollout_gate_test.go` (FR-009a): with `profile.PolicyEnforcementReady=false` (the shipped value until 108-d) each of the six policy fields is a fatal validation error with the exact data-model §1 text, a legacy profile still loads, and a non-empty `anonymous_profile` (known or unknown profile) is fatal with its data-model §1 text through config load, `PATCH /config` and `POST /config/apply` (no build before 108-d confines anonymous callers completely, FR-009a); the test-only override admits them. **Override cannot ship** (zcode round 2): (1) `internal/profile/rollout_gate_ast_test.go` parses `internal/profile/rollout_gate.go` with `go/ast` and asserts `policyGateOpen` reads only `PolicyEnforcementReady`, `testOverride` and `testing.Testing()` (no `os.Getenv`/`os.LookupEnv`, no config or flag access, no other package-level var), that `PolicyEnforcementReady` is declared `const`, that the file carries no `//go:build` line, and — walking every non-`_test.go` Go file in the module — that `EnablePolicyForTest` and `testOverride` are referenced from no production file other than `rollout_gate.go`; (2) `internal/profile/testdata/overrideprobe/main.go` (a plain `main` package) calls `EnablePolicyForTest(nil)`; the test builds it with `go build` (not `go test`) and asserts it exits non-zero with the panic text, i.e. the override is unreachable outside a test binary; (3) a binary-level check builds `./cmd/mcpproxy` and runs `serve` against a scratch `--data-dir`/`--config` (high port, scratch HOME) containing a profile with `max_tier`, and then one with `anonymous_profile`, asserting exit code 4 and the data-model §1 texts on stderr — with `MCPPROXY_*` env vars set to arbitrary values, to show no runtime input opens the gate. Gate-closed cases and override users do not run in parallel (no `t.Parallel()`). 108-d's T055a asserts the constant is `true` and this test inverts -- [ ] T005 [P] [US1] `internal/profile/policy_test.go`: table over contracts/enforcement-matrix.md "Expected profile decisions" (all three profiles + classify row); deny beats allow on the fixture's overlapping pair (`github:get_secret_scanning_alert` matched by allow and deny → `denied_by_rule`); the allow rule naming `filesystem` yields the FR-007 warning and `server_not_in_profile`; allow cannot add a server; stale classification ignored for annotated tools; `IntrinsicTier(nil,false)` = destructive; `EffectiveUnannotated` defaults (FR-003); `EffectiveCodeExecution` defaults (FR-003a): unset → off under `max_tier` read and write, on (global decides) under destructive and legacy, explicit values win, and `Compile` stores the effective value -- [ ] T005a [P] [US1] `internal/profile/intrinsic_tier_contract_test.go` (one tier mapping across Specs 108 and 109, research D30): for every annotation fixture — nil, `{}`, `readOnlyHint:true`, explicit `readOnlyHint:false`, `destructiveHint:true`, `destructiveHint:true`+`readOnlyHint:true`, and every tool of the enforcement-matrix fixture — `IntrinsicTier(a, true).String()` equals `string(contracts.AnnotationTier(a))` and the int value is the one data-model §2's adapter names; `IntrinsicTier(a, false)` is `TierDestructive` for every `a`; an out-of-range `contracts.Tier` (`"unknown"`, `"bogus"`) passed through the adapter maps to `TierDestructive` (fail closed); a grep assertion fails if `internal/profile` reads `DestructiveHint`/`ReadOnlyHint` itself (no second mapping) -- [ ] T006 [P] [US1] `internal/profile/glob_test.go`: `*` only, anchored, case-sensitive, `server:tool` canonical identity incl. names containing `/` and `__` -- [ ] T007 [P] [US1] `internal/profile/contract_test.go`: enums decode/encode the T003 fixtures; fingerprint stable across field order, changes on any policy field -- [ ] T008 [US1] `internal/server/profile_index_policy_test.go`: compiled policy taken with its snapshot as one pair (Spec 105 D17 pattern); legacy profile → `IsLegacy()` fast path +- [x] T005 [P] [US1] `internal/profile/policy_test.go`: table over contracts/enforcement-matrix.md "Expected profile decisions" (all three profiles + classify row); deny beats allow on the fixture's overlapping pair (`github:get_secret_scanning_alert` matched by allow and deny → `denied_by_rule`); the allow rule naming `filesystem` yields the FR-007 warning and `server_not_in_profile`; allow cannot add a server; stale classification ignored for annotated tools; `IntrinsicTier(nil,false)` = destructive; `EffectiveUnannotated` defaults (FR-003); `EffectiveCodeExecution` defaults (FR-003a): unset → off under `max_tier` read and write, on (global decides) under destructive and legacy, explicit values win, and `Compile` stores the effective value +- [x] T005a [P] [US1] `internal/profile/intrinsic_tier_contract_test.go` (one tier mapping across Specs 108 and 109, research D30): for every annotation fixture — nil, `{}`, `readOnlyHint:true`, explicit `readOnlyHint:false`, `destructiveHint:true`, `destructiveHint:true`+`readOnlyHint:true`, and every tool of the enforcement-matrix fixture — `IntrinsicTier(a, true).String()` equals `string(contracts.AnnotationTier(a))` and the int value is the one data-model §2's adapter names; `IntrinsicTier(a, false)` is `TierDestructive` for every `a`; an out-of-range `contracts.Tier` (`"unknown"`, `"bogus"`) passed through the adapter maps to `TierDestructive` (fail closed); a grep assertion fails if `internal/profile` reads `DestructiveHint`/`ReadOnlyHint` itself (no second mapping) +- [x] T006 [P] [US1] `internal/profile/glob_test.go`: `*` only, anchored, case-sensitive, `server:tool` canonical identity incl. names containing `/` and `__` +- [x] T007 [P] [US1] `internal/profile/contract_test.go`: enums decode/encode the T003 fixtures; fingerprint stable across field order, changes on any policy field +- [x] T008 [US1] `internal/server/profile_index_policy_test.go`: compiled policy taken with its snapshot as one pair (Spec 105 D17 pattern); legacy profile → `IsLegacy()` fast path ### Implementation - [ ] T009 [US1] Extend `ProfileConfig` (`SwitchableTo *[]string`)/`ProfileToolRules`/`Config.AnonymousProfile` and `ValidateProfiles` in `internal/config/profiles.go`, `internal/config/config.go`; add `internal/profile/rollout_gate.go` with `const PolicyEnforcementReady = false`, the unexported `testOverride atomic.Bool`, `policyGateOpen()` (`PolicyEnforcementReady || (testOverride.Load() && testing.Testing())`) and `EnablePolicyForTest(tb testing.TB)` (panics unless `testing.Testing()`; sets the flag; `tb.Cleanup` clears it) — the only test-only override mechanism (FR-009a: no env var, config field, flag or build tag) — and the FR-009a fatal rules (the six policy fields **and** a non-empty `anonymous_profile`) in the validator, which reads the gate only through `policyGateOpen()` -- [ ] T010 [US1] Implement `internal/profile/policy.go` (`Tier` + `String()`, `IntrinsicTier` as the data-model §2 adapter over Spec 109-a's `contracts.AnnotationTier` — never its own annotation rule, so 108-a merges after 109-a — `CompiledPolicy`, `Compile`, `Decide`, `Fingerprint`) and `internal/profile/glob.go` -- [ ] T011 [P] [US1] Implement `internal/profile/contract.go` enums (tiers, unannotated, reasons, sources, block reasons, explain steps, fix actions, credential states, surfaces, warning codes) +- [x] T010 [US1] Implement `internal/profile/policy.go` (`Tier` + `String()`, `IntrinsicTier` as the data-model §2 adapter over Spec 109-a's `contracts.AnnotationTier` — never its own annotation rule, so 108-a merges after 109-a — `CompiledPolicy`, `Compile`, `Decide`, `Fingerprint`) and `internal/profile/glob.go` +- [x] T011 [P] [US1] Implement `internal/profile/contract.go` enums (tiers, unannotated, reasons, sources, block reasons, explain steps, fix actions, credential states, surfaces, warning codes) - [ ] T012 [US1] Compile policies into `profileIndex` in `internal/server/profile_tool.go` via the existing pre-publish observer - [ ] T013 [P] [US1] Add the effective-annotations seam `profile.EffectiveAnnotations(server, tool)` used by `IntrinsicTier`, documented as the #1323 `annotation_overrides` hook, in `internal/server/mcp_visibility.go` — a thin wrapper over `resolveExactToolIdentity` (the StateView identity seam every dispatch path uses); it is the **only** annotation source for enforcement, `SearchToolsAdmitted`'s predicate (T022), view-as and the explainer, and the index gains no annotation field (FR-011) -- [ ] T014 [P] Export enums to `frontend/src/types/contracts.ts` via `cmd/generate-types` +- [x] T014 [P] Export enums to `frontend/src/types/contracts.ts` via `cmd/generate-types` ### Verification - [ ] T015 quickstart §1 + recipe 108-a; all Spec 057/105 tests unchanged and green; `config-field-checklist` 5 wiring points audited (Validate vs ValidateDetailed, PersistableConfig, docs, swagger, contracts) @@ -94,7 +94,7 @@ description: "Task list for Spec 108 — Profiles v3" ### Implementation - [x] T034 [US2] Token fields (+ `PendingHash`, `PendingPrefix`, `RotationStartedAt`) + `AuthContext.TokenKind/ClientID/ProfileMode` in `internal/auth/agent_token.go`, `internal/auth/context.go`; `mcp_cli_` prefix generator/validator; the per-authentication invariant check (FR-021) in the shared token validation used by `mcpAuthMiddleware` (`internal/server/server.go`) and the REST middleware (`internal/httpapi/server.go`) - [ ] T035 [US2] Client-credential mint/revoke in `internal/storage/` agent-token store (single bbolt tx each); staged rotation: stage-1 pending secret + `agent_token_pending` hash index, idempotent finalize, rollback; the reconciler in `internal/runtime/clients_service.go` (startup + every clients/connect classification) -- [ ] T036 [US2] `ProfileResolution` + binding/anonymous tiers + `switchable_to` admission in `internal/server/profile_resolver.go` and `profileIndex.selectable` in `internal/server/profile_tool.go` +- [x] T036 [US2] `ProfileResolution` + binding/anonymous tiers + `switchable_to` admission in `internal/server/profile_resolver.go` and `profileIndex.selectable` in `internal/server/profile_tool.go` - [x] T037 [US2] Reject `kind=client` in `internal/httpapi` API-key/agent-token middleware (FR-023) - [x] T037a [US2] Test-only (no policy change, FR-016/D14): `internal/auth/server_op_client_test.go` — a `kind=client` `AuthContext` is non-admin and `AuthorizeServerOp` refuses every member of `agentDeniedServerOps` (incl. `add`, `add_from_registry`, `patch`, `restart`, `enable`, `disable`, `refresh`) exactly as for a regular agent token; only `list`/`tail_log` pass - [ ] T038 [US2] `internal/connect/connect.go`: credential source = minted client token via an injected `CredentialMinter`; `--keyless`; `credential_state` on `ClientStatus`; `internal/httpapi/connect.go` body `{profile, mode, keyless}`; `internal/httpapi/server.go`: wrap `GET /connect`, `GET /connect/{client}`, `GET /connect/{client}/preview` in `requireAdminReadMiddleware("Admin credentials required to read client connection status")` and replace the "Status/preview reads stay open" comment (FR-025a) — unless Spec 109-h merged first and already added exactly this gate (its FR-030a/T129), in which case keep it and add only `credential_state`; swagger `403` on the three reads. **Shared struct with Spec 109-b** (T034 there adds `DisplayPath`/`ReloadHint` to the same `ClientStatus`/`ConnectResult`; parallel PRs, no edge, so 108's security backend is not delayed by 109): whichever of 108-c / 109-b merges second keeps both field sets and runs both JSON-shape tests (T031 here, 109's T028) green — no field is dropped in the conflict resolution diff --git a/specs/109-ux-navigation-consistency/tasks.md b/specs/109-ux-navigation-consistency/tasks.md index 92c4bcb2d..e6a3ff35c 100644 --- a/specs/109-ux-navigation-consistency/tasks.md +++ b/specs/109-ux-navigation-consistency/tasks.md @@ -29,30 +29,30 @@ description: "Task list for Spec 109 — Navigation, scope filters and cross-sur **Goal**: patch-release fixes with no dependency on anything else. **Independent test**: vitest + Playwright a11y sweep + CLI goldens. ### Failing tests -- [ ] T006 [P] [US6] `frontend/tests/unit/router-home-default.spec.ts`: `/` renders the Overview panel; `/usage` renders Usage (interim N8) -- [ ] T007 [P] [US6] `frontend/tests/unit/z-index-scale.spec.ts` + a Playwright step in `e2e/web-ui-sweep/visual-a11y-sweep.spec.ts`: with Add Server open, `elementFromPoint` at the sidebar version row returns the modal or its backdrop (H4); the same check with `views/Repositories.vue`'s add-source dialog open (FR-055: that page lives until 109-j), and a vitest asserting none of Repositories.vue's three dialogs uses the `:open` binding any more -- [ ] T008 [P] [US6] `frontend/tests/unit/settings-naming.spec.ts`: sidebar label, route title, H1 and `document.title` say "Settings"; no emoji in tab labels; Server Edition tab hidden unless `status.edition === 'server'`, even when the config has a `server_edition` key -- [ ] T009 [P] [US4] `frontend/tests/unit/server-detail-tab-url.spec.ts`: a tab click calls `router.replace` with `?tab=` and keeps other query params; the same for Settings tabs -- [ ] T010 [P] [US2] `frontend/tests/unit/tools-approval-filter.spec.ts`: exactly Approved / New, needs review / Changed, needs review; no `awaiting` option; the "Needs review" stat links to `/review`, and the router resolves `/review` to a registered route (the interim redirect T026a in this PR until 109-g), never the 404 catch-all -- [ ] T011 [P] [US6] `frontend/tests/unit/profile-switcher-hidden.spec.ts`: header renders no ProfileSwitcher when `profilesStore.hasProfiles` is false +- [x] T006 [P] [US6] `frontend/tests/unit/router-home-default.spec.ts`: `/` renders the Overview panel; `/usage` renders Usage (interim N8) +- [x] T007 [P] [US6] `frontend/tests/unit/z-index-scale.spec.ts` + a Playwright step in `e2e/web-ui-sweep/visual-a11y-sweep.spec.ts`: with Add Server open, `elementFromPoint` at the sidebar version row returns the modal or its backdrop (H4); the same check with `views/Repositories.vue`'s add-source dialog open (FR-055: that page lives until 109-j), and a vitest asserting none of Repositories.vue's three dialogs uses the `:open` binding any more +- [x] T008 [P] [US6] `frontend/tests/unit/settings-naming.spec.ts`: sidebar label, route title, H1 and `document.title` say "Settings"; no emoji in tab labels; Server Edition tab hidden unless `status.edition === 'server'`, even when the config has a `server_edition` key +- [x] T009 [P] [US4] `frontend/tests/unit/server-detail-tab-url.spec.ts`: a tab click calls `router.replace` with `?tab=` and keeps other query params; the same for Settings tabs +- [x] T010 [P] [US2] `frontend/tests/unit/tools-approval-filter.spec.ts`: exactly Approved / New, needs review / Changed, needs review; no `awaiting` option; the "Needs review" stat links to `/review`, and the router resolves `/review` to a registered route (the interim redirect T026a in this PR until 109-g), never the 404 catch-all +- [x] T011 [P] [US6] `frontend/tests/unit/profile-switcher-hidden.spec.ts`: header renders no ProfileSwitcher when `profilesStore.hasProfiles` is false - [ ] T011a [US6] `scripts/test-api-e2e.sh` (the E2E gate every PR of both specs runs): the `cleanup` trap stops only the processes the script started — `$MCPPROXY_PID` and the launcher fixture's recorded PID — and the blanket `pkill -f "mcpproxy.*serve"` (line ~80) and `pkill -f "launcher-server.*--port 39933"` are removed, so the gate never kills the user's tray core or a parallel worktree's instance; check: `scripts/test-api-e2e-cleanup-check.sh` starts a decoy `mcpproxy serve` on another port with a scratch data dir, runs the E2E script, and asserts the decoy is still alive; quickstart §1 drops its pgrep precondition in the same PR - [ ] T012 [P] [US5] `frontend/tests/unit/add-to-mcpproxy-label.spec.ts`: Repositories result button reads "Add to MCPProxy", and after add "Added ✓ · Open" -- [ ] T013 [P] [US6] `frontend/tests/unit/usage-chart-ticks.spec.ts`: count charts have integer ticks; the "unresolved" group is titled "Calls to unknown tools" -- [ ] T014 [P] [US4] `internal/contracts/tier_test.go`: `AnnotationTier` table (destructive, explicit write, read, unannotated, nil) returning the exported `TierRead|TierWrite|TierDestructive|TierUnannotated` constants (never `TierUnknown`), which Spec 108-a's `IntrinsicTier` adapter switches on (its T005a; edge 108-a ← 109-a); `internal/httpapi/tools_tier_test.go`: `GET /tools` and `GET /servers/{id}/tools` rows carry `tier` -- [ ] T015 [P] [US4] `cmd/mcpproxy/tools_tier_test.go`: proves X11 first (the current `--risk read` over a fixture with read-annotated tools returns nothing, red), then asserts `--tier`/`--risk` filter on the backend `tier` and the `TIER` column -- [ ] T016 [P] [US4] `frontend/tests/unit/tools-tier.spec.ts`: column/filter labelled "Tier"; an unannotated tool shows "Unannotated" (not "write"). The Web URL alias `?risk=read` → `tier` is **not** asserted here: URL parameters are read through `useScopeQuery()`, which lands in 109-k, so that assertion lives in T111 (codex round 3; 109-a ships only the display value and the CLI `--risk` alias, T015/T023) -- [ ] T017 [P] [US2] `native/macos/MCPProxy/MCPProxyTests/ToolLabelsTests.swift`: approval-state labels and tier labels equal the terminology table +- [x] T013 [P] [US6] `frontend/tests/unit/usage-chart-ticks.spec.ts`: count charts have integer ticks; the "unresolved" group is titled "Calls to unknown tools" +- [x] T014 [P] [US4] `internal/contracts/tier_test.go`: `AnnotationTier` table (destructive, explicit write, read, unannotated, nil) returning the exported `TierRead|TierWrite|TierDestructive|TierUnannotated` constants (never `TierUnknown`), which Spec 108-a's `IntrinsicTier` adapter switches on (its T005a; edge 108-a ← 109-a); `internal/httpapi/tools_tier_test.go`: `GET /tools` and `GET /servers/{id}/tools` rows carry `tier` +- [x] T015 [P] [US4] `cmd/mcpproxy/tools_tier_test.go`: proves X11 first (the current `--risk read` over a fixture with read-annotated tools returns nothing, red), then asserts `--tier`/`--risk` filter on the backend `tier` and the `TIER` column +- [x] T016 [P] [US4] `frontend/tests/unit/tools-tier.spec.ts`: column/filter labelled "Tier"; an unannotated tool shows "Unannotated" (not "write"). The Web URL alias `?risk=read` → `tier` is **not** asserted here: URL parameters are read through `useScopeQuery()`, which lands in 109-k, so that assertion lives in T111 (codex round 3; 109-a ships only the display value and the CLI `--risk` alias, T015/T023) +- [x] T017 [P] [US2] `native/macos/MCPProxy/MCPProxyTests/ToolLabelsTests.swift`: approval-state labels and tier labels equal the terminology table ### Implementation -- [ ] T018 [US6] `frontend/src/router/index.ts`: `/` → `dashboardView: 'overview'` (interim; Home replaces it in 109-d) +- [x] T018 [US6] `frontend/src/router/index.ts`: `/` → `dashboardView: 'overview'` (interim; Home replaces it in 109-d) - [ ] T019 [US6] `frontend/src/assets/z-index.css` (or Tailwind theme tokens) with the navigation-map scale; `SidebarNav.vue` replaces the ad-hoc `z-40` on `drawer-side` with the `--z-sidebar` token (the version block carries no z-index of its own; it paints over a modal only because non-top-layer modals share the page stacking context); `AddServerModal.vue`, `ConnectModal.vue` and every other `` use `showModal()` (top layer) — named explicitly: `views/Repositories.vue`'s three `:open`-bound dialogs (`registry-required-input-dialog`, `registry-add-source-dialog`, `registry-delete-dialog`, lines ~374/426/524) switch to `showModal()`/`close()` driven by watchers on their flags (FR-055) - [ ] T020 [US6] `SidebarNav.vue`, `router/index.ts` (`meta.title`), `views/Settings.vue` (heading, line icons from the app's SVG icon set, Server Edition tab gated on `systemStore.status.edition === 'server'`) - [ ] T021 [US4] `views/ServerDetail.vue` and `views/Settings.vue`: a `watch(activeTab)` → `router.replace({query:{...route.query, tab}})`; read on mount (existing) -- [ ] T022 [US2] `views/Tools.vue`: approval options and stat link; macOS `Views/ToolsView.swift` labels; `cmd/mcpproxy/tools_cmd.go` `--approval` help text -- [ ] T023 [US4] `internal/contracts/tier.go` (`Tier`, the five exported constants, `AnnotationTier` — data-model §3; Spec 108-a builds on exactly this API); `internal/httpapi/server.go` `enrichServerTools` sets `tier`; `cmd/generate-types` exports `Tier`; `views/Tools.vue` uses `tool.tier` (delete `getRisk`); `tools_cmd.go` `--tier` + `--risk` alias + `TIER` column; macOS `ToolsView` uses `tier` +- [x] T022 [US2] `views/Tools.vue`: approval options and stat link; macOS `Views/ToolsView.swift` labels; `cmd/mcpproxy/tools_cmd.go` `--approval` help text +- [x] T023 [US4] `internal/contracts/tier.go` (`Tier`, the five exported constants, `AnnotationTier` — data-model §3; Spec 108-a builds on exactly this API); `internal/httpapi/server.go` `enrichServerTools` sets `tier`; `cmd/generate-types` exports `Tier`; `views/Tools.vue` uses `tool.tier` (delete `getRisk`); `tools_cmd.go` `--tier` + `--risk` alias + `TIER` column; macOS `ToolsView` uses `tier` - [ ] T024 [US6] `components/TopHeader.vue`: `v-if="profilesStore.hasProfiles"` on ProfileSwitcher (FR-057) -- [ ] T025 [US5] `views/Repositories.vue` button labels; macOS `Views/ServerBrowseView.swift` / `RegistriesView.swift` labels; `cmd/mcpproxy/registry_cmd.go` add message "Added to MCPProxy (quarantined for review)" -- [ ] T026 [US6] `views/Usage.vue` + `utils/usageFormat.ts`: integer tick option on count charts; label "Calls to unknown tools". Web only: macOS has no usage charts or "unresolved" group to relabel (its Dashboard shows only a token-distribution list), so the macOS label waits for the native macOS Usage view listed under Follow-ups (FR-074, parity row 22a) +- [x] T025 [US5] `views/Repositories.vue` button labels; macOS `Views/ServerBrowseView.swift` / `RegistriesView.swift` labels; `cmd/mcpproxy/registry_cmd.go` add message "Added to MCPProxy (quarantined for review)" +- [x] T026 [US6] `views/Usage.vue` + `utils/usageFormat.ts`: integer tick option on count charts; label "Calls to unknown tools". Web only: macOS has no usage charts or "unresolved" group to relabel (its Dashboard shows only a token-distribution list), so the macOS label waits for the native macOS Usage view listed under Follow-ups (FR-074, parity row 22a) - [ ] T026a [US2] Web `router/index.ts`: interim redirects `/review/:server` → `/servers/:server?tab=tools` and `/review` → `/servers?status=needs_review` (query kept). 109-a is the first PR to link to `/review` (T022), so it owns the redirects; the attention fix targets (109-d), the server card's Review button (109-e) and the Go tray review click (109-f) all merge after it and never land on the 404 catch-all before 109-g. Until 109-k, `/servers` ignores `?status=` and shows the unfiltered list. Test `frontend/tests/unit/review-interim-redirect.spec.ts` (both redirects, query kept, not the catch-all). 109-g replaces both routes with the real views (T091, T093) ### Verification @@ -65,19 +65,19 @@ description: "Task list for Spec 109 — Navigation, scope filters and cross-sur **Goal**: first run ends in something usable, and every connect says what to do next. **Independent test**: US7 scenarios 1–6; US3 scenario 4 (hint + short path). ### Failing tests -- [ ] T028 [P] [US3] `internal/connect/reload_hint_test.go`: every supported `ClientDef` has a non-empty `ReloadHint` and `ClientInfoNames`; `ClientStatus.display_path` replaces the home prefix with `~` (Windows: `%USERPROFILE%` → `~`); `ConnectResult` carries both +- [x] T028 [P] [US3] `internal/connect/reload_hint_test.go`: every supported `ClientDef` has a non-empty `ReloadHint` and `ClientInfoNames`; `ClientStatus.display_path` replaces the home prefix with `~` (Windows: `%USERPROFILE%` → `~`); `ConnectResult` carries both - [x] T029 [P] [US7] `internal/httpapi/onboarding_usable_test.go`: `has_usable_server` false while all servers are quarantined, disabled, health-unusable (including transport-connected `sign_in_required`), or have no approved tool; true after approve; `usable_servers` lists names; `incomplete_tab_count` uses it; `client_connected_at` is set by a successful connect; `internal/storage/onboarding_update_test.go`: concurrent `POST /onboarding/mark` and connect-success writes through `UpdateOnboardingState` under `-race` both survive (a separate get + save pair, as the mark handler does today, can drop one) -- [ ] T030 [P] [US7] `internal/configimport/preview_summary_test.go`: preview rows carry `summary` (command + args or URL + auth), `tags` (`local process`, `remote`, `needs secret`, `oauth`), and per-env `secret_like` / `empty_or_placeholder` +- [x] T030 [P] [US7] `internal/configimport/preview_summary_test.go`: preview rows carry `summary` (command + args or URL + auth), `tags` (`local process`, `remote`, `needs secret`, `oauth`), and per-env `secret_like` / `empty_or_placeholder` - [ ] T031 [P] [US7] vitest `onboarding-wizard-import-rows.spec.ts` (second line + tags), `onboarding-wizard-client-paths.spec.ts` (`~/` paths, tooltip, icons), `onboarding-wizard-usable-completion.spec.ts` (Servers step incomplete until usable), `onboarding-wizard-verify-hints.spec.ts` (reload hints; prompts only from usable servers or "Approve a server first"), `onboarding-wizard-import-footer.spec.ts` (one primary, quarantine checkbox + confirm on uncheck, Settings summary line), `telemetry-banner-wizard.spec.ts` (not rendered while the wizard is open; one line in the final step) -- [ ] T032 [P] [US3] `cmd/mcpproxy/connect_hint_test.go` golden: `Config: ~/…` and `Next: `; `--list` `CONFIG PATH` column shows `display_path` +- [x] T032 [P] [US3] `cmd/mcpproxy/connect_hint_test.go` golden: `Config: ~/…` and `Next: `; `--list` `CONFIG PATH` column shows `display_path` - [ ] T033 [P] [US3] `MCPProxyTests/ConnectResultHintTests.swift`: the connect result view model renders the reload hint and `display_path` ### Implementation -- [ ] T034 [US3] `internal/connect/clients.go`: `ClientInfoNames`, `ReloadHint` per client (verify each alias against a live `initialize` from that client where available; record the source in a comment); `connect.go` `DisplayPath`, `ReloadHint` on `ClientStatus`/`ConnectResult`. **Shared struct with Spec 108-c** (its T038 adds `credential_state` to the same `ClientStatus`; parallel PRs, no edge): whichever of 109-b / 108-c merges second keeps all three fields and runs both JSON-shape tests (T028 here, 108's T031) green -- [ ] T035 [US7] `internal/httpapi/onboarding.go` (response DTO `OnboardingStateResponse`): `has_usable_server`, `usable_servers`; `internal/storage/models.go` (persisted `storage.OnboardingState`): the `client_connected_at` map (set in the connect success path); `internal/storage/bbolt.go` + `manager.go`: a read-modify-write helper `UpdateOnboardingState(func(*OnboardingState) error)` running in one bbolt update transaction, used by `handleMarkOnboardingState` (today a separate get + save) and the connect success path, so concurrent writers never drop each other's fields (T029) -- [ ] T036 [US7] `internal/configimport`: summary/tags/secret-like fields on the preview response (`internal/httpapi/import.go`) +- [x] T034 [US3] `internal/connect/clients.go`: `ClientInfoNames`, `ReloadHint` per client (verify each alias against a live `initialize` from that client where available; record the source in a comment); `connect.go` `DisplayPath`, `ReloadHint` on `ClientStatus`/`ConnectResult`. **Shared struct with Spec 108-c** (its T038 adds `credential_state` to the same `ClientStatus`; parallel PRs, no edge): whichever of 109-b / 108-c merges second keeps all three fields and runs both JSON-shape tests (T028 here, 108's T031) green +- [x] T035 [US7] `internal/httpapi/onboarding.go` (response DTO `OnboardingStateResponse`): `has_usable_server`, `usable_servers`; `internal/storage/models.go` (persisted `storage.OnboardingState`): the `client_connected_at` map (set in the connect success path); `internal/storage/bbolt.go` + `manager.go`: a read-modify-write helper `UpdateOnboardingState(func(*OnboardingState) error)` running in one bbolt update transaction, used by `handleMarkOnboardingState` (today a separate get + save) and the connect success path, so concurrent writers never drop each other's fields (T029) +- [x] T036 [US7] `internal/configimport`: summary/tags/secret-like fields on the preview response (`internal/httpapi/import.go`) - [ ] T037 [US7] `components/OnboardingWizard.vue`: rows, paths, completion rule, Verify hints/prompts, footer, security summary line; `components/TelemetryBanner.vue` gated on wizard state; `stores/onboarding.ts` `hasUsableServer` -- [ ] T038 [P] [US3] `cmd/mcpproxy/connect_cmd.go` output; macOS `Views/ConnectClientView.swift` + `API/ConnectModels.swift` (`reloadHint`, `displayPath`); check `Views/FirstRunDialog.swift` shows the telemetry notice in-flow and add one line if it does not. **Shared files with Spec 108-c** (its T041 adds `--profile/--lock/--switchable/--keyless` and the masked `mcp_cli_` credential line to `connect_cmd.go`; its T042 adds profile, mode and the masked credential to `ConnectClientView.swift`; parallel PRs, no edge): whichever of 109-b / 108-c merges second keeps both sides, and both goldens (`connect_hint_test.go` T032 here, 108's `connect_profile_flags_test.go`) and both Swift suites (`ConnectResultHintTests` T033 here, 108's `ConnectClientModelTests`) stay green +- [x] T038 [P] [US3] `cmd/mcpproxy/connect_cmd.go` output; macOS `Views/ConnectClientView.swift` + `API/ConnectModels.swift` (`reloadHint`, `displayPath`); check `Views/FirstRunDialog.swift` shows the telemetry notice in-flow and add one line if it does not. **Shared files with Spec 108-c** (its T041 adds `--profile/--lock/--switchable/--keyless` and the masked `mcp_cli_` credential line to `connect_cmd.go`; its T042 adds profile, mode and the masked credential to `ConnectClientView.swift`; parallel PRs, no edge): whichever of 109-b / 108-c merges second keeps both sides, and both goldens (`connect_hint_test.go` T032 here, 108's `connect_profile_flags_test.go`) and both Swift suites (`ConnectResultHintTests` T033 here, 108's `ConnectClientModelTests`) stay green - [ ] T039 swagger regen for the new fields ### Verification @@ -90,14 +90,14 @@ description: "Task list for Spec 109 — Navigation, scope filters and cross-sur **Goal**: one status vocabulary with no "healthy" for unusable servers. **Independent test**: US4 scenarios 1, 3. ### Failing tests -- [ ] T041 [P] [US4] `internal/health/status_test.go`: every T001 row, including the `RetryStopped` branch (3b, GH #1145) → `status: error`, `actions: ["restart","view_logs"]`, `action: restart` unchanged, the `pending auth` parked branch (#1013) and the call-time OAuth branch (4b, `CallTimeOAuthRequired`, MCP-2084) → `status: sign_in_required`, `usable: false`, `actions: ["login"]`, and the quarantined + transport-fault row; `action == actions[0]`; quarantined+OAuth → `["login","approve"]` and `action: "login"` (the one declared value change, FR-010; every other existing `calculator_test.go` input keeps its `action`); `level` values unchanged for every existing input (compatibility); macOS `isOAuthLoginRequired` true for that fixture -- [ ] T042 [P] [US4] `frontend/tests/unit/health-status-labels.spec.ts`: label table from `contracts.ts`; card status line and detail Configuration → Health row render `status` labels; SC-003 forbidden words for every `usable=false` fixture -- [ ] T043 [P] [US4] `cmd/mcpproxy/upstream_list_status_test.go` golden: STATUS = status label; ACTION = the existing CLI command hint keyed on `actions[0]` (`auth login --server=`, `upstream restart `, …, per contracts/health-vocabulary.md#cli; unchanged strings for every existing action); the GH #938 held-tools suffix and ACTION fallback kept (`Online · 2 held`, `tools list --server=`); `--status needs_review` filter; **repeatable**: `--status ready --status needs_review` and `--status ready,needs_review` both list exactly the union of the matching rows (FR-015); JSON carries `status/usable/actions` and every legacy field unchanged +- [x] T041 [P] [US4] `internal/health/status_test.go`: every T001 row, including the `RetryStopped` branch (3b, GH #1145) → `status: error`, `actions: ["restart","view_logs"]`, `action: restart` unchanged, the `pending auth` parked branch (#1013) and the call-time OAuth branch (4b, `CallTimeOAuthRequired`, MCP-2084) → `status: sign_in_required`, `usable: false`, `actions: ["login"]`, and the quarantined + transport-fault row; `action == actions[0]`; quarantined+OAuth → `["login","approve"]` and `action: "login"` (the one declared value change, FR-010; every other existing `calculator_test.go` input keeps its `action`); `level` values unchanged for every existing input (compatibility); macOS `isOAuthLoginRequired` true for that fixture +- [x] T042 [P] [US4] `frontend/tests/unit/health-status-labels.spec.ts`: label table from `contracts.ts`; card status line and detail Configuration → Health row render `status` labels; SC-003 forbidden words for every `usable=false` fixture +- [x] T043 [P] [US4] `cmd/mcpproxy/upstream_list_status_test.go` golden: STATUS = status label; ACTION = the existing CLI command hint keyed on `actions[0]` (`auth login --server=`, `upstream restart `, …, per contracts/health-vocabulary.md#cli; unchanged strings for every existing action); the GH #938 held-tools suffix and ACTION fallback kept (`Online · 2 held`, `tools list --server=`); `--status needs_review` filter; **repeatable**: `--status ready --status needs_review` and `--status ready,needs_review` both list exactly the union of the matching rows (FR-015); JSON carries `status/usable/actions` and every legacy field unchanged - [ ] T044 [P] [US4] `MCPProxyTests/HealthVocabularyTests.swift`: decode T001; the row label, tray label and color for each status; forbidden words -- [ ] T045 [P] [US4] `internal/server/upstream_servers_health_status_test.go`: MCP `upstream_servers list` includes the new fields (output golden only) +- [x] T045 [P] [US4] `internal/server/upstream_servers_health_status_test.go`: MCP `upstream_servers list` includes the new fields (output golden only) ### Implementation -- [ ] T046 [US4] `internal/health/constants.go` (`Status*`), `calculator.go` (status/usable/actions per health-vocabulary.md), `internal/contracts/types.go` fields +- [x] T046 [US4] `internal/health/constants.go` (`Status*`), `calculator.go` (status/usable/actions per health-vocabulary.md), `internal/contracts/types.go` fields - [ ] T047 [US4] `cmd/generate-types/main.go`: `HealthStatusValue`, `HEALTH_STATUS_LABELS`, `HEALTH_ACTION_LABELS`; regen `frontend/src/types/contracts.ts` - [ ] T048 [US4] Web: `utils/health.ts` label helpers; `ServerCard.vue` status line text from `status` (layout redesign is 109-e); `ServerDetail.vue` Configuration → Health row shows the label + detail, never `level` - [ ] T049 [US4] macOS: `API/Models.swift` `HealthStatus.status/usable/actions` + `statusLabel`; `ServersView`, `ServerDetailView`, `MCPProxyApp.swift` tray rows use the label (tray badge logic unchanged)