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:
@@ -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`).
|
||||
|
||||
@@ -54,6 +54,7 @@ export function App() {
|
||||
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
|
||||
<Route path="/plans" element={<Dashboard.Plans />} />
|
||||
<Route path="/plans/:name" element={<Dashboard.Plans />} />
|
||||
<Route path="/files" element={<Dashboard.FilesScreen />} />
|
||||
<Route path="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
|
||||
@@ -1,61 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
|
||||
/**
|
||||
* A plan is a markdown document on disk, so it gets an address: `/plans/:name`. No redirect guard — the
|
||||
* bare route is "no plan open" and a name that no longer exists gets the empty pane, not a rewritten URL.
|
||||
*
|
||||
* The picker stays a native `<select>` rather than becoming a link list. It is chrome for one document,
|
||||
* not a master list, and a `<select>` is the right control for that on a phone; it navigates instead of
|
||||
* setting state, which is what M4 was actually about.
|
||||
*/
|
||||
export const Plans = () => {
|
||||
const client = useClient();
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const selected = useParams<{ name: string }>().name ?? null;
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['plans'],
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (plans.length > 0 && !selectedPlan) {
|
||||
setSelectedPlan(plans[0]!);
|
||||
}
|
||||
}, [plans, selectedPlan]);
|
||||
|
||||
const { data: content = '' } = useQuery<string>({
|
||||
queryKey: ['plans', selectedPlan],
|
||||
queryFn: () => client.getText(`/plans/${selectedPlan}`),
|
||||
enabled: !!selectedPlan,
|
||||
queryKey: ['plans', selected],
|
||||
queryFn: () => client.getText(`/plans/${encodeURIComponent(selected!)}`),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4">
|
||||
<Card className="flex-1 overflow-hidden">
|
||||
{/* Header with plan selector */}
|
||||
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
||||
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
|
||||
{plans.length > 1 && (
|
||||
<select
|
||||
value={selectedPlan ?? ''}
|
||||
onChange={(ev) => setSelectedPlan(ev.target.value)}
|
||||
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
|
||||
>
|
||||
{plans.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<Card className="flex-1 overflow-hidden">
|
||||
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
||||
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
|
||||
{plans.length > 0 && (
|
||||
<select
|
||||
value={selected ?? ''}
|
||||
onChange={(ev) => navigate(`/plans/${encodeURIComponent(ev.target.value)}`)}
|
||||
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
|
||||
>
|
||||
{/* Only while nothing is chosen: it disappears once you pick, so it can never be picked back. */}
|
||||
{!selected && <option value="">Select a plan…</option>}
|
||||
{plans.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Markdown content */}
|
||||
<div className="overflow-y-auto h-full p-6">
|
||||
<div className="overflow-y-auto h-full p-6">
|
||||
{selected ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none prose-td:text-sm prose-th:text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/50">
|
||||
{plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { basename, join } from 'node:path';
|
||||
|
||||
const plansDir = join(process.cwd(), 'plans');
|
||||
|
||||
@@ -17,7 +17,11 @@ plansRouter.get('/', async (ctx) => {
|
||||
});
|
||||
|
||||
plansRouter.get('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
// A single path segment is not a single *name*: hono percent-decodes params, so `..%2F..%2Fsecret`
|
||||
// arrives here as `../../secret` and `join` would happily walk out of plansDir. Verified against hono
|
||||
// directly. Auth limits the blast radius to the owner's own token, and the `.md` suffix limits it to
|
||||
// markdown, but "read any .md on the disk" is not what this endpoint is for.
|
||||
const name = basename(ctx.req.param('name'));
|
||||
const filePath = join(plansDir, `${name}.md`);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) return ctx.text('Not found', 404);
|
||||
|
||||
Reference in New Issue
Block a user