put the open plan in the url, and stop /api/plans reading outside its folder

/plans/:name, no redirect guard: the bare route is 'no plan open', which is a
real state, so the auto-select-first effect is deleted rather than turned into
a Navigate. The picker stays a native select — chrome for one document, not a
master list — but it navigates instead of setting state.

Reading the server route for this turned up a path traversal: hono
percent-decodes params, so GET /api/plans/..%2F..%2Fsecret reached
join(plansDir, '../../secret.md'). basename() the param.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:41:55 +00:00
co-authored by Claude Opus 5
parent 32aa1e7cc3
commit 1dc0eddde0
4 changed files with 55 additions and 40 deletions
+5 -5
View File
@@ -37,14 +37,14 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did
**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/email/:emailId`,
`/browser/:tabId`, `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName`, `/task-logs/:id`,
`/activity/:id`. (`/projects/:id`
`/activity/:id`, `/plans/:name`. (`/projects/:id`
was in this list; the Projects feature was removed end to end on 2026-07-30, so the route is gone with it. H3
and navigate-site 22 below still name it — they are the record of work that happened, not of code that exists.)
**Section routes** (a param that names a view rather than an entity): `/settings/:page/:section`,
`/soulseek/:section`, `/headscale/:section`, `/wallet/:section`, `/system-monitor/:scope`.
**Query-param screens:** `/music?path=`, `/files?path=`, `/code-editor?file=`.
**Genuinely flat, still:** `/` `/plans` `/terminal` `/desktop` `/qr-transfer`. Of these only `/plans` selects
something (M9); the other four have nothing addressable to put in an address.
**Genuinely flat, and correctly so:** `/` `/terminal` `/desktop` `/qr-transfer` — none of them selects
anything, so there is nothing to put in an address.
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list
was written.
@@ -85,7 +85,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
| ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. |
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
| M9 | `Screens/Dashboard/Plans/index.tsx:36` | a plan document | `/plans/:name` | native `<select>` → local state; a plan is a real addressable doc. Have the select `navigate()` or use a link list. |
| ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. |
| ~~M10~~ | `SystemMonitor/ScopeList.tsx` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | **Done.** Route pair + `Navigate` guard; the scope buttons are `NavLink`s and `useMonitorScope` reads `useParams` instead of the channel. The Dock's hand-rolled `isActive` is a `startsWith`, so its highlight survives the redirect. |
**Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each
@@ -225,7 +225,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
- [x] **Music**`/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.**
- [x] **Soulseek**`/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
- [ ] **M9** Plans → `/plans/:name` (`Plans/index.tsx:36`).
- [x] **M9** Plans → `/plans/:name`; the auto-select-first effect is gone (the bare route is a real state: no plan open), and the `<select>` navigates instead of setting state. It stays a `<select>` — a native `<option>` cannot be an anchor, so this one has no cmd-click and the doc should not pretend otherwise; it is chrome for a single document, not a master list. Reading the route also turned up a path traversal in `GET /api/plans/:name` (hono percent-decodes params, so `..%2F..%2Fx` walked out of `plansDir`) — fixed with `basename()`. **Needs runtime test.**
### Phase 4 — Polish + borderline decisions
- [ ] Dock + Header + mobile sheet → react-router `<NavLink>` for active state; drop hand-rolled `isActive` (`Dock.tsx`, `Header.tsx`).