the file browser can actually read a member's home, and plans is gone
"This folder is empty" was a lie. The five seeded directories were sitting there and the platform's readdir raised EACCES: a member's home is 700 and owned by them, which is correct for a shell and locks out the file browser, which runs inside the platform process. /ls caught the error and returned an empty listing, so a refusal looked exactly like data. Two doors, two boundaries, and that is the point rather than a compromise. The terminal and the agent RUN AS the member and the kernel is the boundary there. The file browser acts on the member's behalf from inside the platform, which already applies its own containment and is the owner's process on the owner's machine — it can read anything via sudo regardless. Giving it access describes who is doing the work. Done with named POSIX ACLs, because it has to hold in BOTH directions: a file the platform writes must be editable by the member and vice versa. Mode bits cannot say that — whichever party is neither owner nor group lands in "other", and widening "other" opens the home to every account on the box. A shared group fails the same way, since both parties would have to be in it and that puts every member in a group that can read every other member's home. Two named entries plus `d:` defaults grant exactly two users and are inherited by whatever either side creates, whatever their umask. Verified: platform lists the home, member edits a platform-written file, platform edits a member-written file, and a SECOND member is refused on both ls and cat. /ls now distinguishes EACCES from a missing directory. An empty result is data and must never be how a refusal looks. acl joins the core packages in setup.sh — the alternative is an account that provisions and then cannot list its own home. Also: the file browser's own useTasks/useAgents fired /tasks, /agents and both category endpoints on every render, which is where the last four 403s came from — they are the context menu's Run Task and agent submenus, execution-only. Gated. And plans is deleted: router, screen, routes, dock tile, hook, page title and its capability. It read markdown from <repo>/plans, which does not exist. Fresh-install Permissions is now Files alone, with Terminal to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,6 @@ export function App() {
|
||||
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
||||
<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 />} />
|
||||
|
||||
@@ -169,7 +169,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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 navigate = useNavigate();
|
||||
const selected = useParams<{ name: string }>().name ?? null;
|
||||
|
||||
const { data: plans = [] } = useQuery<string[]>({
|
||||
queryKey: ['plans'],
|
||||
queryFn: () => client.get<string[]>('/plans'),
|
||||
});
|
||||
|
||||
const { data: content = '' } = useQuery<string>({
|
||||
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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/50">
|
||||
{plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,6 @@ export * from './AppStore';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
export * from './Plans';
|
||||
export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { usePlans } from 'state/usePlans';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useModels } from 'state/useModels';
|
||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||
import { useColorModeSync } from './useThemeSync';
|
||||
|
||||
// Caches the shell wants warm before anything asks for them. Called once from App.tsx for its effects —
|
||||
// the return value has never been read.
|
||||
export const useInitialData = () => {
|
||||
const { plans } = usePlans();
|
||||
const { settings } = useSettings();
|
||||
useModels();
|
||||
useAccessPolicy();
|
||||
useColorModeSync();
|
||||
|
||||
return { plans, settings };
|
||||
return { settings };
|
||||
};
|
||||
|
||||
@@ -43,7 +43,6 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
|
||||
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
|
||||
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
|
||||
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
|
||||
];
|
||||
|
||||
export function titleForPath(pathname: string): string {
|
||||
|
||||
Reference in New Issue
Block a user