diff --git a/scripts/setup.sh b/scripts/setup.sh index a14828a5..943ed839 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -230,6 +230,21 @@ case $PM in ;; esac +# acl — setfacl/getfacl, needed by per-user Linux accounts. +# +# A member's home is 700 and owned by them, which is right for a shell and locks the platform out of the +# file browser. Named ACL entries are what let both act on the same files without opening the home to every +# account on the box; mode bits cannot express it in both directions. Core rather than a profile extra +# because the alternative is an account that provisions and then cannot list its own home. +case $PM in + apt) + if dpkg -s acl &>/dev/null 2>&1; then skip "acl"; else CORE_PKGS+=(acl); fi + ;; + pacman|dnf|yum) + if has setfacl; then skip "acl"; else CORE_PKGS+=(acl); fi + ;; +esac + if [ ${#CORE_PKGS[@]} -gt 0 ]; then install_pkg "${CORE_PKGS[@]}" ok "Installed: ${CORE_PKGS[*]}" diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 5de30db8..4bd300b2 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -53,8 +53,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 0149be26..14598762 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, diff --git a/src/apps/officer-web/Screens/Dashboard/Plans/index.tsx b/src/apps/officer-web/Screens/Dashboard/Plans/index.tsx deleted file mode 100644 index cf0d2fc2..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Plans/index.tsx +++ /dev/null @@ -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 `` 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({ - queryKey: ['plans'], - queryFn: () => client.get('/plans'), - }); - - const { data: content = '' } = useQuery({ - queryKey: ['plans', selected], - queryFn: () => client.getText(`/plans/${encodeURIComponent(selected!)}`), - enabled: !!selected, - }); - - return ( -
- -
- Plans - {plans.length > 0 && ( - - )} -
- -
- {selected ? ( -
- - {content} - -
- ) : ( -

- {plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'} -

- )} -
-
-
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index 96d05f54..301345de 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -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'; diff --git a/src/apps/officer-web/state/useInitialData.ts b/src/apps/officer-web/state/useInitialData.ts index 7349f867..a2e0b6a1 100644 --- a/src/apps/officer-web/state/useInitialData.ts +++ b/src/apps/officer-web/state/useInitialData.ts @@ -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 }; }; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 70957e56..5dfa6876 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -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 { diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index b8c2eccf..f2b2f6c0 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -195,7 +195,19 @@ router.get('/ls', async (ctx) => { let names: string[]; try { names = await readdir(absPath); - } catch { + } catch (ex) { + // A missing directory resets the browser to the root, which is the right answer for a stale path. + // + // A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and + // theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this + // returned an empty listing — the UI said "This folder is empty" over five directories that existed. + // An empty result is data; it should never be how a refusal looks. + if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') { + throw errors.FORBIDDEN( + `Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` + + `lists are missing — reprovision the Linux account from Settings → User management.`, + ); + } return ctx.json({ path: '/', entries: [], reset: true }); } const entries = await Promise.all( diff --git a/src/servers/api/plans/plans.ts b/src/servers/api/plans/plans.ts deleted file mode 100644 index 0200068b..00000000 --- a/src/servers/api/plans/plans.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createRouter } from '../../create-router'; -import { readdir } from 'node:fs/promises'; -import { basename, join } from 'node:path'; - -const plansDir = join(process.cwd(), 'plans'); - -export const plansRouter = createRouter(); - -plansRouter.get('/', async (ctx) => { - try { - const files = await readdir(plansDir); - const plans = files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', '')); - return ctx.json(plans); - } catch { - return ctx.json([]); - } -}); - -plansRouter.get('/:name', async (ctx) => { - // 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); - const text = await file.text(); - return ctx.text(text); -}); diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 3d52d268..6f8db18b 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -253,14 +253,6 @@ export const CAPABILITIES: Capability[] = [ routes: ['/dashboards'], personal: ['/'], }, - { - key: 'plans', - label: 'Plans', - description: 'Plan documents', - kind: 'app', - api: ['/plans'], - routes: ['/plans'], - }, // ── execution: never grantable ────────────────────────────────────────────────────────────────── { diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 2a83e517..9e699534 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -8,7 +8,6 @@ import { landingPageDataRouter } from './api/landing-page-data/landing-page-data import { waitlistRouter } from './api/waitlist/waitlist'; import { usersRouter } from './api/users/users-router'; import { apiKeysRouter } from './api/api-keys/router'; -import { plansRouter } from './api/plans/plans'; import { skillsRouter } from './api/skills/skills'; import { tasksRouter } from './api/tasks/tasks'; import { agentsRouter } from './api/agents/agents'; @@ -168,7 +167,6 @@ protectedRouter.use(userMiddleware); const PROTECTED_MOUNTS: [prefix: string, router: ReturnType][] = [ ['/server-settings', serverSettingsRouter], ['/users', usersRouter], - ['/plans', plansRouter], ['/skills', skillsRouter], ['/tasks', tasksRouter], ['/agents', agentsRouter], diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index fac767a9..26d75a34 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -319,6 +319,46 @@ export async function confineUserTree(params: { const close = await run(['sudo', '-n', 'chmod', '700', home]); if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` }; + // ── And then let the PLATFORM in, by ACL ── + // + // A 700 home owned by the member locks out the service user, which is correct for a shell and fatal for + // the file browser: it runs inside the platform process, so `readdir` returned EACCES and `/ls` reported + // "This folder is empty" over five directories that were sitting right there. Observed 2026-08-11. + // + // These are two different doors and they need different boundaries. The terminal and the agent RUN AS the + // member, and there the kernel is the boundary. The file browser acts on the member's behalf from inside + // the platform, which already applies its own containment (`resolveUserPath`) and which is the owner's + // process on the owner's machine — it can read anything via sudo regardless. Giving it access is not a + // hole, it is the honest description of who is doing the work. + // + // Why ACLs and not mode bits or a group. It has to work in BOTH directions: a file the platform writes + // must be editable by the member, and a file the member writes must be editable by the platform. Mode + // bits cannot express that — whichever of the two is neither owner nor group ends up as "other", and + // widening "other" would open the home to every account on the box. A shared group fails the same way + // once you notice both parties would have to be in it, which would put every member in a group that can + // read every other member's home. Named ACL entries grant exactly two users, and the `d:` defaults are + // inherited by everything created afterwards, by either party, whatever their umask. + const serviceUid = process.getuid?.(); + if (serviceUid !== undefined) { + const entries = [ + `u:${serviceUid}:rwx`, + `u:${params.uid}:rwx`, + `d:u:${serviceUid}:rwx`, + `d:u:${params.uid}:rwx`, + ].flatMap((entry) => ['-m', entry]); + // After the chmod, never before: chmod recomputes the ACL mask and would clamp entries set earlier. + const acl = await run(['sudo', '-n', 'setfacl', '-R', ...entries, home]); + if (!acl.ok) { + return { + ok: false, + error: + `could not set access control lists on ${home}: ${acl.out}. ` + + `The file browser cannot read a member's home without them. ` + + `Install the acl package (apt install acl) and retry.`, + }; + } + } + return { ok: true }; } catch (ex) { return { ok: false, error: ex instanceof Error ? ex.message : String(ex) }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts index 2d461f06..4c93a8d7 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useAgents.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; +import { useCapabilities } from 'hooks/useCapabilities'; import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks'; export type AgentSummary = { @@ -17,15 +18,21 @@ export type AgentGroup = { category: string; agents: AgentSummary[] }; // wrong thing about what happens when you click. export const useAgents = () => { const client = useClient(); + // Agents are the `items` capability — skills, tools and agents on the owner's disk — and running one + // starts a chat session, which is `chat`. Both are execution-only, so a member gets no agent submenu. + const { can } = useCapabilities(); + const allowed = can('items'); const { data: agents = [] } = useQuery({ queryKey: ['agents'], + enabled: allowed, queryFn: () => client.get('/agents'), staleTime: 60_000, }); const { data: categoryOrder = [] } = useQuery({ queryKey: ['agent-categories'], + enabled: allowed, queryFn: () => client.get('/agents/categories'), staleTime: 60_000, }); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index 9951cedd..799351f9 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { useClient } from 'hooks/useClient'; +import { useCapabilities } from 'hooks/useCapabilities'; export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; @@ -70,15 +71,22 @@ export const matchesTrigger = ( export const useTasks = () => { const client = useClient(); + // `tasks` is `kind: 'execution'`: a task run executes a script as the server owner. A member browsing + // their own files has a file browser, not a task runner — so the context menu simply has no Run Task + // submenu, and these two requests are not made. Without the guard they 403'd on every Files render. + const { can } = useCapabilities(); + const allowed = can('tasks'); const { data: tasks = [] } = useQuery({ queryKey: ['tasks'], + enabled: allowed, queryFn: () => client.get('/tasks'), staleTime: 60_000, }); const { data: categoryOrder = [] } = useQuery({ queryKey: ['task-categories'], + enabled: allowed, queryFn: () => client.get('/tasks/categories'), staleTime: 60_000, }); diff --git a/src/workspaces/state/src/index.ts b/src/workspaces/state/src/index.ts index f6f89715..84669995 100644 --- a/src/workspaces/state/src/index.ts +++ b/src/workspaces/state/src/index.ts @@ -8,7 +8,6 @@ export { useAccessPolicy } from './useAccessPolicy'; export { useClaudeSessions, useChatPwds } from './useClaudeSessions'; export type { ClaudeSessionSummary, ClaudePwd } from './useClaudeSessions'; export { useRecentModels } from './useRecentModels'; -export { usePlans } from './usePlans'; export { useLandingPage } from './useLandingPage'; export { useServerSettings } from './useServerSettings'; export { useServerEnvironment } from './useServerEnvironment'; diff --git a/src/workspaces/state/src/usePlans.ts b/src/workspaces/state/src/usePlans.ts deleted file mode 100644 index 6e837702..00000000 --- a/src/workspaces/state/src/usePlans.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useAuth } from 'hooks/useAuth'; -import { useClient } from 'hooks/useClient'; -import { useCapabilities } from 'hooks/useCapabilities'; -import { useQuery } from '@tanstack/react-query'; - -export const usePlans = () => { - const client = useClient(); - const { isAuthenticated } = useAuth(); - const { can } = useCapabilities(); - - const { data: plans = [] } = useQuery({ - queryKey: ['PLANS'], - enabled: isAuthenticated && can('plans'), - queryFn: () => client.get('/plans'), - }); - - const getPlan = (name: string) => client.getText(`/plans/${name}`); - - return { plans, getPlan }; -};