From 044aacf4d59b48a07ae518fcd21129d33653ceb2 Mon Sep 17 00:00:00 2001 From: brunorezio Date: Sat, 25 Jul 2026 22:14:08 +0100 Subject: [PATCH] remove the dead multi-user surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Officer is single-user: the server owner is the only account, created once by /auth/bootstrap. Everything that existed to serve additional users was unreachable, so it is gone rather than left looking like it does something. Accounts: drop the invite / resend-invite / delete / list-users routes and the Users settings screen, the inert /auth/signup handler, and the account verification chain it fed (verify, resend-verification, VerifyScreen, the UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token survives for password resets only, and now requires a reset-password token rather than accepting any signed JWT. Roles: drop the users.role column and the four-value USER_ROLES enum. The permissions table granted every role identical methods, and every role === 'Super Admin' check was permanently true. The JWT no longer carries a role claim. Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected only for non-Super-Admin users, so it never ran. It was also not a usable agent jail as written — --share-net, the project root (with .env) bound read-only, and runuser dropping to the server's own uid. Rebuilding it for agent containment would be a different construction, and git history keeps this one. getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the owner's real login home, which is what terminals, chats and task runs use. Co-Authored-By: Claude Opus 5 --- scripts/migrate-auth-to-pg.ts | 1 - src/apps/officer-web/App.tsx | 17 +- .../Screens/Authentication/Verify.tsx | 202 --- .../VerifyScreen/VerifyScreen.tsx | 111 -- .../Authentication/VerifyScreen/index.tsx | 1 - .../VerifyScreen/useVerifyScreen.ts | 104 -- .../Screens/Authentication/index.tsx | 10 +- .../Screens/Dashboard/CapabilityPage.tsx | 4 +- .../Screens/Dashboard/Layout/Dock.tsx | 20 +- .../Dashboard/Layout/Header/UserMenu.tsx | 40 +- .../Screens/Dashboard/Settings/AISettings.tsx | 62 +- .../Settings/IntegrationsSettings/index.tsx | 34 +- .../Dashboard/Settings/SystemSettings.tsx | 8 +- .../UserSettings/InviteUserDialog.tsx | 86 - .../Settings/UserSettings/UsersTable.tsx | 95 - .../Dashboard/Settings/UserSettings/index.tsx | 46 - .../Screens/Dashboard/Settings/index.tsx | 1 - .../migrations/0006_absurd_dormammu.sql | 1 + .../migrations/meta/0006_snapshot.json | 1575 +++++++++++++++++ .../officer_db/migrations/meta/_journal.json | 7 + .../officer_db/src/queries/integrations.ts | 26 +- src/databases/officer_db/src/schema/auth.ts | 27 +- src/server.tsx | 9 +- src/servers/_middlewares/index.ts | 1 - .../_middlewares/super-admin-middleware.ts | 8 - src/servers/_middlewares/user-middleware.ts | 19 - src/servers/api/auth/auth.ts | 8 +- src/servers/api/auth/bootstrap.ts | 5 +- src/servers/api/auth/passkey-router.ts | 4 +- src/servers/api/auth/resend-verification.ts | 28 - src/servers/api/auth/signin.ts | 4 +- src/servers/api/auth/signup.ts | 36 - src/servers/api/auth/verify-token.ts | 19 +- src/servers/api/auth/verify.ts | 61 - src/servers/api/chat/claude-sessions.ts | 2 +- src/servers/api/chat/types.ts | 2 - src/servers/api/chat/websocket.ts | 34 +- src/servers/api/cliamp/websocket.ts | 17 +- src/servers/api/desktop/rest.ts | 2 +- src/servers/api/desktop/vnc-config.ts | 10 +- src/servers/api/desktop/websocket.ts | 3 +- src/servers/api/file-browser/router.ts | 161 +- src/servers/api/integrations/integrations.ts | 23 +- src/servers/api/tasks/execute-script.ts | 61 +- src/servers/api/tasks/pipeline-executor.ts | 279 ++- src/servers/api/tasks/pipeline-job-manager.ts | 100 +- src/servers/api/tasks/pipeline-jobs-routes.ts | 9 +- src/servers/api/tasks/task-executor.ts | 104 +- src/servers/api/terminal/websocket.ts | 67 +- src/servers/api/users/provision.ts | 7 - src/servers/api/users/users-router.ts | 105 +- src/servers/channels/routes.ts | 21 - src/servers/channels/send-and-await.ts | 2 - src/servers/channels/send-claude-code.ts | 2 - src/servers/data-path.ts | 7 +- src/servers/hono.ts | 4 +- src/servers/sidecar/claude/claude-manager.ts | 38 +- src/servers/sidecar/claude/user-instance.ts | 76 +- src/servers/sidecar/protocol.ts | 3 - src/servers/sidecar/sandbox.ts | 126 -- src/servers/sidecar/vnc/vnc-manager.ts | 4 +- src/workspaces/definitions/src/index.ts | 1 - src/workspaces/emailer/emails/UserInvite.tsx | 33 - src/workspaces/emailer/emails/VerifyAdmin.tsx | 21 - .../emailer/emails/VerifyRegistration.tsx | 21 - src/workspaces/hooks/src/useAuth/types.ts | 6 - src/workspaces/hooks/src/useAuth/useAuth.ts | 28 - .../src/apps/Chat/ChatPanelWrapper.tsx | 14 +- .../Chat/EmbeddableChat/EmbeddableChat.tsx | 1 - .../Chat/EmbeddableChat/useEmbeddableChat.ts | 13 +- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 4 - .../src/apps/Desktop/DesktopWrapper.tsx | 15 +- .../src/apps/FileBrowser/CliampPanel.tsx | 10 +- .../components/TaskRunnerModal.tsx | 602 ++++--- .../FileBrowserApp/useFileBrowserApp.ts | 8 +- .../src/apps/Preview/PreviewContext.tsx | 1 - .../src/apps/Preview/PreviewHeader.tsx | 6 +- .../src/apps/Preview/PreviewProvider.tsx | 58 +- .../src/apps/Terminal/HostTerminalWrapper.tsx | 16 +- .../officerdev/src/apps/Terminal/Terminal.tsx | 25 +- .../src/apps/Terminal/TerminalWrapper.tsx | 14 +- .../src/apps/Terminal/useTerminalMode.ts | 11 - .../officerdev/src/hooks/useChat.ts | 2 - .../officerdev/src/hooks/useDock.ts | 14 +- src/workspaces/state/src/useModels.ts | 9 +- 85 files changed, 2761 insertions(+), 2121 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Authentication/Verify.tsx delete mode 100644 src/apps/officer-web/Screens/Authentication/VerifyScreen/VerifyScreen.tsx delete mode 100644 src/apps/officer-web/Screens/Authentication/VerifyScreen/index.tsx delete mode 100644 src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/InviteUserDialog.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/UsersTable.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/index.tsx create mode 100644 src/databases/officer_db/migrations/0006_absurd_dormammu.sql create mode 100644 src/databases/officer_db/migrations/meta/0006_snapshot.json delete mode 100644 src/servers/_middlewares/super-admin-middleware.ts delete mode 100644 src/servers/api/auth/resend-verification.ts delete mode 100644 src/servers/api/auth/signup.ts delete mode 100644 src/servers/api/auth/verify.ts delete mode 100644 src/servers/sidecar/sandbox.ts delete mode 100644 src/workspaces/emailer/emails/UserInvite.tsx delete mode 100644 src/workspaces/emailer/emails/VerifyAdmin.tsx delete mode 100644 src/workspaces/emailer/emails/VerifyRegistration.tsx delete mode 100644 src/workspaces/officerdev/src/apps/Terminal/useTerminalMode.ts diff --git a/scripts/migrate-auth-to-pg.ts b/scripts/migrate-auth-to-pg.ts index accc4c03..5f1f469e 100644 --- a/scripts/migrate-auth-to-pg.ts +++ b/scripts/migrate-auth-to-pg.ts @@ -77,7 +77,6 @@ async function migrate() { .values({ email: u.email, password: u.password, - role: u.role as 'Member' | 'Admin' | 'Owner' | 'Super Admin', status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted', name: u.name, username: u.username, diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 3a65be7d..b8d40a93 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -7,7 +7,7 @@ import { useServerEnvironment } from 'state/useServerEnvironment'; import { useInitialData } from '@/state/useInitialData'; export function App() { - const { isLoading, isAuthenticated, user } = useAuth(); + const { isLoading, isAuthenticated } = useAuth(); const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings(); useServerEnvironment(); useInitialData(); @@ -20,7 +20,6 @@ export function App() { } /> - } /> } /> } /> } /> @@ -40,14 +39,7 @@ export function App() { } /> } /> } /> - : } - /> - : } - /> + } /> } /> } /> } /> @@ -72,10 +64,7 @@ export function App() { } /> } /> } /> - : } - /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Authentication/Verify.tsx b/src/apps/officer-web/Screens/Authentication/Verify.tsx deleted file mode 100644 index 516998d9..00000000 --- a/src/apps/officer-web/Screens/Authentication/Verify.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/Card'; -import { cn } from '@/lib/utils'; -import { useMounted } from 'hooks/useMounted'; -import { useForm } from 'hooks/useForm'; -import { useClient } from 'hooks/useClient'; - -export const Verify = () => { - const isMounted = useMounted(); - const navigate = useNavigate(); - const client = useClient('/api/auth'); - const { state, formRef, update } = useForm({ email: '' }); - - const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? ''); - const [tokenStatus, setTokenStatus] = useState('loading'); - const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('verify'); - const [isSubmitting, setIsSubmitting] = useState(false); - - const verifyToken = async () => { - if (!verificationCode) return; - try { - const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode }); - if (data.ok) { - setTokenStatus('valid'); - setFlow(data.flow); - requestAnimationFrame(() => update({ email: data.email })); - } else { - setTokenStatus('invalid'); - } - } catch { - setTokenStatus('invalid'); - } - }; - - useEffect(() => { - if (!isMounted) return; - if (!verificationCode) { - setTokenStatus('invalid'); - return; - } - verifyToken(); - }, [isMounted]); - - const isValid = validateForm(state); - - const handleSubmit = async (ev: React.FormEvent) => { - ev.preventDefault(); - if (!isValid || isSubmitting) return; - - setIsSubmitting(true); - try { - if (flow === 'bootstrap') { - await client.post('/bootstrap', { - token: verificationCode, - name: state.name, - username: state.username, - password: state.password, - confirmPassword: state.confirmPassword, - }); - } else { - await client.post('/verify', { - verificationCode, - name: state.name, - username: state.username, - password: state.password, - confirmPassword: state.confirmPassword, - }); - } - toast.success('Account created. Please sign in.'); - navigate('/'); - } catch (ex) { - const error = ex as { message?: string }; - toast.error(error.message || 'Failed to create account. Please try again.'); - } finally { - setIsSubmitting(false); - } - }; - - const loading = tokenStatus === 'loading'; - const invalid = tokenStatus === 'invalid'; - const hideForm = loading || invalid; - - return ( - <> - {loading && ( - -
Verifying...
-
- )} - - {invalid && ( - -
-
Invalid Link
-
This verification link is invalid or has expired.
-
-
- )} - - -
-
- {flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'} -
-
- {flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'} -
-
- -
- - - - - - -
- - - -
- -
- -
-
-
- - ); -}; - -const validateForm = (state: Partial) => { - const { name, username, password, confirmPassword } = state; - if (!name || !username || !password || !confirmPassword) return false; - if (password !== confirmPassword) return false; - return true; -}; - -type VerifyFormState = { - email?: string; - name?: string; - username?: string; - password?: string; - confirmPassword?: string; -}; - -type TokenStatus = 'loading' | 'valid' | 'invalid'; diff --git a/src/apps/officer-web/Screens/Authentication/VerifyScreen/VerifyScreen.tsx b/src/apps/officer-web/Screens/Authentication/VerifyScreen/VerifyScreen.tsx deleted file mode 100644 index b226a60f..00000000 --- a/src/apps/officer-web/Screens/Authentication/VerifyScreen/VerifyScreen.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/Card'; -import { cn } from '@/lib/utils'; -import { useVerifyScreen } from './useVerifyScreen'; - -export const VerifyScreen = () => { - const { formRef, isValid, tokenStatus, flow, isSubmitting, handleSubmit } = useVerifyScreen(); - const loading = tokenStatus === 'loading'; - const invalid = tokenStatus === 'invalid'; - const hideform = loading || invalid; - - return ( - <> - {loading && ( - -
Verifying...
-
- )} - - {invalid && ( - -
-
Invalid Link
-
This verification link is invalid or has expired.
-
-
- )} - - -
-
- {flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'} -
-
- {flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'} -
-
- -
- - - - - - -
- - - -
- -
- -
-
-
- - ); -}; diff --git a/src/apps/officer-web/Screens/Authentication/VerifyScreen/index.tsx b/src/apps/officer-web/Screens/Authentication/VerifyScreen/index.tsx deleted file mode 100644 index 9a19e608..00000000 --- a/src/apps/officer-web/Screens/Authentication/VerifyScreen/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './VerifyScreen'; diff --git a/src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts b/src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts deleted file mode 100644 index 2ab312e0..00000000 --- a/src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; -import { useMounted } from 'hooks/useMounted'; -import { useForm } from 'hooks/useForm'; -import { useClient } from 'hooks/useClient'; - -export const useVerifyScreen = () => { - const isMounted = useMounted(); - const navigate = useNavigate(); - const client = useClient('/api/auth'); - const { state, formRef, update } = useForm({ email: '' }); - - const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? ''); - const [tokenStatus, setTokenStatus] = useState('loading'); - const [flow, setFlow] = useState<'bootstrap' | 'invite' | 'verify'>('bootstrap'); - const [isSubmitting, setIsSubmitting] = useState(false); - - const verifyToken = async () => { - if (!verificationCode) return; - try { - const data = await client.post<{ ok: boolean; email: string; flow: 'bootstrap' | 'invite' | 'verify' }>('/verify-token', { verificationCode }); - if (data.ok) { - setTokenStatus('valid'); - setFlow(data.flow); - requestAnimationFrame(() => update({ email: data.email })); - } else { - setTokenStatus('invalid'); - } - } catch { - setTokenStatus('invalid'); - } - }; - - useEffect(() => { - if (!isMounted) return; - if (!verificationCode) { - setTokenStatus('invalid'); - return; - } - verifyToken(); - }, [isMounted]); - - const handleSubmit = async (ev: React.FormEvent) => { - ev.preventDefault(); - if (!isValid || isSubmitting) return; - - setIsSubmitting(true); - try { - if (flow === 'bootstrap') { - await client.post('/bootstrap', { - token: verificationCode, - name: state.name, - username: state.username, - password: state.password, - confirmPassword: state.confirmPassword, - }); - } else { - await client.post('/verify', { - verificationCode, - name: state.name, - username: state.username, - password: state.password, - confirmPassword: state.confirmPassword, - }); - } - toast.success('Account created. Please sign in.'); - navigate('/'); - } catch (ex) { - const error = ex as { message?: string }; - toast.error(error.message || 'Failed to create account. Please try again.'); - } finally { - setIsSubmitting(false); - } - }; - - const isValid = flow === 'bootstrap' ? validateBootstrap(state) : validateInvite(state); - - return { formRef, state, isValid, tokenStatus, flow, isSubmitting, handleSubmit }; -}; - -type VerifyFormState = { - email?: string; - name?: string; - username?: string; - password?: string; - confirmPassword?: string; -}; - -type TokenStatus = 'loading' | 'valid' | 'invalid'; - -const validateBootstrap = (state: Partial) => { - const { name, username, password, confirmPassword } = state; - if (!name || !username || !password || !confirmPassword) return false; - if (password !== confirmPassword) return false; - return true; -}; - -const validateInvite = (state: Partial) => { - const { name, username, password, confirmPassword } = state; - if (!name || !username || !password || !confirmPassword) return false; - if (password !== confirmPassword) return false; - return true; -}; diff --git a/src/apps/officer-web/Screens/Authentication/index.tsx b/src/apps/officer-web/Screens/Authentication/index.tsx index 949ea65e..4188b168 100644 --- a/src/apps/officer-web/Screens/Authentication/index.tsx +++ b/src/apps/officer-web/Screens/Authentication/index.tsx @@ -1,14 +1,6 @@ import { AuthenticationLayout } from './Layout'; import { LandingPage } from './LandingPage'; import { SignoutScreen } from './Signout'; -import { VerifyScreen } from './VerifyScreen'; import { ForgotPassword, ResetPassword } from './ForgotPassword'; -export { - AuthenticationLayout, - LandingPage, - SignoutScreen, - VerifyScreen, - ForgotPassword, - ResetPassword, -}; +export { AuthenticationLayout, LandingPage, SignoutScreen, ForgotPassword, ResetPassword }; diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index 22c948dc..072967ab 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -135,7 +135,7 @@ inputs: # optional — parameters the user fills in before runni - No triggers → task is only runnable from the Automation page ## Notes -- Tasks run inside the user's sandboxed container +- Tasks run on the host as the server owner - The markdown body after the frontmatter should contain step-by-step instructions for the agent `; @@ -243,7 +243,7 @@ inputs: - \`object\` — JSON object ## Notes -- Tools run inside the user's sandboxed container +- Tools run on the host as the server owner - The \`name\` field uses snake_case (this is the function name the agent calls) - The \`label\` field is the human-readable display name - Mark parameters as \`optional: true\` when they have sensible defaults diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 3529c143..87a2fe2e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -7,7 +7,6 @@ export type DockItem = { to: string; icon: LucideIcon; color: string; - role?: string; }; type DockProps = { @@ -109,8 +108,21 @@ export const Dock = ({ items, className }: DockProps) => { ); }; - -import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone, Workflow } from 'lucide-react'; +import { + Home, + MessageCircle, + FileText, + FolderOpen, + Code, + LayoutGrid, + ScrollText, + FolderKanban, + Monitor, + Mail, + Globe, + MonitorSmartphone, + Workflow, +} from 'lucide-react'; export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, @@ -124,7 +136,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [ { label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' }, { label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, - { label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899', role: 'Super Admin' }, + { label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' }, { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, ]; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index cddfaaed..c04b1b88 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import * as Dropdown from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { User, Users, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon, Bot } from 'lucide-react'; +import { User, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon, Bot } from 'lucide-react'; import { useAuth } from 'hooks/useAuth'; import { useTranslation } from '@/lib/i18n'; import { useColorMode } from '@/components/ui/ThemeProvider'; @@ -16,8 +16,6 @@ export function UserMenu() { const { settings, saveSettings } = useSettings(); if (isLoading) return null; - const isAdmin = user?.role !== 'Member'; - const toggleColorMode = () => { const next = colorMode === 'dark' ? 'light' : 'dark'; setColorMode(next); @@ -43,22 +41,18 @@ export function UserMenu() { {t('header.userMenu.profile')} - {isAdmin && ( - <> - - - - {t('header.userMenu.systemSettings')} - - - - - - {t('header.userMenu.resources')} - - - - )} + + + + {t('header.userMenu.systemSettings')} + + + + + + {t('header.userMenu.resources')} + + @@ -77,14 +71,6 @@ export function UserMenu() { Apps - {user?.role === 'Super Admin' && ( - - - - Users - - - )} {colorMode === 'dark' ? : } diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx index 42530835..f2c6d1d2 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/AISettings.tsx @@ -104,12 +104,8 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => { // --- My Models Section (per-user hidden models) --- function MyModelsSection() { - const { user } = useAuth(); const { settings, saveSettings } = useSettings(); - const allModels = useModels(); - const policyModels = useVisibleModels(); - const isAdmin = user?.role !== 'Member'; - const visibleModels = isAdmin ? allModels : policyModels; + const visibleModels = useModels(); const [activeProvider, setActiveProvider] = useUserState('my-models-provider', ''); const hiddenModels = settings.chat.hiddenModels ?? []; @@ -411,12 +407,7 @@ function MemberModelsSection() { // // ... full implementation for future use // } -// --- Build groups based on role --- - function useAISettingsGroups(): SettingsSectionGroup[] { - const { user } = useAuth(); - const isAdmin = user?.role !== 'Member'; - return useMemo(() => { const modelsGroup: SettingsSectionGroup = { label: 'Models', @@ -429,17 +420,13 @@ function useAISettingsGroups(): SettingsSectionGroup[] { description: 'Show or hide models for yourself', content: , }, - ...(isAdmin - ? [ - { - key: 'member-models', - icon: Eye, - title: 'Member Models', - description: 'Enable or disable models for members', - content: , - }, - ] - : []), + { + key: 'member-models', + icon: Eye, + title: 'Channel Models', + description: 'Models reachable from Telegram, WhatsApp and Discord', + content: , + }, ], }; @@ -457,25 +444,22 @@ function useAISettingsGroups(): SettingsSectionGroup[] { ], }; - if (isAdmin) { - const providersGroup: SettingsSectionGroup = { - label: 'Providers', - icon: Terminal, - sections: [ - { - key: 'ai-harnesses', - icon: Terminal, - title: 'Providers', - description: 'Remote and local AI providers', - content: , - }, - ], - }; - return [providersGroup, modelsGroup, defaultsGroup]; - } + const providersGroup: SettingsSectionGroup = { + label: 'Providers', + icon: Terminal, + sections: [ + { + key: 'ai-harnesses', + icon: Terminal, + title: 'Providers', + description: 'Remote and local AI providers', + content: , + }, + ], + }; - return [modelsGroup, defaultsGroup]; - }, [isAdmin]); + return [providersGroup, modelsGroup, defaultsGroup]; + }, []); } const layout: LayoutNode = { diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx index aa123bc2..1753fe4c 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx @@ -108,9 +108,7 @@ const personalSections: SettingsSection[] = [ ]; const IntegrationsSidebar = () => { - const { user } = useAuth(); - const isSuperAdmin = user?.role === 'Super Admin'; - const [tab, setTab] = useGlobal(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal'); + const [tab, setTab] = useGlobal(TAB_KEY, 'enterprise'); const sections = tab === 'enterprise' ? enterpriseSections : personalSections; return ( @@ -121,29 +119,25 @@ const IntegrationsSidebar = () => { Integrations - {isSuperAdmin && ( -
- - - - Enterprise - - - Personal - - - -
- )} +
+ + + + Enterprise + + + Personal + + + +
); }; const IntegrationsContent = () => { - const { user } = useAuth(); - const isSuperAdmin = user?.role === 'Super Admin'; - const [tab] = useGlobal(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal'); + const [tab] = useGlobal(TAB_KEY, 'enterprise'); const sections = tab === 'enterprise' ? enterpriseSections : personalSections; return ; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index d506ca09..290315d4 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -124,13 +124,7 @@ const SystemTerminalPanel = () => { - + ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/InviteUserDialog.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/InviteUserDialog.tsx deleted file mode 100644 index 88c949e2..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/InviteUserDialog.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useState } from 'react'; -import { toast } from 'sonner'; -import { useClient } from 'hooks/useClient'; -import { useForm } from 'hooks/useForm'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; - -type InviteUserDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess: () => void; -}; - -type InviteFormState = { - email: string; - role: string; -}; - -const ROLES = ['Member', 'Admin', 'Owner'] as const; - -export const InviteUserDialog = ({ open, onOpenChange, onSuccess }: InviteUserDialogProps) => { - const client = useClient(); - const { state, formRef, update, reset } = useForm({ email: '', role: 'Member' }); - const [loading, setLoading] = useState(false); - - const handleSubmit = async (ev: React.FormEvent) => { - ev.preventDefault(); - const email = state.email?.trim(); - if (!email) return; - - setLoading(true); - try { - await client.post('/users/invite', { email, role: state.role }); - toast.success(`Invitation sent to ${email}`); - reset(); - onOpenChange(false); - onSuccess(); - } catch { - toast.error('Failed to send invitation'); - } finally { - setLoading(false); - } - }; - - const handleRoleChange = (value: string) => { - update((prev) => ({ ...prev, role: value })); - }; - - return ( - - - - Invite User - Send an invitation email to a new user. - -
-
- - -
-
- - -
- -
-
-
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/UsersTable.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/UsersTable.tsx deleted file mode 100644 index 9f040aa3..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/UsersTable.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import type { UserSelect } from 'officerdb/types'; -import { useClient } from 'hooks/useClient'; -import { useAuth } from 'hooks/useAuth'; -import { DataTable, useDataControl } from '@/components/DataTable'; -import { SearchInput } from '@/components/SearchInput'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { UserPlus, Trash2, MailPlus } from 'lucide-react'; -import { InviteUserDialog } from './InviteUserDialog'; - -type SafeUser = Omit; - -export const UsersTable = () => { - const client = useClient(); - const { user: currentUser } = useAuth(); - const [inviteOpen, setInviteOpen] = useState(false); - - const { data: users, refetch } = useQuery({ - queryKey: ['users'], - queryFn: () => client.get('/users'), - }); - - const dataController = useDataControl(users ?? []); - - const handleResendInvite = async (user: SafeUser) => { - try { - await client.post(`/users/${user.id}/resend-invite`); - toast.success(`Invitation resent to ${user.email}`); - } catch { - toast.error('Failed to resend invitation'); - } - }; - - const handleDelete = async (user: SafeUser) => { - if (!confirm(`Delete ${user.name || user.email}?`)) return; - try { - await client.delete(`/users/${user.id}`); - toast.success('User deleted'); - refetch(); - } catch { - toast.error('Failed to delete user'); - } - }; - - return ( -
-
- - -
- - - dataController={dataController} - pageSize={20} - columns={[ - { field: 'name', label: 'Name', sortKey: 'name' }, - { field: 'email', label: 'Email', sortKey: 'email' }, - { field: 'role', label: 'Role', sortKey: 'role' }, - { - field: 'status', - label: 'Status', - sortKey: 'status', - format: ({ value }) => ( - {value as string} - ), - }, - { - label: '', - format: ({ item }) => - item.id !== currentUser?.id ? ( -
- {item.status === 'Invited' && ( - - )} - -
- ) : null, - }, - ]} - /> - - -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/index.tsx deleted file mode 100644 index e890a281..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserSettings/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useMemo } from 'react'; -import { Users } from 'lucide-react'; -import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout } from 'officerdev'; - -import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel'; -import { UsersTable } from './UsersTable'; - -const GLOBAL_KEY = 'USER_SETTINGS_SELECTED'; - -const sections: SettingsSection[] = [ - { key: 'all-users', icon: Users, title: 'All Users', description: 'View and manage users', content: }, -]; - -const { Sidebar, Content } = createSettingsPanelComponents({ - globalKey: GLOBAL_KEY, - sidebarIcon: Users, - sidebarLabel: 'Users', - sections, -}); - -const layout: LayoutNode = { - type: 'group', - id: 'users-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'users-left', appType: null }, size: 20 }, - { node: { type: 'panel', id: 'users-right', appType: null }, size: 80 }, - ], -}; - -export const UserSettings = () => { - const panelComponents: PanelComponents = useMemo( - () => ({ - 'users-left': Sidebar, - 'users-right': Content, - }), - [], - ); - - return ( -
- {}} components={panelComponents} /> -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx index eccceebc..458e3553 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx @@ -1,6 +1,5 @@ export * from './ProfileSettings'; export * from './SystemSettings'; export * from './AISettings'; -export * from './UserSettings'; export * from './IntegrationsSettings'; export * from './AppsSettings'; diff --git a/src/databases/officer_db/migrations/0006_absurd_dormammu.sql b/src/databases/officer_db/migrations/0006_absurd_dormammu.sql new file mode 100644 index 00000000..20f88f88 --- /dev/null +++ b/src/databases/officer_db/migrations/0006_absurd_dormammu.sql @@ -0,0 +1 @@ +ALTER TABLE "users" DROP COLUMN "role"; \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/0006_snapshot.json b/src/databases/officer_db/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..d36705f8 --- /dev/null +++ b/src/databases/officer_db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1575 @@ +{ + "id": "80171599-0d77-4e86-8fa7-e1a140b037d3", + "prevId": "39501bf9-af4e-47a2-8138-a1c53f51e59a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkeys": { + "name": "passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_blacklist": { + "name": "token_blacklist", + "schema": "", + "columns": { + "jti": { + "name": "jti", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_token_blacklist_expires": { + "name": "idx_token_blacklist_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unverified'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_changed_at": { + "name": "password_changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dock_configs": { + "name": "dock_configs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "paths": { + "name": "paths", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dock_configs_user_id_users_id_fk": { + "name": "dock_configs_user_id_users_id_fk", + "tableFrom": "dock_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_integrations": { + "name": "user_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_integrations_user_id_users_id_fk": { + "name": "user_integrations_user_id_users_id_fk", + "tableFrom": "user_integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_integrations_server_integration_id_server_integrations_id_fk": { + "name": "user_integrations_server_integration_id_server_integrations_id_fk", + "tableFrom": "user_integrations", + "tableTo": "server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_user_integrations_user_provider": { + "name": "uq_user_integrations_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_state": { + "name": "user_state", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_state_user_id_users_id_fk": { + "name": "user_state_user_id_users_id_fk", + "tableFrom": "user_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_defaults": { + "name": "dashboard_defaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboard_defaults_user_id_users_id_fk": { + "name": "dashboard_defaults_user_id_users_id_fk", + "tableFrom": "dashboard_defaults", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dashboard_defaults_user_id_unique": { + "name": "dashboard_defaults_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dashboards_user_id_users_id_fk": { + "name": "dashboards_user_id_users_id_fk", + "tableFrom": "dashboards", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_dashboards_user_id": { + "name": "uq_dashboards_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_projects_user_slug": { + "name": "uq_projects_user_slug", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.screens": { + "name": "screens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "terminals": { + "name": "terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "host_terminals": { + "name": "host_terminals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "screens_user_id_users_id_fk": { + "name": "screens_user_id_users_id_fk", + "tableFrom": "screens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_screens_user_name": { + "name": "uq_screens_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_jobs": { + "name": "queue_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lane": { + "name": "lane", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_queue_jobs_status_lane": { + "name": "idx_queue_jobs_status_lane", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lane", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_queue_jobs_user": { + "name": "idx_queue_jobs_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_jobs_user_id_users_id_fk": { + "name": "queue_jobs_user_id_users_id_fk", + "tableFrom": "queue_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_logs": { + "name": "task_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_name": { + "name": "entry_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_type": { + "name": "entry_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_error": { + "name": "is_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_task_logs_user_started": { + "name": "idx_task_logs_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_logs_user_id_users_id_fk": { + "name": "task_logs_user_id_users_id_fk", + "tableFrom": "task_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.terminal_containers": { + "name": "terminal_containers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "docker_id": { + "name": "docker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "terminal_containers_user_id_users_id_fk": { + "name": "terminal_containers_user_id_users_id_fk", + "tableFrom": "terminal_containers", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_config": { + "name": "server_config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_integrations": { + "name": "server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "server_integrations_provider_unique": { + "name": "server_integrations_provider_unique", + "nullsNotDistinct": false, + "columns": [ + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_accounts": { + "name": "email_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imap_secure": { + "name": "imap_secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "sync_meta": { + "name": "sync_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "email_accounts_user_id_users_id_fk": { + "name": "email_accounts_user_id_users_id_fk", + "tableFrom": "email_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_email_accounts_user_email": { + "name": "uq_email_accounts_user_email", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_jobs": { + "name": "pipeline_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_dir_name": { + "name": "task_dir_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_name": { + "name": "task_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pipeline'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "total_cost": { + "name": "total_cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pipeline_jobs_user_created": { + "name": "idx_pipeline_jobs_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_jobs_status": { + "name": "idx_pipeline_jobs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_jobs_user_id_users_id_fk": { + "name": "pipeline_jobs_user_id_users_id_fk", + "tableFrom": "pipeline_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/databases/officer_db/migrations/meta/_journal.json b/src/databases/officer_db/migrations/meta/_journal.json index 3fd1b351..3706d749 100644 --- a/src/databases/officer_db/migrations/meta/_journal.json +++ b/src/databases/officer_db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1784813792772, "tag": "0005_small_the_phantom", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1785013713432, + "tag": "0006_absurd_dormammu", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/databases/officer_db/src/queries/integrations.ts b/src/databases/officer_db/src/queries/integrations.ts index c0912137..ad197e2d 100644 --- a/src/databases/officer_db/src/queries/integrations.ts +++ b/src/databases/officer_db/src/queries/integrations.ts @@ -15,7 +15,11 @@ export async function getServerIntegration(provider: string): Promise, enabled = true): Promise { +export async function upsertServerIntegration( + provider: string, + config: Record, + enabled = true, +): Promise { const [row] = await db .insert(serverIntegrations) .values({ provider, config, enabled, updatedAt: new Date() }) @@ -28,7 +32,10 @@ export async function upsertServerIntegration(provider: string, config: Record { - const result = await db.delete(serverIntegrations).where(eq(serverIntegrations.provider, provider)).returning({ id: serverIntegrations.id }); + const result = await db + .delete(serverIntegrations) + .where(eq(serverIntegrations.provider, provider)) + .returning({ id: serverIntegrations.id }); return result.length > 0; } @@ -57,7 +64,12 @@ type UpsertUserIntegrationParams = { config: Record; }; -export async function upsertUserIntegration({ userId, provider, serverIntegrationId, config }: UpsertUserIntegrationParams): Promise { +export async function upsertUserIntegration({ + userId, + provider, + serverIntegrationId, + config, +}: UpsertUserIntegrationParams): Promise { const [row] = await db .insert(userIntegrations) .values({ userId, provider, serverIntegrationId: serverIntegrationId ?? null, config, updatedAt: new Date() }) @@ -80,7 +92,7 @@ export async function deleteUserIntegration(userId: number, provider: string): P // ── Cross-table lookup ── type UserIntegrationWithUser = UserIntegrationSelect & { - user: { id: number; email: string; username: string | null; role: string }; + user: { id: number; email: string; username: string | null }; }; export async function findUserByIntegrationConfig( @@ -101,16 +113,12 @@ export async function findUserByIntegrationConfig( id: users.id, email: users.email, username: users.username, - role: users.role, }, }) .from(userIntegrations) .innerJoin(users, eq(userIntegrations.userId, users.id)) .where( - and( - eq(userIntegrations.provider, provider), - sql`${userIntegrations.config}->>${configKey} = ${configValue}`, - ), + and(eq(userIntegrations.provider, provider), sql`${userIntegrations.config}->>${configKey} = ${configValue}`), ); return row as UserIntegrationWithUser | undefined; } diff --git a/src/databases/officer_db/src/schema/auth.ts b/src/databases/officer_db/src/schema/auth.ts index 6764f22e..0da3e526 100644 --- a/src/databases/officer_db/src/schema/auth.ts +++ b/src/databases/officer_db/src/schema/auth.ts @@ -4,8 +4,9 @@ export const users = pgTable('users', { id: serial('id').primaryKey(), email: text('email').notNull().unique(), password: text('password'), - role: text('role', { enum: ['Member', 'Admin', 'Owner', 'Super Admin'] }).notNull().default('Member'), - status: text('status', { enum: ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] }).notNull().default('Unverified'), + status: text('status', { enum: ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] }) + .notNull() + .default('Unverified'), name: text('name'), username: text('username').unique(), avatar: text('avatar'), @@ -16,7 +17,9 @@ export const users = pgTable('users', { export const passkeys = pgTable('passkeys', { id: serial('id').primaryKey(), - userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), origin: text('origin'), credentialId: text('credential_id'), publicKey: text('public_key'), @@ -26,16 +29,20 @@ export const passkeys = pgTable('passkeys', { export const passkeyChallenges = pgTable('passkey_challenges', { id: serial('id').primaryKey(), - userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), origin: text('origin').notNull(), challenge: text('challenge').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), }); -export const tokenBlacklist = pgTable('token_blacklist', { - jti: text('jti').primaryKey(), - expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), -}, (table) => [ - index('idx_token_blacklist_expires').on(table.expiresAt), -]); +export const tokenBlacklist = pgTable( + 'token_blacklist', + { + jti: text('jti').primaryKey(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + }, + (table) => [index('idx_token_blacklist_expires').on(table.expiresAt)], +); diff --git a/src/server.tsx b/src/server.tsx index ad4ddf29..0fdff986 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -33,9 +33,7 @@ type WSData = { userId: number; email: string; username: string; - role: string; provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar'; - sandboxed: boolean; sessionId?: string; cwd?: string; command?: string; @@ -220,14 +218,13 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'chat const url = new URL(req.url); const sessionId = url.searchParams.get('sessionId') ?? undefined; - const sandboxed = user.role !== 'Super Admin'; const cwd = url.searchParams.get('cwd') ?? undefined; const command = url.searchParams.get('command') ?? undefined; const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined; const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined; const files = url.searchParams.get('files') ?? undefined; const ok = server.upgrade(req, { - data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), role: user.role, provider, sandboxed, sessionId, cwd, command, cols, rows, files }, + data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), provider, sessionId, cwd, command, cols, rows, files }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); } catch { @@ -254,9 +251,7 @@ function upgradeDevServerWs(req: Request, server: any) { data: { userId: 0, email: '', - role: '', provider: 'dev-server' as const, - sandboxed: false, devServerPort: entry.port, devServerSlug: proxyId, wsProxyPath, @@ -286,7 +281,7 @@ const server = serve({ }, '/api/sidecar/register': (req: Request, server: any) => { const ok = server.upgrade(req, { - data: { provider: 'sidecar', userId: 0, email: '', username: '', role: '', sandboxed: false }, + data: { provider: 'sidecar', userId: 0, email: '', username: '' }, }); if (!ok) return new Response('Upgrade failed', { status: 500 }); }, diff --git a/src/servers/_middlewares/index.ts b/src/servers/_middlewares/index.ts index a3ec191c..5c5b07ec 100644 --- a/src/servers/_middlewares/index.ts +++ b/src/servers/_middlewares/index.ts @@ -3,4 +3,3 @@ export * from './user-middleware'; export * from './origin-middleware'; export * from './origin-validation'; export * from './rate-limiter'; -export * from './super-admin-middleware'; diff --git a/src/servers/_middlewares/super-admin-middleware.ts b/src/servers/_middlewares/super-admin-middleware.ts deleted file mode 100644 index 7f3e8d21..00000000 --- a/src/servers/_middlewares/super-admin-middleware.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { MiddlewareHandler } from 'hono'; -import * as errors from '@@/custom-errors'; - -export const superAdminMiddleware: MiddlewareHandler = function (ctx, next) { - const user = ctx.get('user'); - if (user?.role !== 'Super Admin') throw errors.FORBIDDEN(); - return next(); -}; diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts index f275c2d8..5d2b98a7 100644 --- a/src/servers/_middlewares/user-middleware.ts +++ b/src/servers/_middlewares/user-middleware.ts @@ -5,22 +5,6 @@ import { isOriginAllowed } from './origin-validation'; import { isLockdown, noteBlocked } from '../api/auth/panic'; import { getUserById, isTokenBlacklisted } from 'officerdb'; -// Role permissions: which HTTP methods each role can use -// Roles not listed here are denied by default (fail-safe) -const ROLE_PERMISSIONS: Record = { - Member: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - Admin: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - Owner: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - 'Super Admin': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], -}; - -function isMethodAllowed(role: string | null, method: string): boolean { - if (!role) return false; - const allowedMethods = ROLE_PERMISSIONS[role]; - if (!allowedMethods) return false; // Unknown role = no access - return allowedMethods.includes(method); -} - export const userMiddleware: MiddlewareHandler = async function (ctx, next) { // Duress lockdown: reject every authenticated request, cutting off all existing sessions. if (isLockdown()) { @@ -68,9 +52,6 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) { } } - if (!isMethodAllowed(user.role, ctx.req.method)) { - throw errors.FORBIDDEN('Insufficient permissions'); - } ctx.set('user', user); return next(); } catch (ex) { diff --git a/src/servers/api/auth/auth.ts b/src/servers/api/auth/auth.ts index a66a718d..1afc8199 100644 --- a/src/servers/api/auth/auth.ts +++ b/src/servers/api/auth/auth.ts @@ -10,10 +10,7 @@ import { } from '../../_middlewares'; import { signinHandler } from './signin'; import { signoutHandler } from './signout'; -import { signupHandler } from './signup'; -import { verifyHandler } from './verify'; import { verifyTokenHandler } from './verify-token'; -import { resendVerificationHandler } from './resend-verification'; import { changePasswordHandler } from './change-password'; import { forgotPasswordHandler } from './forgot-password'; import { resetPasswordHandler } from './reset-password'; @@ -39,11 +36,10 @@ authRouter.post('/signout', userMiddleware, signoutHandler); authRouter.post('/revoke', userMiddleware, revokeHandler); // Trigger the panic lockdown — authenticated, no password in the body. authRouter.post('/panic', userMiddleware, panicHandler); -authRouter.post('/signup', signupRateLimiter, signupHandler); +// Creates the single server-owner account. Only succeeds while the user table is empty. authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler); -authRouter.post('/verify', verifyHandler); +// Validates a password-reset link before the reset form is shown. authRouter.post('/verify-token', verifyTokenHandler); -authRouter.post('/resend-verification', resendVerificationHandler); authRouter.post('/change-password', userMiddleware, changePasswordHandler); authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler); authRouter.post('/reset-password', resetPasswordHandler); diff --git a/src/servers/api/auth/bootstrap.ts b/src/servers/api/auth/bootstrap.ts index 8bd8e5a8..aff50bb3 100644 --- a/src/servers/api/auth/bootstrap.ts +++ b/src/servers/api/auth/bootstrap.ts @@ -6,8 +6,8 @@ import { validatePassword } from './validate-password'; import { validateUsername } from './validate-username'; import { provisionUserEnvironment } from '../users/provision'; -// Single-step super-admin bootstrap: the first user is created directly as an active Super Admin, with -// no email-verification round-trip. Gated to an empty user table (registration is otherwise closed). +// Single-step bootstrap for the one account Officer supports: the server owner is created directly as +// active, with no email-verification round-trip. Gated to an empty user table. export const bootstrapHandler: Handler = async function (ctx) { const body = ctx.get('body'); @@ -35,7 +35,6 @@ export const bootstrapHandler: Handler = async function (ctx) { password: passwordHash, name: name.trim(), username: validUsername, - role: 'Super Admin', status: 'Active', }); diff --git a/src/servers/api/auth/passkey-router.ts b/src/servers/api/auth/passkey-router.ts index 29636539..9b8802ad 100644 --- a/src/servers/api/auth/passkey-router.ts +++ b/src/servers/api/auth/passkey-router.ts @@ -168,13 +168,12 @@ const passkeyRouterPostVerify: Handler = async (ctx) => { const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin); - const { id, name, username, role } = dbUser; + const { id, name, username } = dbUser; const token = await sign({ id, email, name, username, - role, passkeys: passkeys.length, }); @@ -185,7 +184,6 @@ const passkeyRouterPostVerify: Handler = async (ctx) => { email, name, username, - role, passkeys: passkeys.length, }, }); diff --git a/src/servers/api/auth/resend-verification.ts b/src/servers/api/auth/resend-verification.ts deleted file mode 100644 index 65b2a2c4..00000000 --- a/src/servers/api/auth/resend-verification.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Handler } from 'hono'; -import { getUserByEmail } from 'officerdb'; -import { sign } from '@@/jwt'; -import * as errors from '@@/custom-errors'; -import { sendMail } from 'emailer'; - -export const resendVerificationHandler: Handler = async function (ctx) { - const { email } = ctx.get('body'); - const origin = ctx.get('origin'); - - if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required'); - - const user = await getUserByEmail(email); - if (!user) throw errors.NOT_FOUND('User not found'); - if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified'); - - const verificationCode = await sign({ id: user.id, email: user.email }, '24h'); - const url = `${origin}/auth/verify?verificationCode=${verificationCode}`; - - await sendMail({ - template: 'VerifyAdmin', - subject: 'Verify your officer.dev account', - to: user.email, - data: { name: user.email, url }, - }); - - return ctx.json({ ok: true }); -}; diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts index be0aadb7..e6083a47 100755 --- a/src/servers/api/auth/signin.ts +++ b/src/servers/api/auth/signin.ts @@ -28,9 +28,9 @@ export const signinHandler: Handler = async function (ctx) { const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password)); if (!isValidPassword) throw errors.UNAUTHORIZED(); - const { id, name, username, role } = dbUser; + const { id, name, username } = dbUser; - const tokenUser = { id, email, name, username, role, passkeys: passkeys.length }; + const tokenUser = { id, email, name, username, passkeys: passkeys.length }; if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) { return ctx.json({ user: tokenUser }); diff --git a/src/servers/api/auth/signup.ts b/src/servers/api/auth/signup.ts deleted file mode 100644 index 28d89e61..00000000 --- a/src/servers/api/auth/signup.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Handler } from 'hono'; -import { getUserCount, createUser } from 'officerdb'; -import { sign } from '@@/jwt'; -import type { USER_ROLES, USER_STATUSES } from 'definitions'; -import * as errors from '@@/custom-errors'; -import { sendMail } from 'emailer'; - -export const signupHandler: Handler = async function (ctx) { - const body = ctx.get('body'); - const origin = ctx.get('origin'); - - if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { - throw errors.BAD_REQUEST('Invalid email address'); - } - - const userCount = await getUserCount(); - if (userCount > 0) throw errors.FORBIDDEN('Registration is closed'); - - const dbUser = await createUser({ - email: body.email as string, - status: 'Unverified' as (typeof USER_STATUSES)[number], - role: 'Admin' as (typeof USER_ROLES)[number], - }); - - const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h'); - const url = `${origin}/auth/verify?verificationCode=${verificationCode}`; - - await sendMail({ - template: 'VerifyAdmin', - subject: 'Verify your officer.dev account', - to: dbUser.email, - data: { name: dbUser.email, url }, - }); - - return ctx.json({ ok: true, user: { id: dbUser.id, email: dbUser.email } }); -}; diff --git a/src/servers/api/auth/verify-token.ts b/src/servers/api/auth/verify-token.ts index 2887249a..94388e3e 100644 --- a/src/servers/api/auth/verify-token.ts +++ b/src/servers/api/auth/verify-token.ts @@ -4,30 +4,25 @@ import { getUserById } from 'officerdb'; import { verify } from '@@/jwt'; import * as errors from '@@/custom-errors'; +// Validates a password-reset link before the reset form is rendered. Officer is single-user, so the +// account-verification and invitation flows this used to serve no longer exist — the sole account is +// created directly by /auth/bootstrap. export const verifyTokenHandler: Handler = async function (ctx) { const { verificationCode } = ctx.get('body'); if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code'); - let userInfo: User; + let userInfo: User & { purpose?: string }; try { - userInfo = (await verify(verificationCode)) as User; + userInfo = (await verify(verificationCode)) as User & { purpose?: string }; } catch { throw errors.BAD_REQUEST('Token is invalid or expired'); } - // Bootstrap token: has email but no id (user not yet created) - if (userInfo?.email && !userInfo?.id) { - return ctx.json({ ok: true, email: userInfo.email, flow: 'bootstrap' }); - } - + if (userInfo?.purpose !== 'reset-password') throw errors.BAD_REQUEST('Token is invalid or expired'); if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired'); const user = await getUserById(userInfo.id); if (!user) throw errors.NOT_FOUND('User not found'); - // Reset-password tokens skip the verification status check - const isResetToken = (userInfo as Record).purpose === 'reset-password'; - if (!isResetToken && user.status !== 'Unverified' && user.status !== 'Invited') throw errors.BAD_REQUEST('Account is already verified'); - - return ctx.json({ ok: true, email: user.email, flow: user.status === 'Invited' ? 'invite' : 'verify' }); + return ctx.json({ ok: true, email: user.email }); }; diff --git a/src/servers/api/auth/verify.ts b/src/servers/api/auth/verify.ts deleted file mode 100644 index 7b3893a2..00000000 --- a/src/servers/api/auth/verify.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Handler } from 'hono'; -import type { User } from 'types'; -import { getUserById, updateUser } from 'officerdb'; -import { verify as verifyJwt, sign } from '@@/jwt'; -import argon2 from 'argon2'; -import * as errors from '@@/custom-errors'; -import { validatePassword } from './validate-password'; -import { validateUsername } from './validate-username'; -import { provisionUserEnvironment } from '../users/provision'; - -export const verifyHandler: Handler = async function (ctx) { - const { verificationCode, name, username, password, confirmPassword } = ctx.get('body'); - const userInfo = (await verifyJwt(verificationCode)) as User; - if (!userInfo) throw errors.BAD_REQUEST(); - - const user = await getUserById(userInfo.id); - if (!user) throw errors.NOT_FOUND('User not found'); - - const updates: Record = { status: 'Active' }; - - if (name) { - if (typeof name !== 'string' || !name.trim() || name.length > 128) { - throw errors.BAD_REQUEST('Name must be between 1 and 128 characters'); - } - updates.name = name.trim(); - } - - if (username && typeof username === 'string' && username.trim()) { - updates.username = validateUsername(username); - } - - if (password) { - validatePassword(password); - if (password !== confirmPassword) { - throw errors.BAD_REQUEST('Passwords do not match'); - } - updates.password = await argon2.hash(password); - } - - await updateUser(userInfo.id, updates); - - // Re-fetch user to get final values after update - const finalUser = await getUserById(userInfo.id); - if (!finalUser) throw errors.NOT_FOUND('User not found'); - - // Provision user environment (directories, configs) - provisionUserEnvironment(finalUser.email, finalUser.username ?? '').catch((err) => { - console.error('[verify] failed to provision user environment:', err); - }); - - // Issue a token so the user is logged in immediately - const token = await sign({ - id: finalUser.id, - email: finalUser.email, - name: finalUser.name, - username: finalUser.username, - role: finalUser.role, - }); - - return ctx.json({ ok: true, token }); -}; diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 7da93523..8b9a9747 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -17,7 +17,7 @@ import { DATA_PATH } from '../../data-path'; // The `claude` CLI persists every session as a JSONL transcript at // $HOME/.claude/projects//.jsonl // where is the working directory with every non-alphanumeric char replaced by '-'. -// The (single-user, Super Admin) platform runs Claude with no isolation — HOME is the real home +// Single-user platform: Claude runs with no isolation — HOME is the real home // (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our // own copy; Claude's files are authoritative. diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index b71123e1..fe4b8cee 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -52,7 +52,6 @@ export type ClientMessage = model?: string; cwd?: string; cwdRoot?: string; - sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: ThinkingLevel; @@ -148,7 +147,6 @@ export type UserSession = { userId?: number; cwd: string; model: string; - sandboxed?: boolean; piProcess: any | null; ws: any | null; lastActivity: number; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 91756f76..7e5695f3 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -7,7 +7,7 @@ import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { ensureGeneralChatSessionsCwd } from './claude-sessions'; import * as sidecar from '@@/sidecar-registry'; import { join } from 'path'; -import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path'; +import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; import { getUserSettings, getEmailAccounts } from 'officerdb'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -34,28 +34,21 @@ type WSData = { userId: number; email: string; username: string; - role: string; - sandboxed: boolean; provider: string; }; const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour -const resolveCwd = (email: string, role: string, cwd?: string) => { - const root = getHomeDirForRole(email, role); +const resolveCwd = (email: string, cwd?: string) => { + const root = getOwnerHomeDir(email); if (!cwd || cwd === '~') return root; if (cwd.startsWith('~/')) return join(root, cwd.slice(2)); - if (cwd.startsWith('/')) { - // Super Admin: trust absolute paths as-is - if (role === 'Super Admin') return cwd; - return join(root, cwd.slice(1)); - } + // The server owner is the only account — absolute paths are theirs to use. + if (cwd.startsWith('/')) return cwd; return join(root, cwd); }; -export const resolveBaseCwd = (email: string, role: string, cwd?: string) => { - return resolveCwd(email, role, cwd); -}; +export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd); // The email chat runs from the selected account's storage dir: // DATA_PATH//email_accounts/ @@ -81,13 +74,11 @@ async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail? async function resolveChatCwd( msg: { context?: string; contextId?: string; cwd?: string }, email: string, - role: string, userId: number, ): Promise { if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId); - if (msg.context === 'chat') - return msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureGeneralChatSessionsCwd(email); - return resolveCwd(email, role, msg.cwd); + if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email); + return resolveCwd(email, msg.cwd); } const wsToSessionMap = new WeakMap(); @@ -295,7 +286,6 @@ async function handleChat( model?: string; cwd?: string; cwdRoot?: string; - sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; @@ -336,14 +326,13 @@ async function handleClaudeCodeChat( contextId?: string; cwd?: string; cwdRoot?: string; - sandboxed?: boolean; resumeSessionId?: string; }, effectivePrompt: string, ): Promise { const { email, username, userId } = ws.data; - const cwd = await resolveChatCwd(msg, email, ws.data.role, userId); + const cwd = await resolveChatCwd(msg, email, userId); const groupSlug = msg.groupSlug || null; @@ -389,7 +378,6 @@ async function handleClaudeCodeChat( sessionKey: sessionId, cwd, model, - role: ws.data.role, resumeSessionId: msg.resumeSessionId, onEvent, }); @@ -416,14 +404,13 @@ async function handleOpenCodeChat( contextId?: string; cwd?: string; cwdRoot?: string; - sandboxed?: boolean; resumeSessionId?: string; }, effectivePrompt: string, ): Promise { const { email, username, userId } = ws.data; - const cwd = await resolveChatCwd(msg, email, ws.data.role, userId); + const cwd = await resolveChatCwd(msg, email, userId); const groupSlug = msg.groupSlug || null; @@ -468,7 +455,6 @@ async function handleOpenCodeChat( sessionKey: sessionId, cwd, model, - role: ws.data.role, resumeSessionId: msg.resumeSessionId, onEvent, }); diff --git a/src/servers/api/cliamp/websocket.ts b/src/servers/api/cliamp/websocket.ts index f00e4e90..aabdc7f0 100644 --- a/src/servers/api/cliamp/websocket.ts +++ b/src/servers/api/cliamp/websocket.ts @@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun'; import { spawn, type Subprocess } from 'bun'; import { resolve, normalize, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getHomeDirForRole } from '@@/data-path'; +import { getOwnerHomeDir } from '@@/data-path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ASOUNDRC_PATH = join(__dirname, 'asoundrc'); @@ -11,7 +11,6 @@ type WSData = { userId: number; email: string; username: string; - role: string; files: string; }; @@ -59,7 +58,9 @@ const findCliamp = (): string | null => { try { const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' }); if (stat.exitCode === 0) return bin; - } catch { /* ignore */ } + } catch { + /* ignore */ + } } return null; }; @@ -68,7 +69,7 @@ const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; export const cliampWebsocket = { async open(ws: ServerWebSocket) { - const { email, role, files: filesParam } = ws.data; + const { email, files: filesParam } = ws.data; if (!filesParam) { sendOutput(ws, '\r\n[Error] No files specified.\r\n'); @@ -81,7 +82,7 @@ export const cliampWebsocket = { return; } - const homeDir = getHomeDirForRole(email, role); + const homeDir = getOwnerHomeDir(email); const rawFiles = [filesParam]; // Resolve paths relative to user home dir @@ -187,7 +188,11 @@ export const cliampWebsocket = { const session = sessions.get(ws); if (session) { session.closed = true; - try { session.proc.kill(); } catch { /* ignore */ } + try { + session.proc.kill(); + } catch { + /* ignore */ + } sessions.delete(ws); } }, diff --git a/src/servers/api/desktop/rest.ts b/src/servers/api/desktop/rest.ts index 38e0318b..6701b581 100644 --- a/src/servers/api/desktop/rest.ts +++ b/src/servers/api/desktop/rest.ts @@ -6,7 +6,7 @@ export const desktopRouter = createRouter(); desktopRouter.get('/vnc-password', async (ctx) => { const user = ctx.get('user'); - const password = await getVncPassword(user.email, user.role); + const password = await getVncPassword(user.email); if (!password) { return ctx.json({ error: 'VNC password not configured' }, 500); } diff --git a/src/servers/api/desktop/vnc-config.ts b/src/servers/api/desktop/vnc-config.ts index 53178c9d..f808255f 100644 --- a/src/servers/api/desktop/vnc-config.ts +++ b/src/servers/api/desktop/vnc-config.ts @@ -1,12 +1,10 @@ import { join } from 'node:path'; -import { getHomeDirForRole } from '@@/data-path'; +import { getOwnerHomeDir } from '@@/data-path'; -function getVncDir(email: string, role: string | null): string { - return join(getHomeDirForRole(email, role), '.vnc'); -} +const getVncDir = (email: string): string => join(getOwnerHomeDir(email), '.vnc'); -export async function getVncPassword(email: string, role: string | null): Promise { - const file = Bun.file(join(getVncDir(email, role), 'password')); +export async function getVncPassword(email: string): Promise { + const file = Bun.file(join(getVncDir(email), 'password')); if (!(await file.exists())) return null; return (await file.text()).trim(); } diff --git a/src/servers/api/desktop/websocket.ts b/src/servers/api/desktop/websocket.ts index 1541bfe8..3badc162 100644 --- a/src/servers/api/desktop/websocket.ts +++ b/src/servers/api/desktop/websocket.ts @@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun'; import type { Socket } from 'bun'; import * as sidecar from '@@/sidecar-registry'; -type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string }; +type WSData = { userId: number; email: string; username: string; sessionId?: string }; type VncSession = { tcpSocket: Socket<{ ws: ServerWebSocket }> | null; @@ -21,7 +21,6 @@ export const desktopWebsocket = { const result = await sidecar.startVnc({ email: ws.data.email, username: ws.data.username, - role: ws.data.role, }); port = result.port; } catch (err) { diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 4309ca0a..73afdd16 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router'; import { resolve, dirname, join, parse as parsePath } from 'node:path'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { getHomeDir, DATA_PATH } from '@@/data-path'; +import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import * as errors from '@@/custom-errors'; import { readTtsConfig } from '@@/api/server-settings/tts'; import { readSttConfig } from '@@/api/server-settings/stt'; @@ -43,16 +43,12 @@ async function syncSeedDir(seedDir: string, targetDir: string) { } } -async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) { +async function seedHomeDir(homeDir: string) { for (const dir of DEFAULT_HOME_DIRS) { const target = join(homeDir, dir); if (dir === 'Onboarding') { - if (isSuperAdmin) { - await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding')); - await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding')); - } else { - await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding')); - } + await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding')); + await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding')); } else if (!existsSync(target)) { await mkdir(target, { recursive: true }); } @@ -61,17 +57,14 @@ async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) { export const router = createRouter(); -type UserCtx = { email: string; role: string | null }; +type UserCtx = { email: string }; function getUserDataDir(email: string): string { return join(DATA_PATH, email); } function getRootDir(user: UserCtx, root?: string): string { - if (!root || root === 'home') { - if (user.role === 'Super Admin' && process.env.HOME_DIR) return process.env.HOME_DIR; - return getHomeDir(user.email); - } + if (!root || root === 'home') return getOwnerHomeDir(user.email); if (root === 'user-data') return getUserDataDir(user.email); throw errors.BAD_REQUEST(`Invalid root: ${root}`); } @@ -117,7 +110,24 @@ async function ensureAudioRemux(email: string, absPath: string, relPath: string, const tmp = `${base}.tmp.${ext}`; const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : []; const proc = Bun.spawn( - ['ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp], + [ + 'ffmpeg', + '-v', + 'error', + '-i', + absPath, + '-map', + '0:v', + '-map', + `0:a:${track}`, + '-c', + 'copy', + '-dn', + '-sn', + ...movflags, + '-y', + tmp, + ], { stdout: 'ignore', stderr: 'pipe' }, ); const code = await proc.exited; @@ -147,7 +157,7 @@ router.get('/ls', async (ctx) => { // Auto-create dir if missing (only for user home root) if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { - await seedHomeDir(rootDir, user.role === 'Super Admin'); + await seedHomeDir(rootDir); await mkdir(absPath, { recursive: true }); } @@ -326,7 +336,17 @@ router.get('/raw', async (ctx) => { }); // List a video's text-based subtitle tracks (for the in-browser player's selector) -const TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'microdvd']); +const TEXT_SUBTITLE_CODECS = new Set([ + 'subrip', + 'srt', + 'ass', + 'ssa', + 'mov_text', + 'webvtt', + 'text', + 'subviewer', + 'microdvd', +]); router.get('/subtitles', async (ctx) => { const user = ctx.get('user'); @@ -336,7 +356,18 @@ router.get('/subtitles', async (ctx) => { const absPath = resolveUserPath(rootDir, relPath); const proc = Bun.spawn( - ['ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=codec_name:stream_tags=language,title,handler_name', '-of', 'json', absPath], + [ + 'ffprobe', + '-v', + 'error', + '-select_streams', + 's', + '-show_entries', + 'stream=codec_name:stream_tags=language,title,handler_name', + '-of', + 'json', + absPath, + ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); @@ -396,13 +427,29 @@ router.get('/audio-tracks', async (ctx) => { const absPath = resolveUserPath(rootDir, relPath); const proc = Bun.spawn( - ['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath], + [ + 'ffprobe', + '-v', + 'error', + '-select_streams', + 'a', + '-show_entries', + 'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name', + '-of', + 'json', + absPath, + ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; - type ProbeAudio = { channels?: number; codec_name?: string; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } }; + type ProbeAudio = { + channels?: number; + codec_name?: string; + bit_rate?: string; + tags?: { language?: string; title?: string; handler_name?: string }; + }; let streams: ProbeAudio[] = []; try { streams = (JSON.parse(out).streams as ProbeAudio[]) ?? []; @@ -427,23 +474,64 @@ router.get('/audio-tracks', async (ctx) => { // odd files out when they don't. Two files "match" when their audio (language + channel count) and // subtitle (language) streams line up in order; per-episode titles are ignored (they always differ). const VIDEO_EXTENSIONS = new Set([ - 'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg', - 'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb', + 'mp4', + 'mkv', + 'webm', + 'mov', + 'avi', + 'wmv', + 'flv', + 'm4v', + 'mpg', + 'mpeg', + 'ts', + 'm2ts', + 'mts', + '3gp', + 'ogv', + 'vob', + 'divx', + 'asf', + 'f4v', + 'rm', + 'rmvb', ]); -type FolderAudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string }; +type FolderAudioTrack = { + id: number; + codec: string; + channels: number; + bitrate: number | null; + lang: string; + title: string; +}; type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string }; type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] }; async function probeVideoTracks(absPath: string): Promise { const proc = Bun.spawn( - ['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath], + [ + 'ffprobe', + '-v', + 'error', + '-show_entries', + 'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name', + '-of', + 'json', + absPath, + ], { stdout: 'pipe', stderr: 'ignore' }, ); const out = await new Response(proc.stdout).text(); await proc.exited; - type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } }; + type ProbeStream = { + codec_type?: string; + codec_name?: string; + channels?: number; + bit_rate?: string; + tags?: { language?: string; title?: string; handler_name?: string }; + }; let streams: ProbeStream[] = []; try { streams = (JSON.parse(out).streams as ProbeStream[]) ?? []; @@ -453,7 +541,14 @@ async function probeVideoTracks(absPath: string): Promise { const audio = streams .filter((s) => s.codec_type === 'audio') - .map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, bitrate: kbps(s.bit_rate), lang: s.tags?.language ?? '', title: trackName(s.tags) })); + .map((s, id) => ({ + id, + codec: s.codec_name ?? '', + channels: s.channels ?? 0, + bitrate: kbps(s.bit_rate), + lang: s.tags?.language ?? '', + title: trackName(s.tags), + })); // subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned // before filtering out image-based tracks that can't become soft subs. const subtitle = streams @@ -490,12 +585,20 @@ router.get('/probe-folder', async (ctx) => { for (let i = 0; i < files.length; i += CONCURRENCY) { const batch = files.slice(i, i + CONCURRENCY); const results = await Promise.all( - batch.map(async (file) => ({ file, tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))) })), + batch.map(async (file) => ({ + file, + tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))), + })), ); probed.push(...results); } - type Group = { signature: string; files: string[]; audioTracks: FolderAudioTrack[]; subtitleTracks: FolderSubtitleTrack[] }; + type Group = { + signature: string; + files: string[]; + audioTracks: FolderAudioTrack[]; + subtitleTracks: FolderSubtitleTrack[]; + }; const groupsMap = new Map(); for (const { file, tracks } of probed) { const sig = layoutSignature(tracks); @@ -1161,7 +1264,9 @@ async function runReclipDownload(jobId: string, url: string, absPath: string, au for (;;) { if (Date.now() > deadline) throw new Error('Download timed out'); await new Promise((r) => setTimeout(r, 2000)); - const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, { signal: AbortSignal.timeout(15_000) }).catch(() => null); + const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, { + signal: AbortSignal.timeout(15_000), + }).catch(() => null); if (!stRes?.ok) continue; const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null }; if (st.status === 'error') throw new Error(st.error || 'ReClip download failed'); diff --git a/src/servers/api/integrations/integrations.ts b/src/servers/api/integrations/integrations.ts index fadab005..5dbd6960 100644 --- a/src/servers/api/integrations/integrations.ts +++ b/src/servers/api/integrations/integrations.ts @@ -34,7 +34,7 @@ integrationsRouter.get('/', async (ctx) => { return ctx.json([]); }); -// --- Enterprise: Apify config (Super Admin only) --- +// --- Apify config --- type ApifyConfig = { apiToken: string }; @@ -47,15 +47,10 @@ export const readApifyConfig = async (): Promise => { }; integrationsRouter.get('/apify/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw FORBIDDEN(); return ctx.json(await readApifyConfig()); }); integrationsRouter.put('/apify/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw FORBIDDEN(); - const body = ctx.get('body') as { apiToken?: string }; const config = { apiToken: body.apiToken ?? '' }; @@ -68,18 +63,13 @@ integrationsRouter.get('/apify/status', async (ctx) => { return ctx.json({ configured: !!config?.apiToken }); }); -// --- Enterprise: Google OAuth config (Super Admin only) --- +// --- Google OAuth config --- integrationsRouter.get('/google/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw FORBIDDEN(); return ctx.json(await readGoogleConfig()); }); integrationsRouter.put('/google/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw FORBIDDEN(); - const body = ctx.get('body') as { clientId?: string; clientSecret?: string }; const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' }; @@ -88,9 +78,6 @@ integrationsRouter.put('/google/config', async (ctx) => { }); integrationsRouter.get('/google/verify', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw FORBIDDEN(); - const config = await readGoogleConfig(); if (!config?.clientId || !config?.clientSecret) { return ctx.json({ valid: false, error: 'Missing credentials' }); @@ -197,7 +184,11 @@ integrationsRouter.post('/google/gmail-proxy', async (ctx) => { const upstream = await fetch(url, init); const text = await upstream.text(); let parsed: unknown; - try { parsed = JSON.parse(text); } catch { parsed = text; } + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } return ctx.json({ status: upstream.status, ok: upstream.ok, body: parsed }); }); diff --git a/src/servers/api/tasks/execute-script.ts b/src/servers/api/tasks/execute-script.ts index 42a65126..ab8ddc0b 100644 --- a/src/servers/api/tasks/execute-script.ts +++ b/src/servers/api/tasks/execute-script.ts @@ -2,8 +2,7 @@ import { join, isAbsolute } from 'node:path'; import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs'; import { tmpdir } from 'node:os'; import { getTaskByDirName } from './task-files'; -import { getHomeDirForRole, DATA_PATH } from '../../data-path'; -import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox'; +import { getOwnerHomeDir, DATA_PATH } from '../../data-path'; import { killTree } from './process-tree'; // Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log @@ -18,8 +17,6 @@ export type ScriptEvent = export type ExecuteScriptParams = { jobId: string; email: string; - role: string; - sandboxed: boolean; taskDirName: string; inputs: Record; cwd?: string; @@ -28,9 +25,21 @@ export type ExecuteScriptParams = { }; const getRunner = (language: string): string[] => - language === 'python' ? ['python3'] : language === 'typescript' ? ['bun', 'run'] : language === 'javascript' ? ['node'] : ['bash']; + language === 'python' + ? ['python3'] + : language === 'typescript' + ? ['bun', 'run'] + : language === 'javascript' + ? ['node'] + : ['bash']; const getFileName = (language: string): string => - language === 'python' ? 'run.py' : language === 'typescript' ? 'index.ts' : language === 'javascript' ? 'index.js' : 'run.sh'; + language === 'python' + ? 'run.py' + : language === 'typescript' + ? 'index.ts' + : language === 'javascript' + ? 'index.js' + : 'run.sh'; function materializeScript(language: string, implementation: string): string { const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -57,7 +66,7 @@ export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.l // output to a durable log file. Resolves with the process exit code; throws only on spawn failure or // when aborted (the manager maps those to failed/stopped). export async function executeScript(params: ExecuteScriptParams): Promise<{ exitCode: number }> { - const { jobId, email, role, sandboxed, inputs, abortSignal, emit } = params; + const { jobId, email, inputs, abortSignal, emit } = params; const task = await getTaskByDirName(params.taskDirName); if (!task) throw new Error(`Task not found: ${params.taskDirName}`); @@ -70,35 +79,21 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit const positionalArgs = buildArgs(inputs, task.args); const cmd = [...getRunner(language), scriptPath, ...positionalArgs]; - const homeDir = getHomeDirForRole(email, role); + const homeDir = getOwnerHomeDir(email); const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir; - let spawnCmd: string[]; - let spawnEnv: Record; - let spawnCwd: string; - - if (sandboxed) { - const prefix = buildSandboxPrefix(email); - const suffix = buildRunuserSuffix(); - const userDataPrefix = join(DATA_PATH, email); - const translatePath = (v: string) => (v.startsWith(userDataPrefix) ? '/data' + v.slice(userDataPrefix.length) : v); - const envArgs: string[] = []; - for (const [key, value] of Object.entries(inputEnv)) envArgs.push('--setenv', key, translatePath(value)); - const sandboxCmd = cmd.map((arg) => translatePath(arg)); - const scriptDir = join(scriptPath, '..'); - spawnCmd = [...prefix, '--ro-bind', scriptDir, scriptDir, ...envArgs, ...suffix, ...sandboxCmd]; - spawnEnv = {}; - spawnCwd = '/'; - } else { - spawnCmd = cmd; - spawnEnv = { ...(process.env as Record), ...inputEnv }; - spawnCwd = cwd; - } + const spawnCmd = cmd; + const spawnEnv = { ...(process.env as Record), ...inputEnv }; + const spawnCwd = cwd; mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true }); const log = createWriteStream(jobLogPath(jobId), { flags: 'w' }); const cleanup = () => { - try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(join(scriptPath, '..'), { recursive: true, force: true }); + } catch { + /* best effort */ + } }; emit({ type: 'started', taskName: task.name }); @@ -109,7 +104,11 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit const abortPoll = setInterval(() => { if (abortSignal.aborted) { clearInterval(abortPoll); - try { killTree(proc.pid); } catch { /* already dead */ } + try { + killTree(proc.pid); + } catch { + /* already dead */ + } } }, 500); diff --git a/src/servers/api/tasks/pipeline-executor.ts b/src/servers/api/tasks/pipeline-executor.ts index d179647c..1d8ba7c9 100644 --- a/src/servers/api/tasks/pipeline-executor.ts +++ b/src/servers/api/tasks/pipeline-executor.ts @@ -5,9 +5,8 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getUserSettings } from 'officerdb'; import { getTaskByDirName } from './task-files'; -import { getHomeDirForRole, getHomeDir } from '../../data-path'; +import { getHomeDir } from '../../data-path'; import { resolveBaseCwd } from '../chat/websocket'; -import { SANDBOX_HOME } from '../../sidecar/sandbox'; import { sendClaudeCodeStreaming } from '../../channels/send-claude-code'; import type { ChatEvent, MessageCost } from '../chat/types'; import * as jobManager from './pipeline-job-manager'; @@ -41,7 +40,12 @@ type PipelineConfig = { // Messages sent to client export type OutMessage = | { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string; concurrency?: number }> } - | { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } } + | { + type: 'step:start'; + stepIndex: number; + taskName: string; + iteration?: { current: number; total: number; label: string }; + } | { type: 'step:complete'; stepIndex: number; cost?: MessageCost } | { type: 'step:skip'; stepIndex: number; label: string; reason: string } | { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number } @@ -51,8 +55,22 @@ export type OutMessage = | { type: 'iteration:error'; stepIndex: number; label: string; error: string } | { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string } | { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string } - | { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record; stepIndex: number; iterationLabel?: string } - | { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string } + | { + type: 'tool:start'; + toolCallId: string; + toolName: string; + toolInput: Record; + stepIndex: number; + iterationLabel?: string; + } + | { + type: 'tool:result'; + toolCallId: string; + output: string; + isError: boolean; + stepIndex: number; + iterationLabel?: string; + } | { type: 'pipeline:complete'; totalCost: MessageCost } | { type: 'error'; message: string } | { type: 'stopped' }; @@ -67,7 +85,6 @@ type RunStepParams = { userId: number; email: string; username: string; - role: string; taskDirName: string; prompt: string; cwd: string; @@ -92,14 +109,28 @@ async function refreshProxyToken(): Promise { const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const WAITING_INTERVAL_MS = 10 * 1000; // emit "waiting" every 10s -async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, model, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise { +async function runAgenticStep({ + userId, + email, + username, + taskDirName, + prompt, + cwd, + model, + abortSignal, + emit, + stepIndex, + iterationLabel, +}: RunStepParams): Promise { const sessionId = randomUUID(); const isClaudeCode = model.startsWith('claude-code'); // Ensure fresh OAuth token before spawning Claude Code if (isClaudeCode) await refreshProxyToken(); - console.log(`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`); + console.log( + `[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`, + ); return new Promise(async (resolve, reject) => { if (abortSignal.aborted) return reject(new Error('Pipeline aborted')); @@ -129,19 +160,42 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel }); break; case 'tool:start': - emit({ type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput, stepIndex, iterationLabel }); + emit({ + type: 'tool:start', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolInput: event.toolInput, + stepIndex, + iterationLabel, + }); break; case 'tool:result': - emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel }); + emit({ + type: 'tool:result', + toolCallId: event.toolCallId, + output: event.output, + isError: event.isError, + stepIndex, + iterationLabel, + }); break; case 'result': - settle(() => { cleanup?.(); resolve(event.cost); }); + settle(() => { + cleanup?.(); + resolve(event.cost); + }); break; case 'error': - settle(() => { cleanup?.(); reject(new Error(event.message)); }); + settle(() => { + cleanup?.(); + reject(new Error(event.message)); + }); break; case 'stopped': - settle(() => { cleanup?.(); reject(new Error('Step was stopped')); }); + settle(() => { + cleanup?.(); + reject(new Error('Step was stopped')); + }); break; } }; @@ -149,14 +203,20 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom // Poll for abort signal and activity timeout const abortPoll = setInterval(() => { if (abortSignal.aborted) { - settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); }); + settle(() => { + cleanup?.(); + reject(new Error('Pipeline was stopped')); + }); return; } // Activity timeout (skip for Claude Code which has its own mechanisms) if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) { const elapsed = Math.round((Date.now() - stepStart) / 1000); console.error(`[pipeline] step ${stepIndex} timed out after ${elapsed}s of inactivity (session=${sessionId})`); - settle(() => { cleanup?.(); reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`)); }); + settle(() => { + cleanup?.(); + reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`)); + }); } }, 500); @@ -176,12 +236,14 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom sessionKey: sessionId, cwd, model, - role, onEvent, }); cleanup = handle.kill; } catch (err) { - settle(() => { cleanup?.(); reject(err); }); + settle(() => { + cleanup?.(); + reject(err); + }); } }); } @@ -192,16 +254,6 @@ function resolveInputTemplate(template: string, variables: Record variables[key] ?? ''); } -/** Convert a host-side absolute path to the path the agent sees inside the sandbox. */ -function toAgentPath(hostPath: string, email: string, role: string): string { - if (role === 'Super Admin') return hostPath; - const hostHome = getHomeDir(email); - if (hostPath.startsWith(hostHome)) { - return SANDBOX_HOME + hostPath.slice(hostHome.length); - } - return hostPath; -} - function buildStepPrompt(taskBody: string, inputs: Record, targetDir?: string): string { const inputLines = Object.entries(inputs) .filter(([, v]) => v.trim()) @@ -219,7 +271,6 @@ function buildStepPrompt(taskBody: string, inputs: Record, targe type RunScriptStepParams = { email: string; - role: string; task: { name: string; implementation: string; language: string; args?: string[] | null }; inputs: Record; cwd: string; @@ -230,25 +281,43 @@ type RunScriptStepParams = { function getRunner(language: string): string[] { switch (language) { - case 'bash': return ['bash']; - case 'python': return ['python3']; - case 'typescript': return ['bun', 'run']; - case 'javascript': return ['node']; - default: return ['bash']; + case 'bash': + return ['bash']; + case 'python': + return ['python3']; + case 'typescript': + return ['bun', 'run']; + case 'javascript': + return ['node']; + default: + return ['bash']; } } function getFileName(language: string): string { switch (language) { - case 'bash': return 'run.sh'; - case 'python': return 'run.py'; - case 'typescript': return 'index.ts'; - case 'javascript': return 'index.js'; - default: return 'run.sh'; + case 'bash': + return 'run.sh'; + case 'python': + return 'run.py'; + case 'typescript': + return 'index.ts'; + case 'javascript': + return 'index.js'; + default: + return 'run.sh'; } } -async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit, stepIndex }: RunScriptStepParams): Promise { +async function runScriptStep({ + email, + task, + inputs, + cwd, + abortSignal, + emit, + stepIndex, +}: RunScriptStepParams): Promise { const language = task.language ?? 'bash'; // Write script to temp file @@ -260,7 +329,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit chmodSync(scriptPath, 0o755); const cleanup = () => { - try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* best effort */ + } }; // Build env vars from inputs @@ -275,7 +348,7 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit const runner = getRunner(language); const cmd = [...runner, scriptPath, ...positionalArgs]; - const spawnEnv = { ...process.env as Record, ...inputEnv }; + const spawnEnv = { ...(process.env as Record), ...inputEnv }; console.log(`[pipeline] running script step ${stepIndex}: ${task.name} (cwd=${cwd})`); @@ -305,7 +378,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit // Check abort periodically const abortCheck = setInterval(() => { if (abortSignal.aborted) { - try { proc.kill(); } catch { /* already dead */ } + try { + proc.kill(); + } catch { + /* already dead */ + } } }, 500); @@ -336,7 +413,6 @@ type ForeachParams = { userId: number; email: string; username: string; - role: string; stepIdx: number; step: PipelineStep; stepTask: { name: string; body: string }; @@ -352,10 +428,22 @@ type ForeachParams = { }; async function runForeach({ - userId, email, username, role, stepIdx, step, stepTask, subdirs, baseCwd, - inputs, cwd, abortSignal, totalCost, emit, concurrency, model, + userId, + email, + username, + stepIdx, + step, + stepTask, + subdirs, + baseCwd, + inputs, + cwd, + abortSignal, + totalCost, + emit, + concurrency, + model, }: ForeachParams) { - // Determine skip vs run const toSkip: string[] = []; const toRun: string[] = []; @@ -400,13 +488,15 @@ async function runForeach({ } const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir; - const resolvedCwd = resolveBaseCwd(email, role, cwdRelative); - const targetDir = toAgentPath(resolvedCwd, email, role); - const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir); + const resolvedCwd = resolveBaseCwd(email, cwdRelative); + const targetDir = resolvedCwd; + const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir); try { const cost = await runAgenticStep({ - userId, email, username, role, + userId, + email, + username, taskDirName: step.task, prompt, cwd: resolvedCwd, @@ -424,12 +514,19 @@ async function runForeach({ emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost }); } catch (err) { if (!abortSignal.aborted) { - emit({ type: 'iteration:error', stepIndex: stepIdx, label: subdir, error: err instanceof Error ? err.message : String(err) }); + emit({ + type: 'iteration:error', + stepIndex: stepIdx, + label: subdir, + error: err instanceof Error ? err.message : String(err), + }); } } }; - const p = run().then(() => { executing.delete(p); }); + const p = run().then(() => { + executing.delete(p); + }); executing.add(p); if (executing.size >= concurrency) { @@ -446,7 +543,6 @@ export type ExecutePipelineParams = { userId: number; email: string; username: string; - role: string; taskDirName: string; inputs: Record; cwd?: string; @@ -456,7 +552,18 @@ export type ExecutePipelineParams = { emit: EmitEvent; }; -export async function executePipeline({ userId, email, username, role, taskDirName, inputs, cwd, model: modelOverride, startAt, abortSignal, emit }: ExecutePipelineParams): Promise { +export async function executePipeline({ + userId, + email, + username, + taskDirName, + inputs, + cwd, + model: modelOverride, + startAt, + abortSignal, + emit, +}: ExecutePipelineParams): Promise { const pipelineTask = await getTaskByDirName(taskDirName); if (!pipelineTask) { emit({ type: 'error', message: `Task not found: ${taskDirName}` }); @@ -473,7 +580,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa return; } - const baseCwd = resolveBaseCwd(email, role, cwd); + const baseCwd = resolveBaseCwd(email, cwd); let model = modelOverride || (await resolveModel(userId)); // Claude-only: coerce any legacy non-Claude task-model preference to the Claude default. if (!model.startsWith('claude-code')) model = DEFAULT_MODEL; @@ -532,8 +639,13 @@ export async function executePipeline({ userId, email, username, role, taskDirNa try { await runScriptStep({ - email, role, - task: { name: stepTask.name, implementation: stepTask.implementation, language: stepTask.language ?? 'bash', args: stepTask.args as string[] | null }, + email, + task: { + name: stepTask.name, + implementation: stepTask.implementation, + language: stepTask.language ?? 'bash', + args: stepTask.args as string[] | null, + }, inputs: resolvedInputs, cwd: baseCwd, abortSignal, @@ -566,19 +678,33 @@ export async function executePipeline({ userId, email, username, role, taskDirNa const concurrency = step.concurrency ? runtimeConcurrency : 1; await runForeach({ - userId, email, username, role, - stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body! }, - subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit, concurrency, model, + userId, + email, + username, + stepIdx, + step, + stepTask: { name: stepTask.name, body: stepTask.body! }, + subdirs, + baseCwd, + inputs, + cwd, + abortSignal, + totalCost, + emit, + concurrency, + model, }); } else { // Single execution step emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name }); - const targetDir = toAgentPath(baseCwd, email, role); - const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir); + const targetDir = baseCwd; + const prompt = buildStepPrompt(stepTask.body!, resolvedInputs, targetDir); const cost = await runAgenticStep({ - userId, email, username, role, + userId, + email, + username, taskDirName: step.task, prompt, cwd: baseCwd, @@ -609,8 +735,6 @@ type WSData = { userId: number; email: string; username: string; - role: string; - sandboxed: boolean; }; type ClientMessage = @@ -633,7 +757,7 @@ export async function message(ws: ServerWebSocket, raw: string | Buffer) switch (msg.type) { case 'run': { - const { userId, email, username, role } = ws.data; + const { userId, email, username } = ws.data; // Resolve task name for the DB record const task = await getTaskByDirName(msg.taskDirName); @@ -646,7 +770,6 @@ export async function message(ws: ServerWebSocket, raw: string | Buffer) userId, email, username, - role, taskDirName: msg.taskDirName, taskName: task.name, inputs: msg.inputs, @@ -672,7 +795,13 @@ export async function message(ws: ServerWebSocket, raw: string | Buffer) // Job not live — send the DB state const job = await jobManager.getJob(msg.jobId); if (job) { - send(ws, { type: 'job:state', jobId: msg.jobId, status: job.status, progress: job.progress, cost: job.totalCost }); + send(ws, { + type: 'job:state', + jobId: msg.jobId, + status: job.status, + progress: job.progress, + cost: job.totalCost, + }); } else { send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` }); } @@ -682,7 +811,19 @@ export async function message(ws: ServerWebSocket, raw: string | Buffer) case 'list': { const jobs = await jobManager.getJobsForUser(ws.data.userId); - send(ws, { type: 'job:list', jobs: jobs.map((j) => ({ id: j.id, taskDirName: j.taskDirName, taskName: j.taskName, status: j.status, isLive: j.isLive, totalCost: j.totalCost, createdAt: j.createdAt, completedAt: j.completedAt })) }); + send(ws, { + type: 'job:list', + jobs: jobs.map((j) => ({ + id: j.id, + taskDirName: j.taskDirName, + taskName: j.taskName, + status: j.status, + isLive: j.isLive, + totalCost: j.totalCost, + createdAt: j.createdAt, + completedAt: j.completedAt, + })), + }); break; } } diff --git a/src/servers/api/tasks/pipeline-job-manager.ts b/src/servers/api/tasks/pipeline-job-manager.ts index 90465a8a..6338aa0e 100644 --- a/src/servers/api/tasks/pipeline-job-manager.ts +++ b/src/servers/api/tasks/pipeline-job-manager.ts @@ -27,8 +27,6 @@ type WSData = { userId: number; email: string; username: string; - role: string; - sandboxed: boolean; }; type LiveJob = { @@ -74,9 +72,7 @@ type StartJobParams = { userId: number; email: string; username: string; - role: string; mode?: JobMode; // defaults to 'pipeline' for back-compat with the existing pipeline caller - sandboxed?: boolean; // script jobs only taskDirName: string; taskName: string; inputs: Record; @@ -92,7 +88,10 @@ export function runningCount(): number { // Create a job. action 'start' runs it now; 'queue' runs it only if nothing is running, else it stays // 'pending' and gets promoted when the running job finishes. (Single user → one global queue.) -export async function enqueueJob(params: StartJobParams, action: 'start' | 'queue'): Promise<{ jobId: string; status: 'running' | 'pending' }> { +export async function enqueueJob( + params: StartJobParams, + action: 'start' | 'queue', +): Promise<{ jobId: string; status: 'running' | 'pending' }> { const jobId = randomUUID(); const mode: JobMode = params.mode ?? 'pipeline'; const run = action === 'start' || runningCount() === 0; @@ -145,7 +144,11 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) { if (event.type === 'step:complete' || event.type === 'iteration:complete') { const cost = 'cost' in event ? event.cost : undefined; if (cost) { - const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; + const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? { + inputTokens: 0, + outputTokens: 0, + totalUSD: 0, + }; job.lastCost = { inputTokens: prev.inputTokens + cost.inputTokens, outputTokens: prev.outputTokens + cost.outputTokens, @@ -177,8 +180,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) { ? executeScript({ jobId, email: params.email, - role: params.role, - sandboxed: params.sandboxed ?? false, taskDirName: params.taskDirName, inputs: params.inputs, cwd: params.cwd, @@ -189,7 +190,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) { userId: params.userId, email: params.email, username: params.username, - role: params.role, taskDirName: params.taskDirName, inputs: params.inputs, cwd: params.cwd, @@ -199,36 +199,38 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) { emit, }); - runner.then(async (result) => { - clearInterval(flushInterval); - // Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void. - const exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null; - const failed = exitCode !== null && exitCode !== 0; - await updatePipelineJob(jobId, { - status: failed ? 'failed' : 'completed', - exitCode, - progress: job.lastProgress as Record, - totalCost: job.lastCost as Record, - error: failed ? `Script exited with code ${exitCode}` : undefined, - completedAt: new Date(), - }).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err)); - liveJobs.delete(jobId); - void promoteNext(); - }).catch(async (err) => { - clearInterval(flushInterval); - const message = err instanceof Error ? err.message : String(err); - const isStopped = job.abortSignal.aborted; - broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message }); - await updatePipelineJob(jobId, { - status: isStopped ? 'stopped' : 'failed', - progress: job.lastProgress as Record, - totalCost: job.lastCost as Record, - error: isStopped ? undefined : message, - completedAt: new Date(), - }).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e)); - liveJobs.delete(jobId); - void promoteNext(); - }); + runner + .then(async (result) => { + clearInterval(flushInterval); + // Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void. + const exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null; + const failed = exitCode !== null && exitCode !== 0; + await updatePipelineJob(jobId, { + status: failed ? 'failed' : 'completed', + exitCode, + progress: job.lastProgress as Record, + totalCost: job.lastCost as Record, + error: failed ? `Script exited with code ${exitCode}` : undefined, + completedAt: new Date(), + }).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err)); + liveJobs.delete(jobId); + void promoteNext(); + }) + .catch(async (err) => { + clearInterval(flushInterval); + const message = err instanceof Error ? err.message : String(err); + const isStopped = job.abortSignal.aborted; + broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message }); + await updatePipelineJob(jobId, { + status: isStopped ? 'stopped' : 'failed', + progress: job.lastProgress as Record, + totalCost: job.lastCost as Record, + error: isStopped ? undefined : message, + completedAt: new Date(), + }).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e)); + liveJobs.delete(jobId); + void promoteNext(); + }); } // When a job finishes (and nothing else is running), promote the oldest queued job. Also called on @@ -239,7 +241,9 @@ async function promoteNext(): Promise { if (!next) return; const user = await getUserById(next.userId); if (!user) { - await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch(() => {}); + await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch( + () => {}, + ); return promoteNext(); } await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() }); @@ -248,9 +252,7 @@ async function promoteNext(): Promise { userId: next.userId, email: user.email, username: toShellUsername(user.username ?? '', user.email), - role: user.role ?? '', mode: nextMode, - sandboxed: (user.role ?? '') !== 'Super Admin', taskDirName: next.taskDirName, taskName: next.taskName, inputs: next.inputs as Record, @@ -340,7 +342,9 @@ export async function clearHistory(userId: number): Promise { // Lightweight header-badge summary: how many of the user's jobs are running / queued, and which one // is running (for the "running" badge's link). -export async function getCounts(userId: number): Promise<{ running: number; runningJobId: string | null; queued: number }> { +export async function getCounts( + userId: number, +): Promise<{ running: number; runningJobId: string | null; queued: number }> { let running = 0; let runningJobId: string | null = null; for (const [id, job] of liveJobs) { @@ -386,7 +390,11 @@ function extractProgress(event: JobEvent, prev: unknown): unknown { return { ...p, currentStepIndex: event.stepIndex, - parallel: { taskName: event.taskName, concurrency: event.concurrency, iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })) }, + parallel: { + taskName: event.taskName, + concurrency: event.concurrency, + iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })), + }, }; case 'iteration:start': @@ -396,7 +404,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown { ...p, parallel: { ...parallel, - iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status: 'running' } : it), + iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status: 'running' } : it)), }, }; } @@ -411,7 +419,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown { ...p, parallel: { ...parallel, - iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status } : it), + iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status } : it)), }, }; } diff --git a/src/servers/api/tasks/pipeline-jobs-routes.ts b/src/servers/api/tasks/pipeline-jobs-routes.ts index 474524d8..7fea9c05 100644 --- a/src/servers/api/tasks/pipeline-jobs-routes.ts +++ b/src/servers/api/tasks/pipeline-jobs-routes.ts @@ -39,7 +39,12 @@ pipelineJobsRouter.get('/', async (c) => { // job. Returns { jobId, status }. This is the REST creation path the phone / unattended runs use. pipelineJobsRouter.post('/', async (c) => { const user = c.get('user'); - const body = await c.req.json<{ taskDirName: string; inputs?: Record; cwd?: string; action?: 'start' | 'queue' }>(); + const body = await c.req.json<{ + taskDirName: string; + inputs?: Record; + cwd?: string; + action?: 'start' | 'queue'; + }>(); if (!body.taskDirName) throw errors.BAD_REQUEST('taskDirName is required'); const task = await getTaskByDirName(body.taskDirName); @@ -52,9 +57,7 @@ pipelineJobsRouter.post('/', async (c) => { userId: user.id, email: user.email, username: user.username ?? '', - role: user.role ?? '', mode, - sandboxed: (user.role ?? '') !== 'Super Admin', taskDirName: body.taskDirName, taskName: task.name, inputs: body.inputs ?? {}, diff --git a/src/servers/api/tasks/task-executor.ts b/src/servers/api/tasks/task-executor.ts index e6544e11..810bc6c9 100644 --- a/src/servers/api/tasks/task-executor.ts +++ b/src/servers/api/tasks/task-executor.ts @@ -2,15 +2,12 @@ import type { ServerWebSocket } from 'bun'; import { join, isAbsolute } from 'node:path'; import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs'; import { getTaskByDirName } from './task-files'; -import { getHomeDirForRole, DATA_PATH } from '../../data-path'; -import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox'; +import { getOwnerHomeDir } from '../../data-path'; type WSData = { userId: number; email: string; username: string; - role: string; - sandboxed: boolean; }; type RunMessage = { @@ -79,11 +76,19 @@ function descendantPids(root: number): number[] { function killTree(root: number) { const pids = [root, ...descendantPids(root)]; for (const pid of pids) { - try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already gone */ + } } setTimeout(() => { for (const pid of pids) { - try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } + try { + process.kill(pid, 'SIGKILL'); + } catch { + /* gone */ + } } }, 2000); } @@ -96,21 +101,31 @@ function send(ws: ServerWebSocket, msg: OutMessage) { function getRunner(language: string): string[] { switch (language) { - case 'bash': return ['bash']; - case 'python': return ['python3']; - case 'typescript': return ['bun', 'run']; - case 'javascript': return ['node']; - default: return ['bash']; + case 'bash': + return ['bash']; + case 'python': + return ['python3']; + case 'typescript': + return ['bun', 'run']; + case 'javascript': + return ['node']; + default: + return ['bash']; } } function getFileName(language: string): string { switch (language) { - case 'bash': return 'run.sh'; - case 'python': return 'run.py'; - case 'typescript': return 'index.ts'; - case 'javascript': return 'index.js'; - default: return 'run.sh'; + case 'bash': + return 'run.sh'; + case 'python': + return 'run.py'; + case 'typescript': + return 'index.ts'; + case 'javascript': + return 'index.js'; + default: + return 'run.sh'; } } @@ -142,7 +157,7 @@ function buildArgs(inputs: Record, argsOrder?: string[] | null): } async function handleRun(ws: ServerWebSocket, msg: RunMessage) { - const { email, role, sandboxed } = ws.data; + const { email } = ws.data; // Resolve task from the file-backed store const task = await getTaskByDirName(msg.taskDirName); @@ -178,44 +193,19 @@ async function handleRun(ws: ServerWebSocket, msg: RunMessage) { // msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute // (a missing cwd surfaces as ENOENT naming the binary, not the directory) - const homeDir = getHomeDirForRole(email, role); + const homeDir = getOwnerHomeDir(email); const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir; - let spawnCmd: string[]; - let spawnEnv: Record; - let spawnCwd: string; - - if (sandboxed) { - const prefix = buildSandboxPrefix(email); - const suffix = buildRunuserSuffix(); - - // Translate paths in inputs and args: DATA_PATH/{email}/... → /data/... - const userDataPrefix = join(DATA_PATH, email); - const translatePath = (v: string) => v.startsWith(userDataPrefix) ? '/data' + v.slice(userDataPrefix.length) : v; - - const envArgs: string[] = []; - for (const [key, value] of Object.entries(inputEnv)) { - envArgs.push('--setenv', key, translatePath(value)); - } - - // Translate positional args too - const sandboxCmd = cmd.map((arg) => translatePath(arg)); - - // Script is in /tmp which is a tmpfs inside bwrap — need to bind-mount the host tmp dir - const scriptDir = join(scriptPath, '..'); - const extraMounts = ['--ro-bind', scriptDir, scriptDir]; - - spawnCmd = [...prefix, ...extraMounts, ...envArgs, ...suffix, ...sandboxCmd]; - spawnEnv = {}; - spawnCwd = '/'; - } else { - spawnCmd = cmd; - spawnEnv = { ...process.env as Record, ...inputEnv }; - spawnCwd = cwd; - } + const spawnCmd = cmd; + const spawnEnv = { ...(process.env as Record), ...inputEnv }; + const spawnCwd = cwd; const cleanup = () => { - try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(join(scriptPath, '..'), { recursive: true, force: true }); + } catch { + /* best effort */ + } }; send(ws, { type: 'started', taskName: task.name }); @@ -231,7 +221,11 @@ async function handleRun(ws: ServerWebSocket, msg: RunMessage) { activeProcs.set(ws, { proc, kill: () => { - try { killTree(proc.pid); } catch { /* already dead */ } + try { + killTree(proc.pid); + } catch { + /* already dead */ + } }, }); @@ -239,7 +233,11 @@ async function handleRun(ws: ServerWebSocket, msg: RunMessage) { // file for minutes with no output). Bun's default 120s idle timeout would otherwise close the // socket → close(ws) → killTree kills the task mid-run. A ping resets the idle timer. const keepAlive = setInterval(() => { - try { ws.ping(); } catch { /* socket gone */ } + try { + ws.ping(); + } catch { + /* socket gone */ + } }, 30_000); const stdoutReader = proc.stdout.getReader(); diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 828b268b..682059b3 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -1,17 +1,12 @@ import type { ServerWebSocket } from 'bun'; -import { mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { getHomeDir } from '@@/data-path'; +import { join } from 'node:path'; import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry'; import type { PtyInitConfig } from '../../sidecar/protocol'; -import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../../sidecar/sandbox'; type WSData = { userId: number; email: string; username: string; - role: string; - sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; @@ -49,60 +44,28 @@ const resolveCwd = (home: string, cwd?: string) => { export const terminalWebsocket = { async open(ws: ServerWebSocket) { - const { email, username, role, sandboxed } = ws.data; - const isHost = !sandboxed && role === 'Super Admin'; + const { email, username } = ws.data; - console.log( - `[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`, - ); + console.log(`[terminal] open: email=${email} username=${username}`); if (!isTerminalConnected()) { sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n'); return; } - const sessionId = ws.data.sessionId ?? (isHost ? `host-${ws.data.userId}` : `default-${ws.data.userId}`); + const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`; - // Build PTY init config - let config: PtyInitConfig; - - if (isHost) { - config = { - sessionId, - host: true, - shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, - cwd: resolveCwd(process.env.HOME!, ws.data.cwd), - homeDir: process.env.HOME!, - userLabel: email, - cols: ws.data.cols, - rows: ws.data.rows, - }; - } else { - const homeDir = getHomeDir(email); - mkdirSync(dirname(homeDir), { recursive: true }); - mkdirSync(homeDir, { recursive: true }); - - // Build bwrap command for sandboxed terminal - const prefix = buildSandboxPrefix(email); - prefix.push('--setenv', 'ZDOTDIR', SANDBOX_HOME); - prefix.push('--setenv', 'ZSH', `${SANDBOX_HOME}/.oh-my-zsh`); - prefix.push('--setenv', 'SHELL', '/bin/zsh'); - prefix.push('--setenv', 'USER', username); - prefix.push('--setenv', 'LOGNAME', username); - prefix.push('--setenv', 'OFFICER_TERMINAL_USER', email); - prefix.push('--setenv', 'TERM', 'xterm-256color'); - const bwrapArgs = [...prefix, ...buildRunuserSuffix(), '/bin/zsh', '-i']; - - config = { - sessionId, - shell: { command: bwrapArgs[0]!, args: bwrapArgs.slice(1) }, - cwd: SANDBOX_HOME, - homeDir, - userLabel: email, - cols: ws.data.cols, - rows: ws.data.rows, - }; - } + // The server owner is the only account, so the terminal is always a plain host shell. + const config: PtyInitConfig = { + sessionId, + host: true, + shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, + cwd: resolveCwd(process.env.HOME!, ws.data.cwd), + homeDir: process.env.HOME!, + userLabel: email, + cols: ws.data.cols, + rows: ws.data.rows, + }; // Subscribe to events for this session const unsubOutput = on('pty:output', (msg) => { diff --git a/src/servers/api/users/provision.ts b/src/servers/api/users/provision.ts index 5a4cde19..69b70b4e 100644 --- a/src/servers/api/users/provision.ts +++ b/src/servers/api/users/provision.ts @@ -83,10 +83,3 @@ async function seedShellConfigs(homeDir: string): Promise { // Ensure .local/bin exists mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true }); } - -export function deprovisionUserEnvironment(email: string, _username: string): boolean { - // User data directories are intentionally kept on disk. - // This function exists for API compatibility. - console.log(`[provision] deprovision called for ${email} (no-op, data kept on disk)`); - return true; -} diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index 14fce307..cc9faa13 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -1,111 +1,10 @@ -import { getUsers, getUserByEmail, getUserById, createUser, deleteUser } from 'officerdb'; import { createRouter } from '@@/create-router'; -import { sign } from '@@/jwt'; -import { USER_ROLES } from 'definitions'; -import { sendMail } from 'emailer'; -import * as errors from '@@/custom-errors'; import { originMiddleware } from '@@/_middlewares'; import { updateUserHandler } from './update-user'; -import { deprovisionUserEnvironment } from './provision'; export const usersRouter = createRouter(); usersRouter.use(originMiddleware); -// List all users (Super Admin only) -usersRouter.get('/', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') throw errors.FORBIDDEN(); - - const users = await getUsers(); - const sanitized = users.map(({ password, ...rest }) => rest); - - return ctx.json(sanitized); -}); - -// Self-update (any authenticated user) +// Self-update. Officer is single-user: the server owner is the only account, so there is no user +// listing, invitation or deletion — the account is created once by /auth/bootstrap. usersRouter.put('/', updateUserHandler); - -// Invite a new user (Super Admin only) -usersRouter.post('/invite', async (ctx) => { - const reqUser = ctx.get('user'); - if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN(); - - const { email, role } = ctx.get('body'); - if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - throw errors.BAD_REQUEST('Invalid email address'); - } - - const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin'); - const assignedRole = - typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number]) - ? (role as (typeof USER_ROLES)[number]) - : ('Member' as const); - - const existing = await getUserByEmail(email); - if (existing) throw errors.CONFLICT('A user with this email already exists'); - - const dbUser = await createUser({ - email, - role: assignedRole, - status: 'Invited', - }); - - const origin = ctx.get('origin'); - const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h'); - const url = `${origin}/auth/verify?verificationCode=${verificationCode}`; - - await sendMail({ - template: 'UserInvite', - subject: 'You have been invited to officer.dev', - to: email, - data: { invitedBy: reqUser.name ?? reqUser.email, url }, - }); - - const { password, ...safeUser } = dbUser; - return ctx.json(safeUser); -}); - -// Resend invite (Super Admin only, status must be Invited) -usersRouter.post('/:id/resend-invite', async (ctx) => { - const reqUser = ctx.get('user'); - if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN(); - - const id = Number(ctx.req.param('id')); - if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID'); - - const target = await getUserById(id); - if (!target) throw errors.NOT_FOUND('User not found'); - if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status'); - - const origin = ctx.get('origin'); - const verificationCode = await sign({ id: target.id, email: target.email }, '24h'); - const url = `${origin}/auth/verify?verificationCode=${verificationCode}`; - - await sendMail({ - template: 'UserInvite', - subject: 'You have been invited to officer.dev', - to: target.email, - data: { invitedBy: reqUser.name ?? reqUser.email, url }, - }); - - return ctx.json({ ok: true }); -}); - -// Delete a user (Super Admin only, cannot delete self) -usersRouter.delete('/:id', async (ctx) => { - const reqUser = ctx.get('user'); - if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN(); - - const id = Number(ctx.req.param('id')); - if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID'); - if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself'); - - const target = await getUserById(id); - if (!target) throw errors.NOT_FOUND('User not found'); - - // Deprovision user environment before deleting from database - deprovisionUserEnvironment(target.email, target.username ?? ''); - - await deleteUser(id); - return ctx.json({ ok: true }); -}); diff --git a/src/servers/channels/routes.ts b/src/servers/channels/routes.ts index 16cf05fe..bd40b605 100644 --- a/src/servers/channels/routes.ts +++ b/src/servers/channels/routes.ts @@ -17,9 +17,6 @@ export const channelsRouter = createRouter(); // ── Admin: Discord config ── channelsRouter.get('/discord/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const integration = await getServerIntegration('discord'); if (!integration) return ctx.json({ configured: false }); @@ -36,9 +33,6 @@ channelsRouter.get('/discord/config', async (ctx) => { }); channelsRouter.put('/discord/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const body = ctx.get('body') as Record; const botToken = body.botToken as string | undefined; const enabled = body.enabled as boolean | undefined; @@ -131,9 +125,6 @@ channelsRouter.delete('/discord/connection', async (ctx) => { // ── Admin: Telegram config ── channelsRouter.get('/telegram/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const integration = await getServerIntegration('telegram'); if (!integration) return ctx.json({ configured: false }); @@ -150,9 +141,6 @@ channelsRouter.get('/telegram/config', async (ctx) => { }); channelsRouter.put('/telegram/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const body = ctx.get('body') as Record; const botToken = body.botToken as string | undefined; const enabled = body.enabled as boolean | undefined; @@ -245,9 +233,6 @@ channelsRouter.delete('/telegram/connection', async (ctx) => { // ── Admin: WhatsApp config ── channelsRouter.get('/whatsapp/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const integration = await getServerIntegration('whatsapp'); return ctx.json({ @@ -259,9 +244,6 @@ channelsRouter.get('/whatsapp/config', async (ctx) => { }); channelsRouter.put('/whatsapp/config', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const body = ctx.get('body') as Record; const enabled = body.enabled as boolean | undefined; @@ -295,9 +277,6 @@ channelsRouter.get('/whatsapp/status', async (ctx) => { }); channelsRouter.get('/whatsapp/qr', async (ctx) => { - const user = ctx.get('user'); - if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403); - const qr = getWhatsAppQR(); return ctx.json({ qr, diff --git a/src/servers/channels/send-and-await.ts b/src/servers/channels/send-and-await.ts index 1fa6445b..1089e972 100644 --- a/src/servers/channels/send-and-await.ts +++ b/src/servers/channels/send-and-await.ts @@ -13,7 +13,6 @@ type SendAndAwaitParams = { context: string; contextId: string; model?: string; - role?: string; }; type SendAndAwaitResult = { @@ -83,7 +82,6 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise void; }; diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index 5a55afc4..f37bd73b 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -25,10 +25,13 @@ export const AGENT_CONFIG_DIR = join(homedir(), '.pi', 'agent'); export const SEED_PATH = resolve(import.meta.dir, '../../seed'); +// The managed home under DATA_PATH — what provisioning seeds and what the generated Claude config +// points at. Distinct from the owner's real login home below. export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); -export const getHomeDirForRole = (email: string, role: string | null): string => - role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email); +// Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform +// terminals/chats/tasks share config and credentials with the shell they use outside Officer. +export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email); export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 5e765558..581b5dcc 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -33,7 +33,7 @@ import { chatRouter } from './api/chat/chat'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { broadcastPanelRefresh } from './api/terminal/websocket'; import { CustomError } from './custom-errors'; -import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares'; +import { userMiddleware, bodyParser, isOriginAllowed } from './_middlewares'; export { Hono }; export { createRouter }; @@ -77,7 +77,6 @@ const protectedRouter = createRouter(); protectedRouter.use(bodyParser()); protectedRouter.use(userMiddleware); -serverSettingsRouter.use(superAdminMiddleware); protectedRouter.route('/server-settings', serverSettingsRouter); protectedRouter.route('/users', usersRouter); protectedRouter.route('/plans', plansRouter); @@ -104,7 +103,6 @@ protectedRouter.route('/bug-report', bugReportRouter); protectedRouter.route('/chat', chatRouter); protectedRouter.route('/pipeline-jobs', pipelineJobsRouter); protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI -desktopRouter.use(superAdminMiddleware); protectedRouter.route('/desktop', desktopRouter); honoServer.route('/api', protectedRouter); diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 7d4bd929..f512ba89 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -2,7 +2,6 @@ import { join } from 'node:path'; import type { Subprocess } from 'bun'; import type { ChatEvent } from '../../api/chat/types'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol'; -import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { parseStream } from './stream-parser'; @@ -16,26 +15,13 @@ const CLAUDE_BIN = '/usr/local/bin/claude'; const HOST_HOME = process.env.HOME!; const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); -// Build full bwrap sandbox args for Claude (prefix + Anthropic env + runuser suffix) -function buildSandboxArgs(email: string): string[] { - const prefix = buildSandboxPrefix(email); - - // Claude-specific env vars - if (process.env.ANTHROPIC_BASE_URL) prefix.push('--setenv', 'ANTHROPIC_BASE_URL', process.env.ANTHROPIC_BASE_URL); - if (process.env.ANTHROPIC_API_KEY) prefix.push('--setenv', 'ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY); - - return [...prefix, ...buildRunuserSuffix()]; -} - // Active streaming processes const activeProcs = new Map(); // MCP config paths, set by user-instance at startup -let mcpSandboxPath: string | undefined; // path inside bwrap sandbox (/data/...) let mcpHostPath: string | undefined; // path on the host filesystem -export function setMcpConfigPath(sandboxPath: string, hostPath: string): void { - mcpSandboxPath = sandboxPath; +export function setMcpConfigPath(hostPath: string): void { mcpHostPath = hostPath; } @@ -57,8 +43,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise string; - }; - const contextFile = generateContainerContext(email!); - writeFileSync(join(claudeDir, 'CLAUDE.md'), readFileSync(contextFile, 'utf-8')); -} - // ── MCP config ── -type McpPaths = { sandboxPath: string; hostPath: string }; - -function generateMcpConfig(): McpPaths { +function generateMcpConfig(): string { const contextDir = join(DATA_PATH, email!, '.container-context'); mkdirSync(contextDir, { recursive: true }); const userRoot = join(DATA_PATH, email!); - // Sandbox config (paths relative to /data mount) - const sandboxToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [`${SANDBOX_DATA}/tools`] : [])].join(':'); - const sandboxConfig = { - mcpServers: { - 'officer-tools': { - type: 'stdio', - command: 'bun', - args: ['run', MCP_SERVER_SCRIPT], - env: { - PI_TOOLS_DIRS: sandboxToolsDirs, - OFFICER_EMAIL_DB: `${SANDBOX_DATA}/${emailDbRel}`, - MCP_TOOLS_LOG: `${SANDBOX_DATA}/logs/mcp-tools.log`, - OFFICER_API_URL, - OFFICER_AUTH_TOKEN, - }, - }, - }, - }; - writeFileSync(join(contextDir, 'mcp.json'), JSON.stringify(sandboxConfig)); - - // Host config (real filesystem paths, for Super Admin) const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':'); const hostConfig = { mcpServers: { @@ -125,27 +82,16 @@ function generateMcpConfig(): McpPaths { }; writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig)); - return { - sandboxPath: `${SANDBOX_DATA}/.container-context/mcp.json`, - hostPath: join(contextDir, 'mcp-host.json'), - }; + return join(contextDir, 'mcp-host.json'); } // ── Startup ── -// Only sandboxed users get the generated container CLAUDE.md. For the un-isolated Super Admin, HOME is -// the real home, so writing it there would pollute the personal global ~/.claude/CLAUDE.md (loaded by -// the terminal `claude` too) — parity means running as the user, not injecting platform context. -if (dbUser.role !== 'Super Admin') { - try { - refreshClaudeMd(); - } catch (err) { - console.error(`[claude:${email}] failed to refresh CLAUDE.md:`, err instanceof Error ? err.message : err); - } -} +// The owner runs un-isolated with HOME as their real home, so the generated container CLAUDE.md is +// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the +// terminal `claude` loads too. -const mcpPaths = generateMcpConfig(); -setMcpConfigPath(mcpPaths.sandboxPath, mcpPaths.hostPath); +setMcpConfigPath(generateMcpConfig()); console.log(`[claude:${email}] started (HOME=${homeDir})`); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index cc3720ce..22bac0e6 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -70,7 +70,6 @@ export type ClaudeSpawnParams = { prompt: string; sessionKey: string; model?: string; - role?: string; cwd?: string; }; @@ -82,7 +81,6 @@ export type ClaudeSpawnStreamingParams = { sessionKey: string; cwd?: string; model?: string; - role?: string; resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list) }; @@ -108,7 +106,6 @@ export type OpenCodeRunParams = { export type VncStartParams = { email: string; username: string; - role: string | null; resolution?: string; }; diff --git a/src/servers/sidecar/sandbox.ts b/src/servers/sidecar/sandbox.ts deleted file mode 100644 index 6c81815b..00000000 --- a/src/servers/sidecar/sandbox.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { existsSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; - -// Resolve paths for sandbox -const BUN_DIR = (() => { - const result = Bun.spawnSync({ cmd: ['which', 'bun'], stdout: 'pipe', stderr: 'ignore' }); - const binDir = dirname(result.stdout.toString().trim()); - return dirname(binDir); // e.g. /home/pastilhas/.bun -})(); - -const PROJECT_ROOT = resolve(import.meta.dir, '../../..'); -const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); -const OFFICER_ITEMS_DIR = process.env.OFFICER_ITEMS_DIR ?? join(process.cwd(), 'officer-items'); -const HOST_HOME = process.env.HOME!; - -// Resolve the OS username for runuser to drop privileges inside the sandbox -const OS_USERNAME = (() => { - const result = Bun.spawnSync({ cmd: ['id', '-un'], stdout: 'pipe', stderr: 'ignore' }); - return result.stdout.toString().trim() || 'pastilhas'; -})(); - -// Sandbox mount points -export const SANDBOX_DATA = '/data'; -export const SANDBOX_HOME = `${SANDBOX_DATA}/home`; -export const SANDBOX_GLOBAL_ROOT = '/officer'; -export const SANDBOX_GLOBAL_SKILLS = `${SANDBOX_GLOBAL_ROOT}/skills`; -export const SANDBOX_GLOBAL_EXTENSIONS = `${SANDBOX_GLOBAL_ROOT}/extensions`; -export const SANDBOX_GLOBAL_TOOLS = `${SANDBOX_GLOBAL_ROOT}/tools`; - -// Build bwrap sandbox prefix for a given user email. -// Returns args up to (but not including) the `-- runuser` suffix. -// Callers can append extra `--setenv` args before calling `buildRunuserSuffix()`. -export function buildSandboxPrefix(email: string): string[] { - const userDataDir = join(DATA_PATH, email); - const globalSkillsDir = join(OFFICER_ITEMS_DIR, 'skills'); - const globalToolsDir = join(OFFICER_ITEMS_DIR, 'tools'); - const globalExtensionsDir = join(OFFICER_ITEMS_DIR, 'extensions'); - - const args = [ - 'sudo', - 'bwrap', - '--share-net', - '--die-with-parent', - '--proc', - '/proc', - '--dev', - '/dev', - '--perms', - '1777', - '--tmpfs', - '/tmp', - // System (read-only) - '--ro-bind', - '/usr', - '/usr', - '--ro-bind', - '/lib', - '/lib', - '--ro-bind', - '/bin', - '/bin', - '--ro-bind', - '/etc', - '/etc', - // /run is needed for systemd-resolved DNS (resolv.conf symlink target) - '--ro-bind', - '/run', - '/run', - ]; - - // Optional system paths - if (existsSync('/lib64')) args.push('--ro-bind', '/lib64', '/lib64'); - if (existsSync('/sbin')) args.push('--ro-bind', '/sbin', '/sbin'); - - // Ensure intermediate dirs under HOME are traversable after runuser drops privileges - // (bwrap auto-creates them as root-owned drwx------) - const homeDir = HOST_HOME; - args.push('--perms', '0755', '--dir', homeDir); - - // Bun runtime (e.g. /home/pastilhas/.bun) - args.push('--ro-bind', BUN_DIR, BUN_DIR); - - // User-local installs (~/.local) — claude binary, pi npm packages, etc. - const localDir = join(homeDir, '.local'); - if (existsSync(localDir)) { - args.push('--ro-bind', localDir, localDir); - } - - // Project source (for MCP server) - args.push('--ro-bind', PROJECT_ROOT, PROJECT_ROOT); - - // Ensure DATA_PATH intermediate dirs are traversable (same issue as HOME) - args.push('--perms', '0755', '--dir', DATA_PATH); - - // Global content mounted at original paths for existing host-path references - if (existsSync(globalSkillsDir)) args.push('--ro-bind', globalSkillsDir, globalSkillsDir); - if (existsSync(globalToolsDir)) args.push('--ro-bind', globalToolsDir, globalToolsDir); - if (existsSync(globalExtensionsDir)) args.push('--ro-bind', globalExtensionsDir, globalExtensionsDir); - - // Ensure sandbox-local global root is traversable before mounting nested paths under it. - args.push('--perms', '0755', '--dir', SANDBOX_GLOBAL_ROOT); - - // Global content also mounted at short sandbox-local paths so nested imports do not - // depend on traversing host-specific parent directories created by bwrap. - if (existsSync(globalSkillsDir)) args.push('--ro-bind', globalSkillsDir, SANDBOX_GLOBAL_SKILLS); - if (existsSync(globalToolsDir)) args.push('--ro-bind', globalToolsDir, SANDBOX_GLOBAL_TOOLS); - if (existsSync(globalExtensionsDir)) args.push('--ro-bind', globalExtensionsDir, SANDBOX_GLOBAL_EXTENSIONS); - - // User data (read-write, mounted at /data to avoid intermediate dir permission issues) - args.push('--bind', userDataDir, SANDBOX_DATA); - - // Common env vars inside the sandbox (sudo strips the environment) - args.push('--setenv', 'HOME', SANDBOX_HOME); - args.push('--setenv', 'PATH', process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'); - - // Set working directory inside the sandbox - args.push('--chdir', SANDBOX_HOME); - - return args; -} - -// Build the runuser suffix that drops privileges to the OS user. -// Append this after any extra --setenv args. -export function buildRunuserSuffix(): string[] { - return ['--', 'runuser', '--preserve-environment', '-u', OS_USERNAME, '--']; -} diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index 37f028c5..aaae0add 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import type { VncStartParams, VncSessionInfo } from '../protocol'; -import { getHomeDirForRole } from '@@/data-path'; +import { getOwnerHomeDir } from '@@/data-path'; // Mirrors the physical display instead of spawning a virtual desktop per user, so the // browser shows the same session as the screen. There is exactly one :0, hence one @@ -106,7 +106,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb } mirror = null; - const homeDir = getHomeDirForRole(params.email, params.role); + const homeDir = getOwnerHomeDir(params.email); const passwdFile = await ensureVncPassword(homeDir); const xauthority = resolveXauthority(); diff --git a/src/workspaces/definitions/src/index.ts b/src/workspaces/definitions/src/index.ts index 830b5eec..af3c5ebd 100644 --- a/src/workspaces/definitions/src/index.ts +++ b/src/workspaces/definitions/src/index.ts @@ -1,4 +1,3 @@ -export const USER_ROLES = ['Member', 'Admin', 'Owner', 'Super Admin'] as const; export const USER_STATUSES = ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] as const; export const COMPANY_SIZES = ['1-10', '11-30', '31-50', '50+'] as const; diff --git a/src/workspaces/emailer/emails/UserInvite.tsx b/src/workspaces/emailer/emails/UserInvite.tsx deleted file mode 100644 index 8ef5d6ce..00000000 --- a/src/workspaces/emailer/emails/UserInvite.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Body, Html, Head, Container, Img, Tailwind } from '@react-email/components'; -import { Text, Button } from '@react-email/components'; - -type EmailProps = { - invitedBy: string; - url: string; -}; - -const Email = ({ invitedBy, url }: EmailProps) => { - return ( - - - - - - officer.dev - You're invited to officer.dev - You have been invited by {invitedBy || ''} to join officer.dev. - Click the button below to set up your account. - - - - - - ); -}; - -export default Email; diff --git a/src/workspaces/emailer/emails/VerifyAdmin.tsx b/src/workspaces/emailer/emails/VerifyAdmin.tsx deleted file mode 100644 index d5f08f7e..00000000 --- a/src/workspaces/emailer/emails/VerifyAdmin.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Layout from './layouts/MainLayout.jsx'; -import { Container } from '@react-email/components'; -import { Text, Button } from '@react-email/components'; - -type EmailProps = { - name: string; - url: string; -}; -const Email = ({ name, url }: EmailProps) => { - return ( - - - Welcome, {name || 'there'} - You can ignore this email if you didn't signup for our site. - - - - ); -}; - -export default Email; diff --git a/src/workspaces/emailer/emails/VerifyRegistration.tsx b/src/workspaces/emailer/emails/VerifyRegistration.tsx deleted file mode 100644 index 360ac064..00000000 --- a/src/workspaces/emailer/emails/VerifyRegistration.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Layout from './layouts/MainLayout.jsx'; -import { Container } from '@react-email/components'; -import { Text, Button } from '@react-email/components'; - -type EmailProps = { - name: string; - url: string; -}; -const Email = ({ name, url }: EmailProps) => { - return ( - - - Welcome, {name || 'there'} - You can ignore this email if you didn't signup for our site. - - - - ); -}; - -export default Email; diff --git a/src/workspaces/hooks/src/useAuth/types.ts b/src/workspaces/hooks/src/useAuth/types.ts index 7d0ce137..954de50d 100644 --- a/src/workspaces/hooks/src/useAuth/types.ts +++ b/src/workspaces/hooks/src/useAuth/types.ts @@ -1,9 +1,3 @@ -export type SignupUserForm = { - email?: string; - name?: string; - password?: string; -}; - export type UpdateUserPayload = { name: string; username?: string; avatar: string }; export type ResetPasswordPayload = { password: string; verificationCode: string }; diff --git a/src/workspaces/hooks/src/useAuth/useAuth.ts b/src/workspaces/hooks/src/useAuth/useAuth.ts index 7ab90cd2..b8aa8b95 100644 --- a/src/workspaces/hooks/src/useAuth/useAuth.ts +++ b/src/workspaces/hooks/src/useAuth/useAuth.ts @@ -15,7 +15,6 @@ export const useAuth = (props: UseAuthProps = {}) => { const apiClient = useClient(apiUrl); const passKeyManager = usePasskeys(); - const { isLoading } = useQuery({ queryKey: ['CURRENT_USER'], refetchOnMount: false, @@ -70,22 +69,10 @@ export const useAuth = (props: UseAuthProps = {}) => { localStorage.removeItem('CURRENT_USER'); }; - const signup = async (newUser: SignupUserForm) => { - return await authClient.post('/signup', newUser); - }; - const resetPassword = async (payload: ResetPasswordPayload) => { await authClient.post('/reset-password', payload); }; - const verify = async (payload: VerifyPayload) => { - const data = await authClient.post('/verify', payload); - if (data.token) { - localStorage.setItem('BEARER_TOKEN', data.token); - queryClient.invalidateQueries({ queryKey: ['CURRENT_USER'] }); - } - }; - const updateUser = async (payload: UpdateUserPayload) => { await apiClient.put('/users', payload); localStorage.removeItem('CURRENT_USER'); @@ -112,8 +99,6 @@ export const useAuth = (props: UseAuthProps = {}) => { refreshUser, signin, signout, - signup, - verify, resetPassword, updateUser, changePassword, @@ -122,12 +107,6 @@ export const useAuth = (props: UseAuthProps = {}) => { }; }; -export type SignupUserForm = { - email?: string; - name?: string; - password?: string; -}; - export type UpdateUserPayload = { name: string; username?: string; @@ -145,11 +124,4 @@ export type ChangePasswordPayload = { confirmPassword: string; }; -export type VerifyPayload = { - verificationCode: string; - name?: string; - password?: string; - confirmPassword?: string; -}; - export type ForgotPasswordPayload = { email: string }; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index b37b6772..f0c722cb 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -6,7 +6,6 @@ import { EmbeddableChat } from './EmbeddableChat'; type ChatPanelInnerProps = { scoped: boolean; - sandboxed: boolean; cwdParam?: { root?: string; path: string }; promptPrefix?: string; chatContext: Record; @@ -14,7 +13,14 @@ type ChatPanelInnerProps = { onTurnComplete?: (hadToolCalls: boolean) => void; }; -const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext, setActiveSession, onTurnComplete }: ChatPanelInnerProps) => { +const ChatPanelInner = ({ + scoped, + cwdParam, + promptPrefix, + chatContext, + setActiveSession, + onTurnComplete, +}: ChatPanelInnerProps) => { const chat = useChat(undefined, undefined, { replaceUrl: false, projectScoped: scoped, @@ -31,7 +37,6 @@ const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext className="h-full" chat={chat} cwd={cwdParam} - sandboxed={sandboxed} replaceUrl={false} promptPrefix={promptPrefix} {...chatContext} @@ -42,8 +47,6 @@ const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext export const ChatPanelWrapper = () => { const { dashboardId, cwd, root, promptPrefix } = useWorkspace(); const scoped = cwd !== '~'; - const hostRoot = root === '~' || root === 'officer.dev'; - const sandboxed = !hostRoot; const chatContext = dashboardId === 'email' || dashboardId === 'screens/email' @@ -73,7 +76,6 @@ export const ChatPanelWrapper = () => { return ( void) { - const { - initialMessage, - defaultInput = '', - promptPrefix, - cwd, - sandboxed, - autoSend = false, - chat: externalChat, - } = params; + const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params; const internalChat = useChat(params.sessionId, params.initialModel, { replaceUrl: params.replaceUrl ?? false, @@ -111,7 +102,6 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp images.length > 0 ? images : undefined, cwd, undefined, - sandboxed, thinkingLevel, displayText, ); @@ -208,7 +198,6 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp initialMessage.images, initialMessage.cwd, undefined, - sandboxed, ); } }, [initialMessage, isConnected]); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 259f9d28..0f6d7fb6 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -60,8 +60,6 @@ type NewChatProps = { function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) { const location = useLocation(); const locationState = location.state as ChatLocationState; - const { user } = useAuth(); - const isSuperAdmin = user?.role === 'Super Admin'; const { invalidate: invalidateClaudeSessions } = useClaudeSessions(); // Refresh the /chat list — Claude has just written/appended this session's transcript. @@ -79,7 +77,6 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro context: 'chat', }); - const sandboxed = !isSuperAdmin; // Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions). const [activeCwd] = usePanelChannel('chat:active-cwd', null); const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd; @@ -103,7 +100,6 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro initialMessage={initialMessage} defaultInput={locationState?.prefillInput ?? ''} cwd={cwd} - sandboxed={sandboxed} className="flex-1 min-h-0" /> diff --git a/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx b/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx index 9c6c412f..26e6583a 100644 --- a/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx @@ -1,16 +1,3 @@ -import { useAuth } from 'hooks/useAuth'; import { DesktopView } from './DesktopView'; -export const DesktopWrapper = () => { - const { user } = useAuth(); - - if (user?.role !== 'Super Admin') { - return ( -
- Remote Desktop requires Super Admin permissions. -
- ); - } - - return ; -}; +export const DesktopWrapper = () => ; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx index ab47e49b..7e59fa24 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/CliampPanel.tsx @@ -32,13 +32,5 @@ export const CliampPanelBody = () => { }); }, [setSearchParams]); - return ( - - ); + return ; }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 8b635a90..22fce617 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -12,7 +12,13 @@ import { useClient } from 'hooks/useClient'; import type { TaskSummary } from '../../useTasks'; import { useTaskRunner } from './useTaskRunner'; import { usePipelineRunner } from './usePipelineRunner'; -import { useFilesAPI, type AudioTrack, type SubtitleTrack, type FolderProbe, type FolderTrackGroup } from '../../../../hooks/useFilesAPI'; +import { + useFilesAPI, + type AudioTrack, + type SubtitleTrack, + type FolderProbe, + type FolderTrackGroup, +} from '../../../../hooks/useFilesAPI'; const playDing = () => { const ctx = new AudioContext(); @@ -46,11 +52,17 @@ type AgenticTaskRunnerProps = { cwd: { root?: string; path: string }; initialModel: string | null; taskInfo: TaskInfo; - sandboxed?: boolean; context: Record; }; -const AgenticTaskRunner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, sandboxed, context }: AgenticTaskRunnerProps) => { +const AgenticTaskRunner = ({ + taskDirName, + defaultInput, + cwd, + initialModel, + taskInfo, + context, +}: AgenticTaskRunnerProps) => { const [phase, setPhase] = useState('ready'); const chat = useChat(undefined, initialModel, { replaceUrl: false, taskInfo }); const availableModels = useUserVisibleModels(); @@ -159,7 +171,7 @@ const AgenticTaskRunner = ({ taskDirName, defaultInput, cwd, initialModel, taskI prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`; } setPhase('running'); - chat.sendPrompt(prompt, undefined, undefined, cwd, undefined, sandboxed); + chat.sendPrompt(prompt, undefined, undefined, cwd, undefined); }; const hasConfigurableInputs = inputDefs && Object.keys(inputDefs).length > 0; @@ -272,8 +284,10 @@ const trackLabel = (t: { title: string; lang: string; id: number }) => t.title || (t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '') || `Track ${t.id + 1}`; // Channel count → friendly layout name; audio meta → "stereo · aac 193k"-style detail for a track row. -const chLabel = (n: number) => (n === 1 ? 'mono' : n === 2 ? 'stereo' : n === 6 ? '5.1' : n === 8 ? '7.1' : n ? `${n}ch` : ''); -const audioMeta = (t: AudioTrack) => [chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : ''); +const chLabel = (n: number) => + n === 1 ? 'mono' : n === 2 ? 'stereo' : n === 6 ? '5.1' : n === 8 ? '7.1' : n ? `${n}ch` : ''; +const audioMeta = (t: AudioTrack) => + [chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : ''); // subtitle_edit input: per-subtitle keep flag + editable label, serialized to JSON in the form value. type SubtitleEditEntry = { id: number; keep: boolean; label: string }; @@ -288,7 +302,17 @@ const parseSubtitleSpec = (raw?: string): SubtitleEditEntry[] => { } }; -const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTracks, subtitleTracks, probing, entryType, hideTrackPickers }: TaskInputFormProps) => { +const TaskInputForm = ({ + inputDefs, + values, + onChange, + autoFilledKeys, + audioTracks, + subtitleTracks, + probing, + entryType, + hideTrackPickers, +}: TaskInputFormProps) => { const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key)); if (configurableInputs.length === 0) return null; @@ -298,16 +322,21 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack if (def.type === 'subtitle_edit') { const tracks = subtitleTracks ?? []; const byId = new Map(parseSubtitleSpec(values[key]).map((e) => [e.id, e])); - const entryFor = (t: SubtitleTrack): SubtitleEditEntry => byId.get(t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }; + const entryFor = (t: SubtitleTrack): SubtitleEditEntry => + byId.get(t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }; const update = (id: number, patch: Partial) => - onChange(key, JSON.stringify(tracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))))); + onChange( + key, + JSON.stringify(tracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t)))), + ); const keptCount = tracks.filter((t) => entryFor(t).keep).length; return (
{def.description ?? key} {probing ? ( - {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} + {' '} + {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} ) : tracks.length === 0 ? ( No subtitles @@ -372,18 +401,32 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack {def.description ?? key} {probing ? ( - {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} + {' '} + {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} ) : tracks.length === 0 ? ( None ) : (
{tracks.map((t) => ( -
)} @@ -1339,11 +1491,15 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
{ps.iterations.map((it) => (
- {it.status === 'pending' && } + {it.status === 'pending' && ( + + )} {it.status === 'running' && } {it.status === 'complete' && } {it.status === 'error' && } - + {it.label} {it.cost && ( @@ -1398,7 +1554,9 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => { )} {pipeline.totalCost && ( - {formatElapsed(pipeline.elapsed)} · {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · ${pipeline.totalCost.totalUSD.toFixed(3)} + {formatElapsed(pipeline.elapsed)} ·{' '} + {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · $ + {pipeline.totalCost.totalUSD.toFixed(3)} )} {pipeline.skippedItems.length > 0 && ( @@ -1408,7 +1566,10 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => { )} {pipeline.jobId && ( {slug} - {isSuperAdmin && port && ( - :{port} - )} + {port && :{port}}