remove the dead multi-user surface
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -77,7 +77,6 @@ async function migrate() {
|
|||||||
.values({
|
.values({
|
||||||
email: u.email,
|
email: u.email,
|
||||||
password: u.password,
|
password: u.password,
|
||||||
role: u.role as 'Member' | 'Admin' | 'Owner' | 'Super Admin',
|
|
||||||
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
|
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
|
||||||
name: u.name,
|
name: u.name,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useServerEnvironment } from 'state/useServerEnvironment';
|
|||||||
import { useInitialData } from '@/state/useInitialData';
|
import { useInitialData } from '@/state/useInitialData';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { isLoading, isAuthenticated, user } = useAuth();
|
const { isLoading, isAuthenticated } = useAuth();
|
||||||
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
|
const { onboardingComplete, plugins, isLoading: isServerSettingsLoading } = useServerSettings();
|
||||||
useServerEnvironment();
|
useServerEnvironment();
|
||||||
useInitialData();
|
useInitialData();
|
||||||
@@ -20,7 +20,6 @@ export function App() {
|
|||||||
<Authentication.AuthenticationLayout>
|
<Authentication.AuthenticationLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Authentication.LandingPage />} />
|
<Route path="/" element={<Authentication.LandingPage />} />
|
||||||
<Route path="/auth/verify" element={<Authentication.VerifyScreen />} />
|
|
||||||
<Route path="/auth/forgot-password" element={<Authentication.ForgotPassword />} />
|
<Route path="/auth/forgot-password" element={<Authentication.ForgotPassword />} />
|
||||||
<Route path="/auth/reset-password" element={<Authentication.ResetPassword />} />
|
<Route path="/auth/reset-password" element={<Authentication.ResetPassword />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
@@ -40,14 +39,7 @@ export function App() {
|
|||||||
<Route path="/" element={<Dashboard.HomeScreen />} />
|
<Route path="/" element={<Dashboard.HomeScreen />} />
|
||||||
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
|
<Route path="/settings/profile" element={<Dashboard.ProfileSettings />} />
|
||||||
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
|
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
|
||||||
<Route
|
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
|
||||||
path="/settings/system"
|
|
||||||
element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/settings/users"
|
|
||||||
element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />}
|
|
||||||
/>
|
|
||||||
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
|
||||||
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
|
<Route path="/settings/apps" element={<Dashboard.AppsSettings />} />
|
||||||
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
<Route path="/chat" element={<Dashboard.SessionListPage />} />
|
||||||
@@ -72,10 +64,7 @@ export function App() {
|
|||||||
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
<Route path="/email/:emailId" element={<Dashboard.EmailScreen />} />
|
||||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||||
<Route
|
<Route path="/desktop" element={<Dashboard.DesktopScreen />} />
|
||||||
path="/desktop"
|
|
||||||
element={user?.role === 'Super Admin' ? <Dashboard.DesktopScreen /> : <Navigate to="/" replace />}
|
|
||||||
/>
|
|
||||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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<VerifyFormState>({ email: '' });
|
|
||||||
|
|
||||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
|
||||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('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 && (
|
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
|
||||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invalid && (
|
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
|
||||||
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className={cn('flex flex-col gap-6', hideForm && 'hidden')}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-duck-dark text-2xl font-bold">
|
|
||||||
{flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'}
|
|
||||||
</div>
|
|
||||||
<div className="text-duck-dark/60">
|
|
||||||
{flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">Email</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">Name</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
placeholder="Your name"
|
|
||||||
autoComplete="name"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">Username</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
placeholder="your-username"
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="grid md:flex gap-4">
|
|
||||||
<Label className="grid gap-2 flex-1">
|
|
||||||
<span className="text-duck-dark/70">Password</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Min 12 characters"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2 flex-1">
|
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="password"
|
|
||||||
name="confirmPassword"
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateForm = (state: Partial<VerifyFormState>) => {
|
|
||||||
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';
|
|
||||||
@@ -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 && (
|
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
|
||||||
<div className="text-center text-duck-dark/60">Verifying...</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{invalid && (
|
|
||||||
<Card className="py-12 px-24 flex flex-col gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-duck-dark text-2xl font-bold">Invalid Link</div>
|
|
||||||
<div className="text-duck-dark/60">This verification link is invalid or has expired.</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card className={cn("flex flex-col gap-6", hideform && "hidden")}>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-duck-dark text-2xl font-bold">
|
|
||||||
{flow === 'bootstrap' ? 'Create Your Account' : 'Accept Invitation'}
|
|
||||||
</div>
|
|
||||||
<div className="text-duck-dark/60">
|
|
||||||
{flow === 'bootstrap' ? 'Set up your administrator profile' : 'Set up your profile to get started'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">Email</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">Name</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
placeholder="Your name"
|
|
||||||
autoComplete="name"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2">
|
|
||||||
<span className="text-duck-dark/70">Username</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
placeholder="your-username"
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="grid md:flex gap-4">
|
|
||||||
<Label className="grid gap-2 flex-1">
|
|
||||||
<span className="text-duck-dark/70">Password</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
placeholder="Min 12 characters"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Label className="grid gap-2 flex-1">
|
|
||||||
<span className="text-duck-dark/70">Confirm Password</span>
|
|
||||||
<Input
|
|
||||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
|
||||||
type="password"
|
|
||||||
name="confirmPassword"
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold text-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card >
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './VerifyScreen';
|
|
||||||
@@ -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<VerifyFormState>({ email: '' });
|
|
||||||
|
|
||||||
const [verificationCode] = useState(() => new URL(window.location.href).searchParams.get('verificationCode') ?? '');
|
|
||||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('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<VerifyFormState>) => {
|
|
||||||
const { name, username, password, confirmPassword } = state;
|
|
||||||
if (!name || !username || !password || !confirmPassword) return false;
|
|
||||||
if (password !== confirmPassword) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateInvite = (state: Partial<VerifyFormState>) => {
|
|
||||||
const { name, username, password, confirmPassword } = state;
|
|
||||||
if (!name || !username || !password || !confirmPassword) return false;
|
|
||||||
if (password !== confirmPassword) return false;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,6 @@
|
|||||||
import { AuthenticationLayout } from './Layout';
|
import { AuthenticationLayout } from './Layout';
|
||||||
import { LandingPage } from './LandingPage';
|
import { LandingPage } from './LandingPage';
|
||||||
import { SignoutScreen } from './Signout';
|
import { SignoutScreen } from './Signout';
|
||||||
import { VerifyScreen } from './VerifyScreen';
|
|
||||||
import { ForgotPassword, ResetPassword } from './ForgotPassword';
|
import { ForgotPassword, ResetPassword } from './ForgotPassword';
|
||||||
|
|
||||||
export {
|
export { AuthenticationLayout, LandingPage, SignoutScreen, ForgotPassword, ResetPassword };
|
||||||
AuthenticationLayout,
|
|
||||||
LandingPage,
|
|
||||||
SignoutScreen,
|
|
||||||
VerifyScreen,
|
|
||||||
ForgotPassword,
|
|
||||||
ResetPassword,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ inputs: # optional — parameters the user fills in before runni
|
|||||||
- No triggers → task is only runnable from the Automation page
|
- No triggers → task is only runnable from the Automation page
|
||||||
|
|
||||||
## Notes
|
## 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
|
- The markdown body after the frontmatter should contain step-by-step instructions for the agent
|
||||||
</task-creation-guide>`;
|
</task-creation-guide>`;
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ inputs:
|
|||||||
- \`object\` — JSON object
|
- \`object\` — JSON object
|
||||||
|
|
||||||
## Notes
|
## 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 \`name\` field uses snake_case (this is the function name the agent calls)
|
||||||
- The \`label\` field is the human-readable display name
|
- The \`label\` field is the human-readable display name
|
||||||
- Mark parameters as \`optional: true\` when they have sensible defaults
|
- Mark parameters as \`optional: true\` when they have sensible defaults
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export type DockItem = {
|
|||||||
to: string;
|
to: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
color: string;
|
color: string;
|
||||||
role?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type DockProps = {
|
type DockProps = {
|
||||||
@@ -109,8 +108,21 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, ScrollText, FolderKanban, Monitor, Mail, Globe, MonitorSmartphone, Workflow } from 'lucide-react';
|
Home,
|
||||||
|
MessageCircle,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
Code,
|
||||||
|
LayoutGrid,
|
||||||
|
ScrollText,
|
||||||
|
FolderKanban,
|
||||||
|
Monitor,
|
||||||
|
Mail,
|
||||||
|
Globe,
|
||||||
|
MonitorSmartphone,
|
||||||
|
Workflow,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
{ 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: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||||
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
|
{ 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' },
|
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
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 { useAuth } from 'hooks/useAuth';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||||
@@ -16,8 +16,6 @@ export function UserMenu() {
|
|||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
const isAdmin = user?.role !== 'Member';
|
|
||||||
|
|
||||||
const toggleColorMode = () => {
|
const toggleColorMode = () => {
|
||||||
const next = colorMode === 'dark' ? 'light' : 'dark';
|
const next = colorMode === 'dark' ? 'light' : 'dark';
|
||||||
setColorMode(next);
|
setColorMode(next);
|
||||||
@@ -43,8 +41,6 @@ export function UserMenu() {
|
|||||||
{t('header.userMenu.profile')}
|
{t('header.userMenu.profile')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/system">
|
<Link to="/settings/system">
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
@@ -57,8 +53,6 @@ export function UserMenu() {
|
|||||||
{t('header.userMenu.resources')}
|
{t('header.userMenu.resources')}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/settings/ai">
|
<Link to="/settings/ai">
|
||||||
<Bot className="mr-2 h-4 w-4" />
|
<Bot className="mr-2 h-4 w-4" />
|
||||||
@@ -77,14 +71,6 @@ export function UserMenu() {
|
|||||||
Apps
|
Apps
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{user?.role === 'Super Admin' && (
|
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
|
||||||
<Link to="/settings/users">
|
|
||||||
<Users className="mr-2 h-4 w-4" />
|
|
||||||
Users
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
|
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
|
||||||
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
|
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
|
||||||
|
|||||||
@@ -104,12 +104,8 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
|||||||
// --- My Models Section (per-user hidden models) ---
|
// --- My Models Section (per-user hidden models) ---
|
||||||
|
|
||||||
function MyModelsSection() {
|
function MyModelsSection() {
|
||||||
const { user } = useAuth();
|
|
||||||
const { settings, saveSettings } = useSettings();
|
const { settings, saveSettings } = useSettings();
|
||||||
const allModels = useModels();
|
const visibleModels = useModels();
|
||||||
const policyModels = useVisibleModels();
|
|
||||||
const isAdmin = user?.role !== 'Member';
|
|
||||||
const visibleModels = isAdmin ? allModels : policyModels;
|
|
||||||
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
|
const [activeProvider, setActiveProvider] = useUserState<string>('my-models-provider', '');
|
||||||
|
|
||||||
const hiddenModels = settings.chat.hiddenModels ?? [];
|
const hiddenModels = settings.chat.hiddenModels ?? [];
|
||||||
@@ -411,12 +407,7 @@ function MemberModelsSection() {
|
|||||||
// // ... full implementation for future use
|
// // ... full implementation for future use
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// --- Build groups based on role ---
|
|
||||||
|
|
||||||
function useAISettingsGroups(): SettingsSectionGroup[] {
|
function useAISettingsGroups(): SettingsSectionGroup[] {
|
||||||
const { user } = useAuth();
|
|
||||||
const isAdmin = user?.role !== 'Member';
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const modelsGroup: SettingsSectionGroup = {
|
const modelsGroup: SettingsSectionGroup = {
|
||||||
label: 'Models',
|
label: 'Models',
|
||||||
@@ -429,17 +420,13 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
|
|||||||
description: 'Show or hide models for yourself',
|
description: 'Show or hide models for yourself',
|
||||||
content: <MyModelsSection />,
|
content: <MyModelsSection />,
|
||||||
},
|
},
|
||||||
...(isAdmin
|
|
||||||
? [
|
|
||||||
{
|
{
|
||||||
key: 'member-models',
|
key: 'member-models',
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
title: 'Member Models',
|
title: 'Channel Models',
|
||||||
description: 'Enable or disable models for members',
|
description: 'Models reachable from Telegram, WhatsApp and Discord',
|
||||||
content: <MemberModelsSection />,
|
content: <MemberModelsSection />,
|
||||||
},
|
},
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -457,7 +444,6 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
const providersGroup: SettingsSectionGroup = {
|
const providersGroup: SettingsSectionGroup = {
|
||||||
label: 'Providers',
|
label: 'Providers',
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
@@ -471,11 +457,9 @@ function useAISettingsGroups(): SettingsSectionGroup[] {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
return [providersGroup, modelsGroup, defaultsGroup];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [modelsGroup, defaultsGroup];
|
return [providersGroup, modelsGroup, defaultsGroup];
|
||||||
}, [isAdmin]);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout: LayoutNode = {
|
const layout: LayoutNode = {
|
||||||
|
|||||||
@@ -108,9 +108,7 @@ const personalSections: SettingsSection[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const IntegrationsSidebar = () => {
|
const IntegrationsSidebar = () => {
|
||||||
const { user } = useAuth();
|
const [tab, setTab] = useGlobal<string>(TAB_KEY, 'enterprise');
|
||||||
const isSuperAdmin = user?.role === 'Super Admin';
|
|
||||||
const [tab, setTab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
|
|
||||||
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -121,7 +119,6 @@ const IntegrationsSidebar = () => {
|
|||||||
Integrations
|
Integrations
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSuperAdmin && (
|
|
||||||
<div className="px-3 pb-2">
|
<div className="px-3 pb-2">
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
@@ -134,16 +131,13 @@ const IntegrationsSidebar = () => {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
|
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const IntegrationsContent = () => {
|
const IntegrationsContent = () => {
|
||||||
const { user } = useAuth();
|
const [tab] = useGlobal<string>(TAB_KEY, 'enterprise');
|
||||||
const isSuperAdmin = user?.role === 'Super Admin';
|
|
||||||
const [tab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
|
|
||||||
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
||||||
|
|
||||||
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
|
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
|
||||||
|
|||||||
@@ -124,13 +124,7 @@ const SystemTerminalPanel = () => {
|
|||||||
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<TerminalView
|
<TerminalView className="flex-1" command={session.command} sessionId={session.id} onCommandDone={onCommandDone} />
|
||||||
className="flex-1"
|
|
||||||
sandboxed={false}
|
|
||||||
command={session.command}
|
|
||||||
sessionId={session.id}
|
|
||||||
onCommandDone={onCommandDone}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<InviteFormState>({ 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 (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Invite User</DialogTitle>
|
|
||||||
<DialogDescription>Send an invitation email to a new user.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="invite-email">Email</Label>
|
|
||||||
<Input id="invite-email" type="email" name="email" placeholder="user@example.com" required />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="invite-role">Role</Label>
|
|
||||||
<Select value={state.role ?? 'Member'} onValueChange={handleRoleChange}>
|
|
||||||
<SelectTrigger id="invite-role">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROLES.map((r) => (
|
|
||||||
<SelectItem key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={loading || !state.email?.trim()}>
|
|
||||||
{loading ? 'Sending...' : 'Send Invitation'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<UserSelect, 'password'>;
|
|
||||||
|
|
||||||
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<SafeUser[]>('/users'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataController = useDataControl<SafeUser>(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 (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<SearchInput value={dataController.searchQuery} handleSearch={dataController.setSearchQuery} />
|
|
||||||
<Button size="sm" onClick={() => setInviteOpen(true)}>
|
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
Invite User
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable<SafeUser>
|
|
||||||
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 }) => (
|
|
||||||
<Badge variant={value === 'Active' ? 'default' : 'secondary'}>{value as string}</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '',
|
|
||||||
format: ({ item }) =>
|
|
||||||
item.id !== currentUser?.id ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{item.status === 'Invited' && (
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleResendInvite(item)}>
|
|
||||||
<MailPlus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => handleDelete(item)}>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InviteUserDialog open={inviteOpen} onOpenChange={setInviteOpen} onSuccess={refetch} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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: <UsersTable /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="h-full w-full pt-2">
|
|
||||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './ProfileSettings';
|
export * from './ProfileSettings';
|
||||||
export * from './SystemSettings';
|
export * from './SystemSettings';
|
||||||
export * from './AISettings';
|
export * from './AISettings';
|
||||||
export * from './UserSettings';
|
|
||||||
export * from './IntegrationsSettings';
|
export * from './IntegrationsSettings';
|
||||||
export * from './AppsSettings';
|
export * from './AppsSettings';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" DROP COLUMN "role";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1784813792772,
|
"when": 1784813792772,
|
||||||
"tag": "0005_small_the_phantom",
|
"tag": "0005_small_the_phantom",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785013713432,
|
||||||
|
"tag": "0006_absurd_dormammu",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,11 @@ export async function getServerIntegration(provider: string): Promise<ServerInte
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertServerIntegration(provider: string, config: Record<string, unknown>, enabled = true): Promise<ServerIntegrationSelect> {
|
export async function upsertServerIntegration(
|
||||||
|
provider: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
enabled = true,
|
||||||
|
): Promise<ServerIntegrationSelect> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(serverIntegrations)
|
.insert(serverIntegrations)
|
||||||
.values({ provider, config, enabled, updatedAt: new Date() })
|
.values({ provider, config, enabled, updatedAt: new Date() })
|
||||||
@@ -28,7 +32,10 @@ export async function upsertServerIntegration(provider: string, config: Record<s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteServerIntegration(provider: string): Promise<boolean> {
|
export async function deleteServerIntegration(provider: string): Promise<boolean> {
|
||||||
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;
|
return result.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +64,12 @@ type UpsertUserIntegrationParams = {
|
|||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function upsertUserIntegration({ userId, provider, serverIntegrationId, config }: UpsertUserIntegrationParams): Promise<UserIntegrationSelect> {
|
export async function upsertUserIntegration({
|
||||||
|
userId,
|
||||||
|
provider,
|
||||||
|
serverIntegrationId,
|
||||||
|
config,
|
||||||
|
}: UpsertUserIntegrationParams): Promise<UserIntegrationSelect> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(userIntegrations)
|
.insert(userIntegrations)
|
||||||
.values({ userId, provider, serverIntegrationId: serverIntegrationId ?? null, config, updatedAt: new Date() })
|
.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 ──
|
// ── Cross-table lookup ──
|
||||||
|
|
||||||
type UserIntegrationWithUser = UserIntegrationSelect & {
|
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(
|
export async function findUserByIntegrationConfig(
|
||||||
@@ -101,16 +113,12 @@ export async function findUserByIntegrationConfig(
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
role: users.role,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.from(userIntegrations)
|
.from(userIntegrations)
|
||||||
.innerJoin(users, eq(userIntegrations.userId, users.id))
|
.innerJoin(users, eq(userIntegrations.userId, users.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userIntegrations.provider, provider), sql`${userIntegrations.config}->>${configKey} = ${configValue}`),
|
||||||
eq(userIntegrations.provider, provider),
|
|
||||||
sql`${userIntegrations.config}->>${configKey} = ${configValue}`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return row as UserIntegrationWithUser | undefined;
|
return row as UserIntegrationWithUser | undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ export const users = pgTable('users', {
|
|||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
email: text('email').notNull().unique(),
|
email: text('email').notNull().unique(),
|
||||||
password: text('password'),
|
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'] })
|
||||||
status: text('status', { enum: ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] }).notNull().default('Unverified'),
|
.notNull()
|
||||||
|
.default('Unverified'),
|
||||||
name: text('name'),
|
name: text('name'),
|
||||||
username: text('username').unique(),
|
username: text('username').unique(),
|
||||||
avatar: text('avatar'),
|
avatar: text('avatar'),
|
||||||
@@ -16,7 +17,9 @@ export const users = pgTable('users', {
|
|||||||
|
|
||||||
export const passkeys = pgTable('passkeys', {
|
export const passkeys = pgTable('passkeys', {
|
||||||
id: serial('id').primaryKey(),
|
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'),
|
origin: text('origin'),
|
||||||
credentialId: text('credential_id'),
|
credentialId: text('credential_id'),
|
||||||
publicKey: text('public_key'),
|
publicKey: text('public_key'),
|
||||||
@@ -26,16 +29,20 @@ export const passkeys = pgTable('passkeys', {
|
|||||||
|
|
||||||
export const passkeyChallenges = pgTable('passkey_challenges', {
|
export const passkeyChallenges = pgTable('passkey_challenges', {
|
||||||
id: serial('id').primaryKey(),
|
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(),
|
origin: text('origin').notNull(),
|
||||||
challenge: text('challenge').notNull(),
|
challenge: text('challenge').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tokenBlacklist = pgTable('token_blacklist', {
|
export const tokenBlacklist = pgTable(
|
||||||
|
'token_blacklist',
|
||||||
|
{
|
||||||
jti: text('jti').primaryKey(),
|
jti: text('jti').primaryKey(),
|
||||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
}, (table) => [
|
},
|
||||||
index('idx_token_blacklist_expires').on(table.expiresAt),
|
(table) => [index('idx_token_blacklist_expires').on(table.expiresAt)],
|
||||||
]);
|
);
|
||||||
|
|||||||
+2
-7
@@ -33,9 +33,7 @@ type WSData = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar';
|
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar';
|
||||||
sandboxed: boolean;
|
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
@@ -220,14 +218,13 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'chat
|
|||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
||||||
const sandboxed = user.role !== 'Super Admin';
|
|
||||||
const cwd = url.searchParams.get('cwd') ?? undefined;
|
const cwd = url.searchParams.get('cwd') ?? undefined;
|
||||||
const command = url.searchParams.get('command') ?? undefined;
|
const command = url.searchParams.get('command') ?? undefined;
|
||||||
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : 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 rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
|
||||||
const files = url.searchParams.get('files') ?? undefined;
|
const files = url.searchParams.get('files') ?? undefined;
|
||||||
const ok = server.upgrade(req, {
|
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 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -254,9 +251,7 @@ function upgradeDevServerWs(req: Request, server: any) {
|
|||||||
data: {
|
data: {
|
||||||
userId: 0,
|
userId: 0,
|
||||||
email: '',
|
email: '',
|
||||||
role: '',
|
|
||||||
provider: 'dev-server' as const,
|
provider: 'dev-server' as const,
|
||||||
sandboxed: false,
|
|
||||||
devServerPort: entry.port,
|
devServerPort: entry.port,
|
||||||
devServerSlug: proxyId,
|
devServerSlug: proxyId,
|
||||||
wsProxyPath,
|
wsProxyPath,
|
||||||
@@ -286,7 +281,7 @@ const server = serve({
|
|||||||
},
|
},
|
||||||
'/api/sidecar/register': (req: Request, server: any) => {
|
'/api/sidecar/register': (req: Request, server: any) => {
|
||||||
const ok = server.upgrade(req, {
|
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 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ export * from './user-middleware';
|
|||||||
export * from './origin-middleware';
|
export * from './origin-middleware';
|
||||||
export * from './origin-validation';
|
export * from './origin-validation';
|
||||||
export * from './rate-limiter';
|
export * from './rate-limiter';
|
||||||
export * from './super-admin-middleware';
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
};
|
|
||||||
@@ -5,22 +5,6 @@ import { isOriginAllowed } from './origin-validation';
|
|||||||
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
import { isLockdown, noteBlocked } from '../api/auth/panic';
|
||||||
import { getUserById, isTokenBlacklisted } from 'officerdb';
|
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<string, string[]> = {
|
|
||||||
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) {
|
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||||
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
|
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
|
||||||
if (isLockdown()) {
|
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);
|
ctx.set('user', user);
|
||||||
return next();
|
return next();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ import {
|
|||||||
} from '../../_middlewares';
|
} from '../../_middlewares';
|
||||||
import { signinHandler } from './signin';
|
import { signinHandler } from './signin';
|
||||||
import { signoutHandler } from './signout';
|
import { signoutHandler } from './signout';
|
||||||
import { signupHandler } from './signup';
|
|
||||||
import { verifyHandler } from './verify';
|
|
||||||
import { verifyTokenHandler } from './verify-token';
|
import { verifyTokenHandler } from './verify-token';
|
||||||
import { resendVerificationHandler } from './resend-verification';
|
|
||||||
import { changePasswordHandler } from './change-password';
|
import { changePasswordHandler } from './change-password';
|
||||||
import { forgotPasswordHandler } from './forgot-password';
|
import { forgotPasswordHandler } from './forgot-password';
|
||||||
import { resetPasswordHandler } from './reset-password';
|
import { resetPasswordHandler } from './reset-password';
|
||||||
@@ -39,11 +36,10 @@ authRouter.post('/signout', userMiddleware, signoutHandler);
|
|||||||
authRouter.post('/revoke', userMiddleware, revokeHandler);
|
authRouter.post('/revoke', userMiddleware, revokeHandler);
|
||||||
// Trigger the panic lockdown — authenticated, no password in the body.
|
// Trigger the panic lockdown — authenticated, no password in the body.
|
||||||
authRouter.post('/panic', userMiddleware, panicHandler);
|
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('/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('/verify-token', verifyTokenHandler);
|
||||||
authRouter.post('/resend-verification', resendVerificationHandler);
|
|
||||||
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
|
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
|
||||||
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
|
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
|
||||||
authRouter.post('/reset-password', resetPasswordHandler);
|
authRouter.post('/reset-password', resetPasswordHandler);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { validatePassword } from './validate-password';
|
|||||||
import { validateUsername } from './validate-username';
|
import { validateUsername } from './validate-username';
|
||||||
import { provisionUserEnvironment } from '../users/provision';
|
import { provisionUserEnvironment } from '../users/provision';
|
||||||
|
|
||||||
// Single-step super-admin bootstrap: the first user is created directly as an active Super Admin, with
|
// Single-step bootstrap for the one account Officer supports: the server owner is created directly as
|
||||||
// no email-verification round-trip. Gated to an empty user table (registration is otherwise closed).
|
// active, with no email-verification round-trip. Gated to an empty user table.
|
||||||
export const bootstrapHandler: Handler = async function (ctx) {
|
export const bootstrapHandler: Handler = async function (ctx) {
|
||||||
const body = ctx.get('body');
|
const body = ctx.get('body');
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
password: passwordHash,
|
password: passwordHash,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
username: validUsername,
|
username: validUsername,
|
||||||
role: 'Super Admin',
|
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -168,13 +168,12 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
|
|
||||||
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||||
|
|
||||||
const { id, name, username, role } = dbUser;
|
const { id, name, username } = dbUser;
|
||||||
const token = await sign({
|
const token = await sign({
|
||||||
id,
|
id,
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
|
||||||
passkeys: passkeys.length,
|
passkeys: passkeys.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,7 +184,6 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
|||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
username,
|
username,
|
||||||
role,
|
|
||||||
passkeys: passkeys.length,
|
passkeys: passkeys.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
};
|
|
||||||
@@ -28,9 +28,9 @@ export const signinHandler: Handler = async function (ctx) {
|
|||||||
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
|
||||||
if (!isValidPassword) throw errors.UNAUTHORIZED();
|
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)) {
|
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
|
||||||
return ctx.json({ user: tokenUser });
|
return ctx.json({ user: tokenUser });
|
||||||
|
|||||||
@@ -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 } });
|
|
||||||
};
|
|
||||||
@@ -4,30 +4,25 @@ import { getUserById } from 'officerdb';
|
|||||||
import { verify } from '@@/jwt';
|
import { verify } from '@@/jwt';
|
||||||
import * as errors from '@@/custom-errors';
|
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) {
|
export const verifyTokenHandler: Handler = async function (ctx) {
|
||||||
const { verificationCode } = ctx.get('body');
|
const { verificationCode } = ctx.get('body');
|
||||||
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
||||||
|
|
||||||
let userInfo: User;
|
let userInfo: User & { purpose?: string };
|
||||||
try {
|
try {
|
||||||
userInfo = (await verify(verificationCode)) as User;
|
userInfo = (await verify(verificationCode)) as User & { purpose?: string };
|
||||||
} catch {
|
} catch {
|
||||||
throw errors.BAD_REQUEST('Token is invalid or expired');
|
throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bootstrap token: has email but no id (user not yet created)
|
if (userInfo?.purpose !== 'reset-password') throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||||
if (userInfo?.email && !userInfo?.id) {
|
|
||||||
return ctx.json({ ok: true, email: userInfo.email, flow: 'bootstrap' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userInfo?.id) 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);
|
const user = await getUserById(userInfo.id);
|
||||||
if (!user) throw errors.NOT_FOUND('User not found');
|
if (!user) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Reset-password tokens skip the verification status check
|
return ctx.json({ ok: true, email: user.email });
|
||||||
const isResetToken = (userInfo as Record<string, unknown>).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' });
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<string, unknown> = { 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 });
|
|
||||||
};
|
|
||||||
@@ -17,7 +17,7 @@ import { DATA_PATH } from '../../data-path';
|
|||||||
// The `claude` CLI persists every session as a JSONL transcript at
|
// The `claude` CLI persists every session as a JSONL transcript at
|
||||||
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
||||||
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
|
// where <slug> 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
|
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our
|
||||||
// own copy; Claude's files are authoritative.
|
// own copy; Claude's files are authoritative.
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export type ClientMessage =
|
|||||||
model?: string;
|
model?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
groupSlug?: string;
|
groupSlug?: string;
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
thinking?: ThinkingLevel;
|
thinking?: ThinkingLevel;
|
||||||
@@ -148,7 +147,6 @@ export type UserSession = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
piProcess: any | null;
|
piProcess: any | null;
|
||||||
ws: any | null;
|
ws: any | null;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
|||||||
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
||||||
import * as sidecar from '@@/sidecar-registry';
|
import * as sidecar from '@@/sidecar-registry';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path';
|
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
||||||
import { getUserSettings, getEmailAccounts } from 'officerdb';
|
import { getUserSettings, getEmailAccounts } from 'officerdb';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
@@ -34,28 +34,21 @@ type WSData = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
provider: string;
|
provider: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
const resolveCwd = (email: string, role: string, cwd?: string) => {
|
const resolveCwd = (email: string, cwd?: string) => {
|
||||||
const root = getHomeDirForRole(email, role);
|
const root = getOwnerHomeDir(email);
|
||||||
if (!cwd || cwd === '~') return root;
|
if (!cwd || cwd === '~') return root;
|
||||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||||
if (cwd.startsWith('/')) {
|
// The server owner is the only account — absolute paths are theirs to use.
|
||||||
// Super Admin: trust absolute paths as-is
|
if (cwd.startsWith('/')) return cwd;
|
||||||
if (role === 'Super Admin') return cwd;
|
|
||||||
return join(root, cwd.slice(1));
|
|
||||||
}
|
|
||||||
return join(root, cwd);
|
return join(root, cwd);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveBaseCwd = (email: string, role: string, cwd?: string) => {
|
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd);
|
||||||
return resolveCwd(email, role, cwd);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The email chat runs from the selected account's storage dir:
|
// The email chat runs from the selected account's storage dir:
|
||||||
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
||||||
@@ -81,13 +74,11 @@ async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?
|
|||||||
async function resolveChatCwd(
|
async function resolveChatCwd(
|
||||||
msg: { context?: string; contextId?: string; cwd?: string },
|
msg: { context?: string; contextId?: string; cwd?: string },
|
||||||
email: string,
|
email: string,
|
||||||
role: string,
|
|
||||||
userId: number,
|
userId: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||||
if (msg.context === 'chat')
|
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||||
return msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
return resolveCwd(email, msg.cwd);
|
||||||
return resolveCwd(email, role, msg.cwd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsToSessionMap = new WeakMap<any, string>();
|
const wsToSessionMap = new WeakMap<any, string>();
|
||||||
@@ -295,7 +286,6 @@ async function handleChat(
|
|||||||
model?: string;
|
model?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
groupSlug?: string;
|
groupSlug?: string;
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@@ -336,14 +326,13 @@ async function handleClaudeCodeChat(
|
|||||||
contextId?: string;
|
contextId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
},
|
},
|
||||||
effectivePrompt: string,
|
effectivePrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
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;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
@@ -389,7 +378,6 @@ async function handleClaudeCodeChat(
|
|||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
role: ws.data.role,
|
|
||||||
resumeSessionId: msg.resumeSessionId,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
@@ -416,14 +404,13 @@ async function handleOpenCodeChat(
|
|||||||
contextId?: string;
|
contextId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
cwdRoot?: string;
|
cwdRoot?: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
},
|
},
|
||||||
effectivePrompt: string,
|
effectivePrompt: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email, username, userId } = ws.data;
|
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;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
@@ -468,7 +455,6 @@ async function handleOpenCodeChat(
|
|||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
role: ws.data.role,
|
|
||||||
resumeSessionId: msg.resumeSessionId,
|
resumeSessionId: msg.resumeSessionId,
|
||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import { spawn, type Subprocess } from 'bun';
|
import { spawn, type Subprocess } from 'bun';
|
||||||
import { resolve, normalize, dirname, join } from 'node:path';
|
import { resolve, normalize, dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { getHomeDirForRole } from '@@/data-path';
|
import { getOwnerHomeDir } from '@@/data-path';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ASOUNDRC_PATH = join(__dirname, 'asoundrc');
|
const ASOUNDRC_PATH = join(__dirname, 'asoundrc');
|
||||||
@@ -11,7 +11,6 @@ type WSData = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
files: string;
|
files: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,7 +58,9 @@ const findCliamp = (): string | null => {
|
|||||||
try {
|
try {
|
||||||
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
|
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
|
||||||
if (stat.exitCode === 0) return bin;
|
if (stat.exitCode === 0) return bin;
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -68,7 +69,7 @@ const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
|||||||
|
|
||||||
export const cliampWebsocket = {
|
export const cliampWebsocket = {
|
||||||
async open(ws: ServerWebSocket<WSData>) {
|
async open(ws: ServerWebSocket<WSData>) {
|
||||||
const { email, role, files: filesParam } = ws.data;
|
const { email, files: filesParam } = ws.data;
|
||||||
|
|
||||||
if (!filesParam) {
|
if (!filesParam) {
|
||||||
sendOutput(ws, '\r\n[Error] No files specified.\r\n');
|
sendOutput(ws, '\r\n[Error] No files specified.\r\n');
|
||||||
@@ -81,7 +82,7 @@ export const cliampWebsocket = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeDir = getHomeDirForRole(email, role);
|
const homeDir = getOwnerHomeDir(email);
|
||||||
const rawFiles = [filesParam];
|
const rawFiles = [filesParam];
|
||||||
|
|
||||||
// Resolve paths relative to user home dir
|
// Resolve paths relative to user home dir
|
||||||
@@ -187,7 +188,11 @@ export const cliampWebsocket = {
|
|||||||
const session = sessions.get(ws);
|
const session = sessions.get(ws);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.closed = true;
|
session.closed = true;
|
||||||
try { session.proc.kill(); } catch { /* ignore */ }
|
try {
|
||||||
|
session.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
sessions.delete(ws);
|
sessions.delete(ws);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const desktopRouter = createRouter();
|
|||||||
|
|
||||||
desktopRouter.get('/vnc-password', async (ctx) => {
|
desktopRouter.get('/vnc-password', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const password = await getVncPassword(user.email, user.role);
|
const password = await getVncPassword(user.email);
|
||||||
if (!password) {
|
if (!password) {
|
||||||
return ctx.json({ error: 'VNC password not configured' }, 500);
|
return ctx.json({ error: 'VNC password not configured' }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { getHomeDirForRole } from '@@/data-path';
|
import { getOwnerHomeDir } from '@@/data-path';
|
||||||
|
|
||||||
function getVncDir(email: string, role: string | null): string {
|
const getVncDir = (email: string): string => join(getOwnerHomeDir(email), '.vnc');
|
||||||
return join(getHomeDirForRole(email, role), '.vnc');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVncPassword(email: string, role: string | null): Promise<string | null> {
|
export async function getVncPassword(email: string): Promise<string | null> {
|
||||||
const file = Bun.file(join(getVncDir(email, role), 'password'));
|
const file = Bun.file(join(getVncDir(email), 'password'));
|
||||||
if (!(await file.exists())) return null;
|
if (!(await file.exists())) return null;
|
||||||
return (await file.text()).trim();
|
return (await file.text()).trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import type { Socket } from 'bun';
|
import type { Socket } from 'bun';
|
||||||
import * as sidecar from '@@/sidecar-registry';
|
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 = {
|
type VncSession = {
|
||||||
tcpSocket: Socket<{ ws: ServerWebSocket<WSData> }> | null;
|
tcpSocket: Socket<{ ws: ServerWebSocket<WSData> }> | null;
|
||||||
@@ -21,7 +21,6 @@ export const desktopWebsocket = {
|
|||||||
const result = await sidecar.startVnc({
|
const result = await sidecar.startVnc({
|
||||||
email: ws.data.email,
|
email: ws.data.email,
|
||||||
username: ws.data.username,
|
username: ws.data.username,
|
||||||
role: ws.data.role,
|
|
||||||
});
|
});
|
||||||
port = result.port;
|
port = result.port;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
|
|||||||
import { resolve, dirname, join, parse as parsePath } from 'node:path';
|
import { resolve, dirname, join, parse as parsePath } from 'node:path';
|
||||||
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
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 * as errors from '@@/custom-errors';
|
||||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
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) {
|
for (const dir of DEFAULT_HOME_DIRS) {
|
||||||
const target = join(homeDir, dir);
|
const target = join(homeDir, dir);
|
||||||
if (dir === 'Onboarding') {
|
if (dir === 'Onboarding') {
|
||||||
if (isSuperAdmin) {
|
|
||||||
await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding'));
|
await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding'));
|
||||||
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding'));
|
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding'));
|
||||||
} else {
|
|
||||||
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding'));
|
|
||||||
}
|
|
||||||
} else if (!existsSync(target)) {
|
} else if (!existsSync(target)) {
|
||||||
await mkdir(target, { recursive: true });
|
await mkdir(target, { recursive: true });
|
||||||
}
|
}
|
||||||
@@ -61,17 +57,14 @@ async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) {
|
|||||||
|
|
||||||
export const router = createRouter();
|
export const router = createRouter();
|
||||||
|
|
||||||
type UserCtx = { email: string; role: string | null };
|
type UserCtx = { email: string };
|
||||||
|
|
||||||
function getUserDataDir(email: string): string {
|
function getUserDataDir(email: string): string {
|
||||||
return join(DATA_PATH, email);
|
return join(DATA_PATH, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRootDir(user: UserCtx, root?: string): string {
|
function getRootDir(user: UserCtx, root?: string): string {
|
||||||
if (!root || root === 'home') {
|
if (!root || root === 'home') return getOwnerHomeDir(user.email);
|
||||||
if (user.role === 'Super Admin' && process.env.HOME_DIR) return process.env.HOME_DIR;
|
|
||||||
return getHomeDir(user.email);
|
|
||||||
}
|
|
||||||
if (root === 'user-data') return getUserDataDir(user.email);
|
if (root === 'user-data') return getUserDataDir(user.email);
|
||||||
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
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 tmp = `${base}.tmp.${ext}`;
|
||||||
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
|
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
|
||||||
const proc = Bun.spawn(
|
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' },
|
{ stdout: 'ignore', stderr: 'pipe' },
|
||||||
);
|
);
|
||||||
const code = await proc.exited;
|
const code = await proc.exited;
|
||||||
@@ -147,7 +157,7 @@ router.get('/ls', async (ctx) => {
|
|||||||
|
|
||||||
// Auto-create dir if missing (only for user home root)
|
// Auto-create dir if missing (only for user home root)
|
||||||
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
|
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 });
|
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)
|
// 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) => {
|
router.get('/subtitles', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
@@ -336,7 +356,18 @@ router.get('/subtitles', async (ctx) => {
|
|||||||
const absPath = resolveUserPath(rootDir, relPath);
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
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' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = await new Response(proc.stdout).text();
|
const out = await new Response(proc.stdout).text();
|
||||||
@@ -396,13 +427,29 @@ router.get('/audio-tracks', async (ctx) => {
|
|||||||
const absPath = resolveUserPath(rootDir, relPath);
|
const absPath = resolveUserPath(rootDir, relPath);
|
||||||
|
|
||||||
const proc = Bun.spawn(
|
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' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = await new Response(proc.stdout).text();
|
const out = await new Response(proc.stdout).text();
|
||||||
await proc.exited;
|
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[] = [];
|
let streams: ProbeAudio[] = [];
|
||||||
try {
|
try {
|
||||||
streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
|
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
|
// 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).
|
// subtitle (language) streams line up in order; per-episode titles are ignored (they always differ).
|
||||||
const VIDEO_EXTENSIONS = new Set([
|
const VIDEO_EXTENSIONS = new Set([
|
||||||
'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg',
|
'mp4',
|
||||||
'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb',
|
'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 FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string };
|
||||||
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };
|
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };
|
||||||
|
|
||||||
async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
||||||
const proc = Bun.spawn(
|
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' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = await new Response(proc.stdout).text();
|
const out = await new Response(proc.stdout).text();
|
||||||
await proc.exited;
|
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[] = [];
|
let streams: ProbeStream[] = [];
|
||||||
try {
|
try {
|
||||||
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
|
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
|
||||||
@@ -453,7 +541,14 @@ async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
|||||||
|
|
||||||
const audio = streams
|
const audio = streams
|
||||||
.filter((s) => s.codec_type === 'audio')
|
.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
|
// 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.
|
// before filtering out image-based tracks that can't become soft subs.
|
||||||
const subtitle = streams
|
const subtitle = streams
|
||||||
@@ -490,12 +585,20 @@ router.get('/probe-folder', async (ctx) => {
|
|||||||
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
||||||
const batch = files.slice(i, i + CONCURRENCY);
|
const batch = files.slice(i, i + CONCURRENCY);
|
||||||
const results = await Promise.all(
|
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);
|
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<string, Group>();
|
const groupsMap = new Map<string, Group>();
|
||||||
for (const { file, tracks } of probed) {
|
for (const { file, tracks } of probed) {
|
||||||
const sig = layoutSignature(tracks);
|
const sig = layoutSignature(tracks);
|
||||||
@@ -1161,7 +1264,9 @@ async function runReclipDownload(jobId: string, url: string, absPath: string, au
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
if (Date.now() > deadline) throw new Error('Download timed out');
|
if (Date.now() > deadline) throw new Error('Download timed out');
|
||||||
await new Promise((r) => setTimeout(r, 2000));
|
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;
|
if (!stRes?.ok) continue;
|
||||||
const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null };
|
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');
|
if (st.status === 'error') throw new Error(st.error || 'ReClip download failed');
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ integrationsRouter.get('/', async (ctx) => {
|
|||||||
return ctx.json([]);
|
return ctx.json([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Enterprise: Apify config (Super Admin only) ---
|
// --- Apify config ---
|
||||||
|
|
||||||
type ApifyConfig = { apiToken: string };
|
type ApifyConfig = { apiToken: string };
|
||||||
|
|
||||||
@@ -47,15 +47,10 @@ export const readApifyConfig = async (): Promise<ApifyConfig | null> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
integrationsRouter.get('/apify/config', async (ctx) => {
|
integrationsRouter.get('/apify/config', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
|
||||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
|
||||||
return ctx.json(await readApifyConfig());
|
return ctx.json(await readApifyConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
integrationsRouter.put('/apify/config', async (ctx) => {
|
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 body = ctx.get('body') as { apiToken?: string };
|
||||||
const config = { apiToken: body.apiToken ?? '' };
|
const config = { apiToken: body.apiToken ?? '' };
|
||||||
|
|
||||||
@@ -68,18 +63,13 @@ integrationsRouter.get('/apify/status', async (ctx) => {
|
|||||||
return ctx.json({ configured: !!config?.apiToken });
|
return ctx.json({ configured: !!config?.apiToken });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Enterprise: Google OAuth config (Super Admin only) ---
|
// --- Google OAuth config ---
|
||||||
|
|
||||||
integrationsRouter.get('/google/config', async (ctx) => {
|
integrationsRouter.get('/google/config', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
|
||||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
|
||||||
return ctx.json(await readGoogleConfig());
|
return ctx.json(await readGoogleConfig());
|
||||||
});
|
});
|
||||||
|
|
||||||
integrationsRouter.put('/google/config', async (ctx) => {
|
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 body = ctx.get('body') as { clientId?: string; clientSecret?: string };
|
||||||
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
|
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
|
||||||
|
|
||||||
@@ -88,9 +78,6 @@ integrationsRouter.put('/google/config', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
integrationsRouter.get('/google/verify', async (ctx) => {
|
integrationsRouter.get('/google/verify', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
|
||||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
|
||||||
|
|
||||||
const config = await readGoogleConfig();
|
const config = await readGoogleConfig();
|
||||||
if (!config?.clientId || !config?.clientSecret) {
|
if (!config?.clientId || !config?.clientSecret) {
|
||||||
return ctx.json({ valid: false, error: 'Missing credentials' });
|
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 upstream = await fetch(url, init);
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
let parsed: unknown;
|
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 });
|
return ctx.json({ status: upstream.status, ok: upstream.ok, body: parsed });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { join, isAbsolute } from 'node:path';
|
|||||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
|
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { getTaskByDirName } from './task-files';
|
import { getTaskByDirName } from './task-files';
|
||||||
import { getHomeDirForRole, DATA_PATH } from '../../data-path';
|
import { getOwnerHomeDir, DATA_PATH } from '../../data-path';
|
||||||
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
|
|
||||||
import { killTree } from './process-tree';
|
import { killTree } from './process-tree';
|
||||||
|
|
||||||
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
|
// 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 = {
|
export type ExecuteScriptParams = {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
inputs: Record<string, string>;
|
inputs: Record<string, string>;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -28,9 +25,21 @@ export type ExecuteScriptParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getRunner = (language: string): string[] =>
|
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 =>
|
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 {
|
function materializeScript(language: string, implementation: string): string {
|
||||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
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
|
// 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).
|
// when aborted (the manager maps those to failed/stopped).
|
||||||
export async function executeScript(params: ExecuteScriptParams): Promise<{ exitCode: number }> {
|
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);
|
const task = await getTaskByDirName(params.taskDirName);
|
||||||
if (!task) throw new Error(`Task not found: ${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 positionalArgs = buildArgs(inputs, task.args);
|
||||||
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
|
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;
|
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
|
||||||
|
|
||||||
let spawnCmd: string[];
|
const spawnCmd = cmd;
|
||||||
let spawnEnv: Record<string, string>;
|
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||||
let spawnCwd: string;
|
const spawnCwd = cwd;
|
||||||
|
|
||||||
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<string, string>), ...inputEnv };
|
|
||||||
spawnCwd = cwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
|
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
|
||||||
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
|
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
|
||||||
const cleanup = () => {
|
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 });
|
emit({ type: 'started', taskName: task.name });
|
||||||
@@ -109,7 +104,11 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
|||||||
const abortPoll = setInterval(() => {
|
const abortPoll = setInterval(() => {
|
||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
clearInterval(abortPoll);
|
clearInterval(abortPoll);
|
||||||
try { killTree(proc.pid); } catch { /* already dead */ }
|
try {
|
||||||
|
killTree(proc.pid);
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { join } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { getUserSettings } from 'officerdb';
|
import { getUserSettings } from 'officerdb';
|
||||||
import { getTaskByDirName } from './task-files';
|
import { getTaskByDirName } from './task-files';
|
||||||
import { getHomeDirForRole, getHomeDir } from '../../data-path';
|
import { getHomeDir } from '../../data-path';
|
||||||
import { resolveBaseCwd } from '../chat/websocket';
|
import { resolveBaseCwd } from '../chat/websocket';
|
||||||
import { SANDBOX_HOME } from '../../sidecar/sandbox';
|
|
||||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||||
import type { ChatEvent, MessageCost } from '../chat/types';
|
import type { ChatEvent, MessageCost } from '../chat/types';
|
||||||
import * as jobManager from './pipeline-job-manager';
|
import * as jobManager from './pipeline-job-manager';
|
||||||
@@ -41,7 +40,12 @@ type PipelineConfig = {
|
|||||||
// Messages sent to client
|
// Messages sent to client
|
||||||
export type OutMessage =
|
export type OutMessage =
|
||||||
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string; concurrency?: number }> }
|
| { 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:complete'; stepIndex: number; cost?: MessageCost }
|
||||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||||
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
| { 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: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||||
| { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
| { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
||||||
| { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
| { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
||||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; 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<string, unknown>;
|
||||||
|
stepIndex: number;
|
||||||
|
iterationLabel?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tool:result';
|
||||||
|
toolCallId: string;
|
||||||
|
output: string;
|
||||||
|
isError: boolean;
|
||||||
|
stepIndex: number;
|
||||||
|
iterationLabel?: string;
|
||||||
|
}
|
||||||
| { type: 'pipeline:complete'; totalCost: MessageCost }
|
| { type: 'pipeline:complete'; totalCost: MessageCost }
|
||||||
| { type: 'error'; message: string }
|
| { type: 'error'; message: string }
|
||||||
| { type: 'stopped' };
|
| { type: 'stopped' };
|
||||||
@@ -67,7 +85,6 @@ type RunStepParams = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -92,14 +109,28 @@ async function refreshProxyToken(): Promise<void> {
|
|||||||
const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
const WAITING_INTERVAL_MS = 10 * 1000; // emit "waiting" every 10s
|
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<MessageCost> {
|
async function runAgenticStep({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
taskDirName,
|
||||||
|
prompt,
|
||||||
|
cwd,
|
||||||
|
model,
|
||||||
|
abortSignal,
|
||||||
|
emit,
|
||||||
|
stepIndex,
|
||||||
|
iterationLabel,
|
||||||
|
}: RunStepParams): Promise<MessageCost> {
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const isClaudeCode = model.startsWith('claude-code');
|
const isClaudeCode = model.startsWith('claude-code');
|
||||||
|
|
||||||
// Ensure fresh OAuth token before spawning Claude Code
|
// Ensure fresh OAuth token before spawning Claude Code
|
||||||
if (isClaudeCode) await refreshProxyToken();
|
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<MessageCost>(async (resolve, reject) => {
|
return new Promise<MessageCost>(async (resolve, reject) => {
|
||||||
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
|
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 });
|
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
|
||||||
break;
|
break;
|
||||||
case 'tool:start':
|
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;
|
break;
|
||||||
case 'tool:result':
|
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;
|
break;
|
||||||
case 'result':
|
case 'result':
|
||||||
settle(() => { cleanup?.(); resolve(event.cost); });
|
settle(() => {
|
||||||
|
cleanup?.();
|
||||||
|
resolve(event.cost);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
settle(() => { cleanup?.(); reject(new Error(event.message)); });
|
settle(() => {
|
||||||
|
cleanup?.();
|
||||||
|
reject(new Error(event.message));
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
settle(() => { cleanup?.(); reject(new Error('Step was stopped')); });
|
settle(() => {
|
||||||
|
cleanup?.();
|
||||||
|
reject(new Error('Step was stopped'));
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -149,14 +203,20 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
|||||||
// Poll for abort signal and activity timeout
|
// Poll for abort signal and activity timeout
|
||||||
const abortPoll = setInterval(() => {
|
const abortPoll = setInterval(() => {
|
||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); });
|
settle(() => {
|
||||||
|
cleanup?.();
|
||||||
|
reject(new Error('Pipeline was stopped'));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Activity timeout (skip for Claude Code which has its own mechanisms)
|
// Activity timeout (skip for Claude Code which has its own mechanisms)
|
||||||
if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) {
|
if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) {
|
||||||
const elapsed = Math.round((Date.now() - stepStart) / 1000);
|
const elapsed = Math.round((Date.now() - stepStart) / 1000);
|
||||||
console.error(`[pipeline] step ${stepIndex} timed out after ${elapsed}s of inactivity (session=${sessionId})`);
|
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);
|
}, 500);
|
||||||
|
|
||||||
@@ -176,12 +236,14 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
|||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
role,
|
|
||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
cleanup = handle.kill;
|
cleanup = handle.kill;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
settle(() => { cleanup?.(); reject(err); });
|
settle(() => {
|
||||||
|
cleanup?.();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -192,16 +254,6 @@ function resolveInputTemplate(template: string, variables: Record<string, string
|
|||||||
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] ?? '');
|
return template.replace(/\$\{(\w+)\}/g, (_, key) => 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<string, string>, targetDir?: string): string {
|
function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targetDir?: string): string {
|
||||||
const inputLines = Object.entries(inputs)
|
const inputLines = Object.entries(inputs)
|
||||||
.filter(([, v]) => v.trim())
|
.filter(([, v]) => v.trim())
|
||||||
@@ -219,7 +271,6 @@ function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targe
|
|||||||
|
|
||||||
type RunScriptStepParams = {
|
type RunScriptStepParams = {
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
|
||||||
task: { name: string; implementation: string; language: string; args?: string[] | null };
|
task: { name: string; implementation: string; language: string; args?: string[] | null };
|
||||||
inputs: Record<string, string>;
|
inputs: Record<string, string>;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -230,25 +281,43 @@ type RunScriptStepParams = {
|
|||||||
|
|
||||||
function getRunner(language: string): string[] {
|
function getRunner(language: string): string[] {
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'bash': return ['bash'];
|
case 'bash':
|
||||||
case 'python': return ['python3'];
|
return ['bash'];
|
||||||
case 'typescript': return ['bun', 'run'];
|
case 'python':
|
||||||
case 'javascript': return ['node'];
|
return ['python3'];
|
||||||
default: return ['bash'];
|
case 'typescript':
|
||||||
|
return ['bun', 'run'];
|
||||||
|
case 'javascript':
|
||||||
|
return ['node'];
|
||||||
|
default:
|
||||||
|
return ['bash'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileName(language: string): string {
|
function getFileName(language: string): string {
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'bash': return 'run.sh';
|
case 'bash':
|
||||||
case 'python': return 'run.py';
|
return 'run.sh';
|
||||||
case 'typescript': return 'index.ts';
|
case 'python':
|
||||||
case 'javascript': return 'index.js';
|
return 'run.py';
|
||||||
default: return 'run.sh';
|
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<void> {
|
async function runScriptStep({
|
||||||
|
email,
|
||||||
|
task,
|
||||||
|
inputs,
|
||||||
|
cwd,
|
||||||
|
abortSignal,
|
||||||
|
emit,
|
||||||
|
stepIndex,
|
||||||
|
}: RunScriptStepParams): Promise<void> {
|
||||||
const language = task.language ?? 'bash';
|
const language = task.language ?? 'bash';
|
||||||
|
|
||||||
// Write script to temp file
|
// Write script to temp file
|
||||||
@@ -260,7 +329,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
|||||||
chmodSync(scriptPath, 0o755);
|
chmodSync(scriptPath, 0o755);
|
||||||
|
|
||||||
const cleanup = () => {
|
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
|
// Build env vars from inputs
|
||||||
@@ -275,7 +348,7 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
|||||||
const runner = getRunner(language);
|
const runner = getRunner(language);
|
||||||
const cmd = [...runner, scriptPath, ...positionalArgs];
|
const cmd = [...runner, scriptPath, ...positionalArgs];
|
||||||
|
|
||||||
const spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
|
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||||
|
|
||||||
console.log(`[pipeline] running script step ${stepIndex}: ${task.name} (cwd=${cwd})`);
|
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
|
// Check abort periodically
|
||||||
const abortCheck = setInterval(() => {
|
const abortCheck = setInterval(() => {
|
||||||
if (abortSignal.aborted) {
|
if (abortSignal.aborted) {
|
||||||
try { proc.kill(); } catch { /* already dead */ }
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
@@ -336,7 +413,6 @@ type ForeachParams = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
stepIdx: number;
|
stepIdx: number;
|
||||||
step: PipelineStep;
|
step: PipelineStep;
|
||||||
stepTask: { name: string; body: string };
|
stepTask: { name: string; body: string };
|
||||||
@@ -352,10 +428,22 @@ type ForeachParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function runForeach({
|
async function runForeach({
|
||||||
userId, email, username, role, stepIdx, step, stepTask, subdirs, baseCwd,
|
userId,
|
||||||
inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
|
email,
|
||||||
|
username,
|
||||||
|
stepIdx,
|
||||||
|
step,
|
||||||
|
stepTask,
|
||||||
|
subdirs,
|
||||||
|
baseCwd,
|
||||||
|
inputs,
|
||||||
|
cwd,
|
||||||
|
abortSignal,
|
||||||
|
totalCost,
|
||||||
|
emit,
|
||||||
|
concurrency,
|
||||||
|
model,
|
||||||
}: ForeachParams) {
|
}: ForeachParams) {
|
||||||
|
|
||||||
// Determine skip vs run
|
// Determine skip vs run
|
||||||
const toSkip: string[] = [];
|
const toSkip: string[] = [];
|
||||||
const toRun: string[] = [];
|
const toRun: string[] = [];
|
||||||
@@ -400,13 +488,15 @@ async function runForeach({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||||
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
const resolvedCwd = resolveBaseCwd(email, cwdRelative);
|
||||||
const targetDir = toAgentPath(resolvedCwd, email, role);
|
const targetDir = resolvedCwd;
|
||||||
const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cost = await runAgenticStep({
|
const cost = await runAgenticStep({
|
||||||
userId, email, username, role,
|
userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
taskDirName: step.task,
|
taskDirName: step.task,
|
||||||
prompt,
|
prompt,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
@@ -424,12 +514,19 @@ async function runForeach({
|
|||||||
emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost });
|
emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!abortSignal.aborted) {
|
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);
|
executing.add(p);
|
||||||
|
|
||||||
if (executing.size >= concurrency) {
|
if (executing.size >= concurrency) {
|
||||||
@@ -446,7 +543,6 @@ export type ExecutePipelineParams = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
inputs: Record<string, string>;
|
inputs: Record<string, string>;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -456,7 +552,18 @@ export type ExecutePipelineParams = {
|
|||||||
emit: EmitEvent;
|
emit: EmitEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function executePipeline({ userId, email, username, role, taskDirName, inputs, cwd, model: modelOverride, startAt, abortSignal, emit }: ExecutePipelineParams): Promise<void> {
|
export async function executePipeline({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
taskDirName,
|
||||||
|
inputs,
|
||||||
|
cwd,
|
||||||
|
model: modelOverride,
|
||||||
|
startAt,
|
||||||
|
abortSignal,
|
||||||
|
emit,
|
||||||
|
}: ExecutePipelineParams): Promise<void> {
|
||||||
const pipelineTask = await getTaskByDirName(taskDirName);
|
const pipelineTask = await getTaskByDirName(taskDirName);
|
||||||
if (!pipelineTask) {
|
if (!pipelineTask) {
|
||||||
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
|
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
|
||||||
@@ -473,7 +580,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseCwd = resolveBaseCwd(email, role, cwd);
|
const baseCwd = resolveBaseCwd(email, cwd);
|
||||||
let model = modelOverride || (await resolveModel(userId));
|
let model = modelOverride || (await resolveModel(userId));
|
||||||
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
||||||
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
||||||
@@ -532,8 +639,13 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await runScriptStep({
|
await runScriptStep({
|
||||||
email, role,
|
email,
|
||||||
task: { name: stepTask.name, implementation: stepTask.implementation, language: stepTask.language ?? 'bash', args: stepTask.args as string[] | null },
|
task: {
|
||||||
|
name: stepTask.name,
|
||||||
|
implementation: stepTask.implementation,
|
||||||
|
language: stepTask.language ?? 'bash',
|
||||||
|
args: stepTask.args as string[] | null,
|
||||||
|
},
|
||||||
inputs: resolvedInputs,
|
inputs: resolvedInputs,
|
||||||
cwd: baseCwd,
|
cwd: baseCwd,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
@@ -566,19 +678,33 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
|||||||
const concurrency = step.concurrency ? runtimeConcurrency : 1;
|
const concurrency = step.concurrency ? runtimeConcurrency : 1;
|
||||||
|
|
||||||
await runForeach({
|
await runForeach({
|
||||||
userId, email, username, role,
|
userId,
|
||||||
stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body! },
|
email,
|
||||||
subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
|
username,
|
||||||
|
stepIdx,
|
||||||
|
step,
|
||||||
|
stepTask: { name: stepTask.name, body: stepTask.body! },
|
||||||
|
subdirs,
|
||||||
|
baseCwd,
|
||||||
|
inputs,
|
||||||
|
cwd,
|
||||||
|
abortSignal,
|
||||||
|
totalCost,
|
||||||
|
emit,
|
||||||
|
concurrency,
|
||||||
|
model,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Single execution step
|
// Single execution step
|
||||||
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
|
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
|
||||||
|
|
||||||
const targetDir = toAgentPath(baseCwd, email, role);
|
const targetDir = baseCwd;
|
||||||
const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir);
|
const prompt = buildStepPrompt(stepTask.body!, resolvedInputs, targetDir);
|
||||||
|
|
||||||
const cost = await runAgenticStep({
|
const cost = await runAgenticStep({
|
||||||
userId, email, username, role,
|
userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
taskDirName: step.task,
|
taskDirName: step.task,
|
||||||
prompt,
|
prompt,
|
||||||
cwd: baseCwd,
|
cwd: baseCwd,
|
||||||
@@ -609,8 +735,6 @@ type WSData = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientMessage =
|
type ClientMessage =
|
||||||
@@ -633,7 +757,7 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'run': {
|
case 'run': {
|
||||||
const { userId, email, username, role } = ws.data;
|
const { userId, email, username } = ws.data;
|
||||||
|
|
||||||
// Resolve task name for the DB record
|
// Resolve task name for the DB record
|
||||||
const task = await getTaskByDirName(msg.taskDirName);
|
const task = await getTaskByDirName(msg.taskDirName);
|
||||||
@@ -646,7 +770,6 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
|||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
role,
|
|
||||||
taskDirName: msg.taskDirName,
|
taskDirName: msg.taskDirName,
|
||||||
taskName: task.name,
|
taskName: task.name,
|
||||||
inputs: msg.inputs,
|
inputs: msg.inputs,
|
||||||
@@ -672,7 +795,13 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
|||||||
// Job not live — send the DB state
|
// Job not live — send the DB state
|
||||||
const job = await jobManager.getJob(msg.jobId);
|
const job = await jobManager.getJob(msg.jobId);
|
||||||
if (job) {
|
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 {
|
} else {
|
||||||
send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` });
|
send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` });
|
||||||
}
|
}
|
||||||
@@ -682,7 +811,19 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
|||||||
|
|
||||||
case 'list': {
|
case 'list': {
|
||||||
const jobs = await jobManager.getJobsForUser(ws.data.userId);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ type WSData = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type LiveJob = {
|
type LiveJob = {
|
||||||
@@ -74,9 +72,7 @@ type StartJobParams = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
mode?: JobMode; // defaults to 'pipeline' for back-compat with the existing pipeline caller
|
mode?: JobMode; // defaults to 'pipeline' for back-compat with the existing pipeline caller
|
||||||
sandboxed?: boolean; // script jobs only
|
|
||||||
taskDirName: string;
|
taskDirName: string;
|
||||||
taskName: string;
|
taskName: string;
|
||||||
inputs: Record<string, string>;
|
inputs: Record<string, string>;
|
||||||
@@ -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
|
// 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.)
|
// '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 jobId = randomUUID();
|
||||||
const mode: JobMode = params.mode ?? 'pipeline';
|
const mode: JobMode = params.mode ?? 'pipeline';
|
||||||
const run = action === 'start' || runningCount() === 0;
|
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') {
|
if (event.type === 'step:complete' || event.type === 'iteration:complete') {
|
||||||
const cost = 'cost' in event ? event.cost : undefined;
|
const cost = 'cost' in event ? event.cost : undefined;
|
||||||
if (cost) {
|
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 = {
|
job.lastCost = {
|
||||||
inputTokens: prev.inputTokens + cost.inputTokens,
|
inputTokens: prev.inputTokens + cost.inputTokens,
|
||||||
outputTokens: prev.outputTokens + cost.outputTokens,
|
outputTokens: prev.outputTokens + cost.outputTokens,
|
||||||
@@ -177,8 +180,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
|||||||
? executeScript({
|
? executeScript({
|
||||||
jobId,
|
jobId,
|
||||||
email: params.email,
|
email: params.email,
|
||||||
role: params.role,
|
|
||||||
sandboxed: params.sandboxed ?? false,
|
|
||||||
taskDirName: params.taskDirName,
|
taskDirName: params.taskDirName,
|
||||||
inputs: params.inputs,
|
inputs: params.inputs,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
@@ -189,7 +190,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
email: params.email,
|
email: params.email,
|
||||||
username: params.username,
|
username: params.username,
|
||||||
role: params.role,
|
|
||||||
taskDirName: params.taskDirName,
|
taskDirName: params.taskDirName,
|
||||||
inputs: params.inputs,
|
inputs: params.inputs,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
@@ -199,7 +199,8 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
|||||||
emit,
|
emit,
|
||||||
});
|
});
|
||||||
|
|
||||||
runner.then(async (result) => {
|
runner
|
||||||
|
.then(async (result) => {
|
||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
// Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void.
|
// 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 exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null;
|
||||||
@@ -214,7 +215,8 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
|||||||
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
||||||
liveJobs.delete(jobId);
|
liveJobs.delete(jobId);
|
||||||
void promoteNext();
|
void promoteNext();
|
||||||
}).catch(async (err) => {
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
const isStopped = job.abortSignal.aborted;
|
const isStopped = job.abortSignal.aborted;
|
||||||
@@ -239,7 +241,9 @@ async function promoteNext(): Promise<void> {
|
|||||||
if (!next) return;
|
if (!next) return;
|
||||||
const user = await getUserById(next.userId);
|
const user = await getUserById(next.userId);
|
||||||
if (!user) {
|
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();
|
return promoteNext();
|
||||||
}
|
}
|
||||||
await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() });
|
await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() });
|
||||||
@@ -248,9 +252,7 @@ async function promoteNext(): Promise<void> {
|
|||||||
userId: next.userId,
|
userId: next.userId,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: toShellUsername(user.username ?? '', user.email),
|
username: toShellUsername(user.username ?? '', user.email),
|
||||||
role: user.role ?? '',
|
|
||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
sandboxed: (user.role ?? '') !== 'Super Admin',
|
|
||||||
taskDirName: next.taskDirName,
|
taskDirName: next.taskDirName,
|
||||||
taskName: next.taskName,
|
taskName: next.taskName,
|
||||||
inputs: next.inputs as Record<string, string>,
|
inputs: next.inputs as Record<string, string>,
|
||||||
@@ -340,7 +342,9 @@ export async function clearHistory(userId: number): Promise<number> {
|
|||||||
|
|
||||||
// Lightweight header-badge summary: how many of the user's jobs are running / queued, and which one
|
// 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).
|
// 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 running = 0;
|
||||||
let runningJobId: string | null = null;
|
let runningJobId: string | null = null;
|
||||||
for (const [id, job] of liveJobs) {
|
for (const [id, job] of liveJobs) {
|
||||||
@@ -386,7 +390,11 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
|||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
currentStepIndex: event.stepIndex,
|
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':
|
case 'iteration:start':
|
||||||
@@ -396,7 +404,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
|||||||
...p,
|
...p,
|
||||||
parallel: {
|
parallel: {
|
||||||
...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,
|
...p,
|
||||||
parallel: {
|
parallel: {
|
||||||
...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)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ pipelineJobsRouter.get('/', async (c) => {
|
|||||||
// job. Returns { jobId, status }. This is the REST creation path the phone / unattended runs use.
|
// job. Returns { jobId, status }. This is the REST creation path the phone / unattended runs use.
|
||||||
pipelineJobsRouter.post('/', async (c) => {
|
pipelineJobsRouter.post('/', async (c) => {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const body = await c.req.json<{ taskDirName: string; inputs?: Record<string, string>; cwd?: string; action?: 'start' | 'queue' }>();
|
const body = await c.req.json<{
|
||||||
|
taskDirName: string;
|
||||||
|
inputs?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
action?: 'start' | 'queue';
|
||||||
|
}>();
|
||||||
if (!body.taskDirName) throw errors.BAD_REQUEST('taskDirName is required');
|
if (!body.taskDirName) throw errors.BAD_REQUEST('taskDirName is required');
|
||||||
|
|
||||||
const task = await getTaskByDirName(body.taskDirName);
|
const task = await getTaskByDirName(body.taskDirName);
|
||||||
@@ -52,9 +57,7 @@ pipelineJobsRouter.post('/', async (c) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username ?? '',
|
username: user.username ?? '',
|
||||||
role: user.role ?? '',
|
|
||||||
mode,
|
mode,
|
||||||
sandboxed: (user.role ?? '') !== 'Super Admin',
|
|
||||||
taskDirName: body.taskDirName,
|
taskDirName: body.taskDirName,
|
||||||
taskName: task.name,
|
taskName: task.name,
|
||||||
inputs: body.inputs ?? {},
|
inputs: body.inputs ?? {},
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import type { ServerWebSocket } from 'bun';
|
|||||||
import { join, isAbsolute } from 'node:path';
|
import { join, isAbsolute } from 'node:path';
|
||||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { getTaskByDirName } from './task-files';
|
import { getTaskByDirName } from './task-files';
|
||||||
import { getHomeDirForRole, DATA_PATH } from '../../data-path';
|
import { getOwnerHomeDir } from '../../data-path';
|
||||||
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
|
|
||||||
|
|
||||||
type WSData = {
|
type WSData = {
|
||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunMessage = {
|
type RunMessage = {
|
||||||
@@ -79,11 +76,19 @@ function descendantPids(root: number): number[] {
|
|||||||
function killTree(root: number) {
|
function killTree(root: number) {
|
||||||
const pids = [root, ...descendantPids(root)];
|
const pids = [root, ...descendantPids(root)];
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
for (const pid of pids) {
|
for (const pid of pids) {
|
||||||
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {
|
||||||
|
/* gone */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
@@ -96,21 +101,31 @@ function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
|
|||||||
|
|
||||||
function getRunner(language: string): string[] {
|
function getRunner(language: string): string[] {
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'bash': return ['bash'];
|
case 'bash':
|
||||||
case 'python': return ['python3'];
|
return ['bash'];
|
||||||
case 'typescript': return ['bun', 'run'];
|
case 'python':
|
||||||
case 'javascript': return ['node'];
|
return ['python3'];
|
||||||
default: return ['bash'];
|
case 'typescript':
|
||||||
|
return ['bun', 'run'];
|
||||||
|
case 'javascript':
|
||||||
|
return ['node'];
|
||||||
|
default:
|
||||||
|
return ['bash'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileName(language: string): string {
|
function getFileName(language: string): string {
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'bash': return 'run.sh';
|
case 'bash':
|
||||||
case 'python': return 'run.py';
|
return 'run.sh';
|
||||||
case 'typescript': return 'index.ts';
|
case 'python':
|
||||||
case 'javascript': return 'index.js';
|
return 'run.py';
|
||||||
default: return 'run.sh';
|
case 'typescript':
|
||||||
|
return 'index.ts';
|
||||||
|
case 'javascript':
|
||||||
|
return 'index.js';
|
||||||
|
default:
|
||||||
|
return 'run.sh';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +157,7 @@ function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||||
const { email, role, sandboxed } = ws.data;
|
const { email } = ws.data;
|
||||||
|
|
||||||
// Resolve task from the file-backed store
|
// Resolve task from the file-backed store
|
||||||
const task = await getTaskByDirName(msg.taskDirName);
|
const task = await getTaskByDirName(msg.taskDirName);
|
||||||
@@ -178,44 +193,19 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
|||||||
|
|
||||||
// msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute
|
// 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)
|
// (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;
|
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
|
||||||
|
|
||||||
let spawnCmd: string[];
|
const spawnCmd = cmd;
|
||||||
let spawnEnv: Record<string, string>;
|
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||||
let spawnCwd: string;
|
const spawnCwd = cwd;
|
||||||
|
|
||||||
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<string, string>, ...inputEnv };
|
|
||||||
spawnCwd = cwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
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 });
|
send(ws, { type: 'started', taskName: task.name });
|
||||||
@@ -231,7 +221,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
|||||||
activeProcs.set(ws, {
|
activeProcs.set(ws, {
|
||||||
proc,
|
proc,
|
||||||
kill: () => {
|
kill: () => {
|
||||||
try { killTree(proc.pid); } catch { /* already dead */ }
|
try {
|
||||||
|
killTree(proc.pid);
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,7 +233,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
|||||||
// file for minutes with no output). Bun's default 120s idle timeout would otherwise close the
|
// 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.
|
// socket → close(ws) → killTree kills the task mid-run. A ping resets the idle timer.
|
||||||
const keepAlive = setInterval(() => {
|
const keepAlive = setInterval(() => {
|
||||||
try { ws.ping(); } catch { /* socket gone */ }
|
try {
|
||||||
|
ws.ping();
|
||||||
|
} catch {
|
||||||
|
/* socket gone */
|
||||||
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
const stdoutReader = proc.stdout.getReader();
|
const stdoutReader = proc.stdout.getReader();
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { join } from 'node:path';
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { getHomeDir } from '@@/data-path';
|
|
||||||
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
||||||
import type { PtyInitConfig } from '../../sidecar/protocol';
|
import type { PtyInitConfig } from '../../sidecar/protocol';
|
||||||
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../../sidecar/sandbox';
|
|
||||||
|
|
||||||
type WSData = {
|
type WSData = {
|
||||||
userId: number;
|
userId: number;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string;
|
|
||||||
sandboxed: boolean;
|
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
@@ -49,25 +44,19 @@ const resolveCwd = (home: string, cwd?: string) => {
|
|||||||
|
|
||||||
export const terminalWebsocket = {
|
export const terminalWebsocket = {
|
||||||
async open(ws: ServerWebSocket<WSData>) {
|
async open(ws: ServerWebSocket<WSData>) {
|
||||||
const { email, username, role, sandboxed } = ws.data;
|
const { email, username } = ws.data;
|
||||||
const isHost = !sandboxed && role === 'Super Admin';
|
|
||||||
|
|
||||||
console.log(
|
console.log(`[terminal] open: email=${email} username=${username}`);
|
||||||
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isTerminalConnected()) {
|
if (!isTerminalConnected()) {
|
||||||
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
|
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
|
||||||
return;
|
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
|
// The server owner is the only account, so the terminal is always a plain host shell.
|
||||||
let config: PtyInitConfig;
|
const config: PtyInitConfig = {
|
||||||
|
|
||||||
if (isHost) {
|
|
||||||
config = {
|
|
||||||
sessionId,
|
sessionId,
|
||||||
host: true,
|
host: true,
|
||||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||||
@@ -77,32 +66,6 @@ export const terminalWebsocket = {
|
|||||||
cols: ws.data.cols,
|
cols: ws.data.cols,
|
||||||
rows: ws.data.rows,
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to events for this session
|
// Subscribe to events for this session
|
||||||
const unsubOutput = on('pty:output', (msg) => {
|
const unsubOutput = on('pty:output', (msg) => {
|
||||||
|
|||||||
@@ -83,10 +83,3 @@ async function seedShellConfigs(homeDir: string): Promise<void> {
|
|||||||
// Ensure .local/bin exists
|
// Ensure .local/bin exists
|
||||||
mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true });
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,111 +1,10 @@
|
|||||||
import { getUsers, getUserByEmail, getUserById, createUser, deleteUser } from 'officerdb';
|
|
||||||
import { createRouter } from '@@/create-router';
|
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 { originMiddleware } from '@@/_middlewares';
|
||||||
import { updateUserHandler } from './update-user';
|
import { updateUserHandler } from './update-user';
|
||||||
import { deprovisionUserEnvironment } from './provision';
|
|
||||||
|
|
||||||
export const usersRouter = createRouter();
|
export const usersRouter = createRouter();
|
||||||
usersRouter.use(originMiddleware);
|
usersRouter.use(originMiddleware);
|
||||||
|
|
||||||
// List all users (Super Admin only)
|
// Self-update. Officer is single-user: the server owner is the only account, so there is no user
|
||||||
usersRouter.get('/', async (ctx) => {
|
// listing, invitation or deletion — the account is created once by /auth/bootstrap.
|
||||||
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)
|
|
||||||
usersRouter.put('/', updateUserHandler);
|
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 });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ export const channelsRouter = createRouter();
|
|||||||
// ── Admin: Discord config ──
|
// ── Admin: Discord config ──
|
||||||
|
|
||||||
channelsRouter.get('/discord/config', async (ctx) => {
|
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');
|
const integration = await getServerIntegration('discord');
|
||||||
if (!integration) return ctx.json({ configured: false });
|
if (!integration) return ctx.json({ configured: false });
|
||||||
|
|
||||||
@@ -36,9 +33,6 @@ channelsRouter.get('/discord/config', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
channelsRouter.put('/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<string, unknown>;
|
const body = ctx.get('body') as Record<string, unknown>;
|
||||||
const botToken = body.botToken as string | undefined;
|
const botToken = body.botToken as string | undefined;
|
||||||
const enabled = body.enabled as boolean | undefined;
|
const enabled = body.enabled as boolean | undefined;
|
||||||
@@ -131,9 +125,6 @@ channelsRouter.delete('/discord/connection', async (ctx) => {
|
|||||||
// ── Admin: Telegram config ──
|
// ── Admin: Telegram config ──
|
||||||
|
|
||||||
channelsRouter.get('/telegram/config', async (ctx) => {
|
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');
|
const integration = await getServerIntegration('telegram');
|
||||||
if (!integration) return ctx.json({ configured: false });
|
if (!integration) return ctx.json({ configured: false });
|
||||||
|
|
||||||
@@ -150,9 +141,6 @@ channelsRouter.get('/telegram/config', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
channelsRouter.put('/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<string, unknown>;
|
const body = ctx.get('body') as Record<string, unknown>;
|
||||||
const botToken = body.botToken as string | undefined;
|
const botToken = body.botToken as string | undefined;
|
||||||
const enabled = body.enabled as boolean | undefined;
|
const enabled = body.enabled as boolean | undefined;
|
||||||
@@ -245,9 +233,6 @@ channelsRouter.delete('/telegram/connection', async (ctx) => {
|
|||||||
// ── Admin: WhatsApp config ──
|
// ── Admin: WhatsApp config ──
|
||||||
|
|
||||||
channelsRouter.get('/whatsapp/config', async (ctx) => {
|
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');
|
const integration = await getServerIntegration('whatsapp');
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
@@ -259,9 +244,6 @@ channelsRouter.get('/whatsapp/config', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
channelsRouter.put('/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<string, unknown>;
|
const body = ctx.get('body') as Record<string, unknown>;
|
||||||
const enabled = body.enabled as boolean | undefined;
|
const enabled = body.enabled as boolean | undefined;
|
||||||
|
|
||||||
@@ -295,9 +277,6 @@ channelsRouter.get('/whatsapp/status', async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
channelsRouter.get('/whatsapp/qr', 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();
|
const qr = getWhatsAppQR();
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
qr,
|
qr,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ type SendAndAwaitParams = {
|
|||||||
context: string;
|
context: string;
|
||||||
contextId: string;
|
contextId: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SendAndAwaitResult = {
|
type SendAndAwaitResult = {
|
||||||
@@ -83,7 +82,6 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndA
|
|||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
model,
|
model,
|
||||||
role: params.role,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock!();
|
releaseLock!();
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ type ClaudeCodeParams = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClaudeCodeResult = {
|
type ClaudeCodeResult = {
|
||||||
@@ -38,7 +37,6 @@ type ClaudeCodeStreamingParams = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
onEvent: (event: ChatEvent) => void;
|
onEvent: (event: ChatEvent) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ export const AGENT_CONFIG_DIR = join(homedir(), '.pi', 'agent');
|
|||||||
|
|
||||||
export const SEED_PATH = resolve(import.meta.dir, '../../seed');
|
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 getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||||
|
|
||||||
export const getHomeDirForRole = (email: string, role: string | null): string =>
|
// Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform
|
||||||
role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email);
|
// 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');
|
export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent');
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -33,7 +33,7 @@ import { chatRouter } from './api/chat/chat';
|
|||||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||||
import { CustomError } from './custom-errors';
|
import { CustomError } from './custom-errors';
|
||||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
import { userMiddleware, bodyParser, isOriginAllowed } from './_middlewares';
|
||||||
|
|
||||||
export { Hono };
|
export { Hono };
|
||||||
export { createRouter };
|
export { createRouter };
|
||||||
@@ -77,7 +77,6 @@ const protectedRouter = createRouter();
|
|||||||
protectedRouter.use(bodyParser());
|
protectedRouter.use(bodyParser());
|
||||||
protectedRouter.use(userMiddleware);
|
protectedRouter.use(userMiddleware);
|
||||||
|
|
||||||
serverSettingsRouter.use(superAdminMiddleware);
|
|
||||||
protectedRouter.route('/server-settings', serverSettingsRouter);
|
protectedRouter.route('/server-settings', serverSettingsRouter);
|
||||||
protectedRouter.route('/users', usersRouter);
|
protectedRouter.route('/users', usersRouter);
|
||||||
protectedRouter.route('/plans', plansRouter);
|
protectedRouter.route('/plans', plansRouter);
|
||||||
@@ -104,7 +103,6 @@ protectedRouter.route('/bug-report', bugReportRouter);
|
|||||||
protectedRouter.route('/chat', chatRouter);
|
protectedRouter.route('/chat', chatRouter);
|
||||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||||
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||||
desktopRouter.use(superAdminMiddleware);
|
|
||||||
protectedRouter.route('/desktop', desktopRouter);
|
protectedRouter.route('/desktop', desktopRouter);
|
||||||
|
|
||||||
honoServer.route('/api', protectedRouter);
|
honoServer.route('/api', protectedRouter);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { join } from 'node:path';
|
|||||||
import type { Subprocess } from 'bun';
|
import type { Subprocess } from 'bun';
|
||||||
import type { ChatEvent } from '../../api/chat/types';
|
import type { ChatEvent } from '../../api/chat/types';
|
||||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||||
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox';
|
|
||||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||||
import { parseStream } from './stream-parser';
|
import { parseStream } from './stream-parser';
|
||||||
|
|
||||||
@@ -16,26 +15,13 @@ const CLAUDE_BIN = '/usr/local/bin/claude';
|
|||||||
const HOST_HOME = process.env.HOME!;
|
const HOST_HOME = process.env.HOME!;
|
||||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
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
|
// Active streaming processes
|
||||||
const activeProcs = new Map<string, Subprocess>();
|
const activeProcs = new Map<string, Subprocess>();
|
||||||
|
|
||||||
// MCP config paths, set by user-instance at startup
|
// 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
|
let mcpHostPath: string | undefined; // path on the host filesystem
|
||||||
|
|
||||||
export function setMcpConfigPath(sandboxPath: string, hostPath: string): void {
|
export function setMcpConfigPath(hostPath: string): void {
|
||||||
mcpSandboxPath = sandboxPath;
|
|
||||||
mcpHostPath = hostPath;
|
mcpHostPath = hostPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,8 +43,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
|||||||
|
|
||||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
||||||
|
|
||||||
const isSuperAdmin = params.role === 'Super Admin';
|
const mcpConfig = mcpHostPath;
|
||||||
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
|
|
||||||
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
||||||
|
|
||||||
const subModel = params.model?.split('/')[1];
|
const subModel = params.model?.split('/')[1];
|
||||||
@@ -68,10 +53,10 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
|||||||
claudeArgs.push('--resume', existingSession);
|
claudeArgs.push('--resume', existingSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
const spawnCmd = claudeArgs;
|
||||||
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions dir);
|
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions
|
||||||
// fall back to the host home for Super Admin, or the sandbox default otherwise.
|
// dir); fall back to the owner's host home.
|
||||||
const spawnCwd = isSuperAdmin ? (params.cwd ?? HOST_HOME) : undefined;
|
const spawnCwd = params.cwd ?? HOST_HOME;
|
||||||
|
|
||||||
const proc = Bun.spawn(spawnCmd, {
|
const proc = Bun.spawn(spawnCmd, {
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
@@ -156,8 +141,7 @@ export async function spawnClaudeStreaming(
|
|||||||
];
|
];
|
||||||
|
|
||||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||||
const isSuperAdmin = params.role === 'Super Admin';
|
const mcpConfig = mcpHostPath;
|
||||||
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
|
|
||||||
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
||||||
|
|
||||||
const subModel = params.model?.split('/')[1];
|
const subModel = params.model?.split('/')[1];
|
||||||
@@ -171,10 +155,10 @@ export async function spawnClaudeStreaming(
|
|||||||
if (!existingSession) setClaudeSession(sessionKey, resumeId);
|
if (!existingSession) setClaudeSession(sessionKey, resumeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
const spawnCmd = claudeArgs;
|
||||||
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions dir);
|
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions
|
||||||
// fall back to the host home for Super Admin, or the sandbox default otherwise.
|
// dir); fall back to the owner's host home.
|
||||||
const spawnCwd = isSuperAdmin ? (params.cwd ?? HOST_HOME) : undefined;
|
const spawnCwd = params.cwd ?? HOST_HOME;
|
||||||
|
|
||||||
const proc = Bun.spawn(spawnCmd, {
|
const proc = Bun.spawn(spawnCmd, {
|
||||||
stdin: 'ignore',
|
stdin: 'ignore',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { homedir } from 'node:os';
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock } from './state';
|
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock } from './state';
|
||||||
import { setMcpConfigPath } from './claude-manager';
|
import { setMcpConfigPath } from './claude-manager';
|
||||||
import { SANDBOX_DATA } from '../sandbox';
|
|
||||||
import * as claudeManager from './claude-manager';
|
import * as claudeManager from './claude-manager';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '../connect';
|
||||||
import { sign } from '../../jwt';
|
import { sign } from '../../jwt';
|
||||||
@@ -27,16 +26,12 @@ if (!dbUser) {
|
|||||||
console.error(`[user-instance] no user found for ${email}`);
|
console.error(`[user-instance] no user found for ${email}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const OFFICER_AUTH_TOKEN = await sign(
|
const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d');
|
||||||
{ id: dbUser.id, email, username: dbUser.username, role: dbUser.role },
|
|
||||||
'30d',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Single-user platform: the Super Admin runs Claude with no isolation — real HOME, real ~/.claude —
|
// Single-user platform: the owner runs Claude with no isolation — real HOME, real ~/.claude — so
|
||||||
// so platform sessions have perfect parity with terminal sessions (same config, credentials, and
|
// platform sessions have perfect parity with terminal sessions (same config, credentials and
|
||||||
// transcript store, interchangeable via `claude --resume`). Any non-super-admin keeps an isolated home.
|
// transcript store, interchangeable via `claude --resume`).
|
||||||
const homeDir =
|
const homeDir = process.env.HOME_DIR ?? homedir();
|
||||||
dbUser.role === 'Super Admin' ? (process.env.HOME_DIR ?? homedir()) : join(DATA_PATH, email, 'home');
|
|
||||||
const globalToolsDir = join(DATA_PATH, 'tools');
|
const globalToolsDir = join(DATA_PATH, 'tools');
|
||||||
|
|
||||||
// The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled).
|
// The email_db MCP tool reads one account's SQLite store; match the API's choice (first enabled).
|
||||||
@@ -60,52 +55,14 @@ if (!acquireLock()) {
|
|||||||
|
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
// ── CLAUDE.md refresh ──
|
|
||||||
|
|
||||||
function refreshClaudeMd(): void {
|
|
||||||
const claudeDir = join(homeDir, '.claude');
|
|
||||||
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
|
||||||
|
|
||||||
// generateContainerContext writes and returns the file path
|
|
||||||
// Import inline to avoid circular deps at module level
|
|
||||||
const { generateContainerContext } = require('../../generate-container-context') as {
|
|
||||||
generateContainerContext: (email: string) => string;
|
|
||||||
};
|
|
||||||
const contextFile = generateContainerContext(email!);
|
|
||||||
writeFileSync(join(claudeDir, 'CLAUDE.md'), readFileSync(contextFile, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MCP config ──
|
// ── MCP config ──
|
||||||
|
|
||||||
type McpPaths = { sandboxPath: string; hostPath: string };
|
function generateMcpConfig(): string {
|
||||||
|
|
||||||
function generateMcpConfig(): McpPaths {
|
|
||||||
const contextDir = join(DATA_PATH, email!, '.container-context');
|
const contextDir = join(DATA_PATH, email!, '.container-context');
|
||||||
mkdirSync(contextDir, { recursive: true });
|
mkdirSync(contextDir, { recursive: true });
|
||||||
|
|
||||||
const userRoot = join(DATA_PATH, email!);
|
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 hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
|
||||||
const hostConfig = {
|
const hostConfig = {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
@@ -125,27 +82,16 @@ function generateMcpConfig(): McpPaths {
|
|||||||
};
|
};
|
||||||
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig));
|
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig));
|
||||||
|
|
||||||
return {
|
return join(contextDir, 'mcp-host.json');
|
||||||
sandboxPath: `${SANDBOX_DATA}/.container-context/mcp.json`,
|
|
||||||
hostPath: join(contextDir, 'mcp-host.json'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Startup ──
|
// ── Startup ──
|
||||||
|
|
||||||
// Only sandboxed users get the generated container CLAUDE.md. For the un-isolated Super Admin, HOME is
|
// The owner runs un-isolated with HOME as their real home, so the generated container CLAUDE.md is
|
||||||
// the real home, so writing it there would pollute the personal global ~/.claude/CLAUDE.md (loaded by
|
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
||||||
// the terminal `claude` too) — parity means running as the user, not injecting platform context.
|
// terminal `claude` loads too.
|
||||||
if (dbUser.role !== 'Super Admin') {
|
|
||||||
try {
|
|
||||||
refreshClaudeMd();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[claude:${email}] failed to refresh CLAUDE.md:`, err instanceof Error ? err.message : err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mcpPaths = generateMcpConfig();
|
setMcpConfigPath(generateMcpConfig());
|
||||||
setMcpConfigPath(mcpPaths.sandboxPath, mcpPaths.hostPath);
|
|
||||||
|
|
||||||
console.log(`[claude:${email}] started (HOME=${homeDir})`);
|
console.log(`[claude:${email}] started (HOME=${homeDir})`);
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export type ClaudeSpawnParams = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ export type ClaudeSpawnStreamingParams = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
role?: string;
|
|
||||||
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
|
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,7 +106,6 @@ export type OpenCodeRunParams = {
|
|||||||
export type VncStartParams = {
|
export type VncStartParams = {
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: string | null;
|
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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, '--'];
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, mkdirSync } from 'node:fs';
|
import { existsSync, mkdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { VncStartParams, VncSessionInfo } from '../protocol';
|
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
|
// 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
|
// 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;
|
mirror = null;
|
||||||
|
|
||||||
const homeDir = getHomeDirForRole(params.email, params.role);
|
const homeDir = getOwnerHomeDir(params.email);
|
||||||
const passwdFile = await ensureVncPassword(homeDir);
|
const passwdFile = await ensureVncPassword(homeDir);
|
||||||
|
|
||||||
const xauthority = resolveXauthority();
|
const xauthority = resolveXauthority();
|
||||||
|
|||||||
@@ -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 USER_STATUSES = ['Unverified', 'Active', 'Prospect', 'Invited', 'Blocked', 'Banned', 'Deleted'] as const;
|
||||||
|
|
||||||
export const COMPANY_SIZES = ['1-10', '11-30', '31-50', '50+'] as const;
|
export const COMPANY_SIZES = ['1-10', '11-30', '31-50', '50+'] as const;
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Html>
|
|
||||||
<Head />
|
|
||||||
<Tailwind>
|
|
||||||
<Body className="mx-auto my-12 bg-white font-sans">
|
|
||||||
<Container className="rounded-lg bg-white p-8 shadow-lg">
|
|
||||||
<Img
|
|
||||||
className="mx-auto block"
|
|
||||||
src="/og-image.jpg"
|
|
||||||
width="480"
|
|
||||||
alt="officer.dev"
|
|
||||||
/>
|
|
||||||
<Text className="pt-4 text-2xl">You're invited to officer.dev</Text>
|
|
||||||
<Text>You have been invited by {invitedBy || '<invitedBy>'} to join officer.dev.</Text>
|
|
||||||
<Text>Click the button below to set up your account.</Text>
|
|
||||||
<Button href={url || 'https://example.com'}>Accept Invitation</Button>
|
|
||||||
</Container>
|
|
||||||
</Body>
|
|
||||||
</Tailwind>
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Email;
|
|
||||||
@@ -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 (
|
|
||||||
<Layout>
|
|
||||||
<Container>
|
|
||||||
<Text className="pt-4 text-2xl">Welcome, {name || 'there'}</Text>
|
|
||||||
<Text className="text-xs">You can ignore this email if you didn't signup for our site.</Text>
|
|
||||||
<Button href={url || 'https://example.com'}>Click here to set up your admin account</Button>
|
|
||||||
</Container>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Email;
|
|
||||||
@@ -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 (
|
|
||||||
<Layout>
|
|
||||||
<Container>
|
|
||||||
<Text className="pt-4 text-2xl">Welcome, {name || 'there'}</Text>
|
|
||||||
<Text className="text-xs">You can ignore this email if you didn't signup for our site.</Text>
|
|
||||||
<Button href={url || 'https://example.com'}>Click here to Confirm your Registration</Button>
|
|
||||||
</Container>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Email;
|
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
export type SignupUserForm = {
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateUserPayload = { name: string; username?: string; avatar: string };
|
export type UpdateUserPayload = { name: string; username?: string; avatar: string };
|
||||||
|
|
||||||
export type ResetPasswordPayload = { password: string; verificationCode: string };
|
export type ResetPasswordPayload = { password: string; verificationCode: string };
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
|||||||
const apiClient = useClient(apiUrl);
|
const apiClient = useClient(apiUrl);
|
||||||
const passKeyManager = usePasskeys();
|
const passKeyManager = usePasskeys();
|
||||||
|
|
||||||
|
|
||||||
const { isLoading } = useQuery<UserWithToken | null>({
|
const { isLoading } = useQuery<UserWithToken | null>({
|
||||||
queryKey: ['CURRENT_USER'],
|
queryKey: ['CURRENT_USER'],
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
@@ -70,22 +69,10 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
|||||||
localStorage.removeItem('CURRENT_USER');
|
localStorage.removeItem('CURRENT_USER');
|
||||||
};
|
};
|
||||||
|
|
||||||
const signup = async (newUser: SignupUserForm) => {
|
|
||||||
return await authClient.post('/signup', newUser);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetPassword = async (payload: ResetPasswordPayload) => {
|
const resetPassword = async (payload: ResetPasswordPayload) => {
|
||||||
await authClient.post('/reset-password', payload);
|
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) => {
|
const updateUser = async (payload: UpdateUserPayload) => {
|
||||||
await apiClient.put('/users', payload);
|
await apiClient.put('/users', payload);
|
||||||
localStorage.removeItem('CURRENT_USER');
|
localStorage.removeItem('CURRENT_USER');
|
||||||
@@ -112,8 +99,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
|||||||
refreshUser,
|
refreshUser,
|
||||||
signin,
|
signin,
|
||||||
signout,
|
signout,
|
||||||
signup,
|
|
||||||
verify,
|
|
||||||
resetPassword,
|
resetPassword,
|
||||||
updateUser,
|
updateUser,
|
||||||
changePassword,
|
changePassword,
|
||||||
@@ -122,12 +107,6 @@ export const useAuth = (props: UseAuthProps = {}) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignupUserForm = {
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateUserPayload = {
|
export type UpdateUserPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -145,11 +124,4 @@ export type ChangePasswordPayload = {
|
|||||||
confirmPassword: string;
|
confirmPassword: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VerifyPayload = {
|
|
||||||
verificationCode: string;
|
|
||||||
name?: string;
|
|
||||||
password?: string;
|
|
||||||
confirmPassword?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ForgotPasswordPayload = { email: string };
|
export type ForgotPasswordPayload = { email: string };
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { EmbeddableChat } from './EmbeddableChat';
|
|||||||
|
|
||||||
type ChatPanelInnerProps = {
|
type ChatPanelInnerProps = {
|
||||||
scoped: boolean;
|
scoped: boolean;
|
||||||
sandboxed: boolean;
|
|
||||||
cwdParam?: { root?: string; path: string };
|
cwdParam?: { root?: string; path: string };
|
||||||
promptPrefix?: string;
|
promptPrefix?: string;
|
||||||
chatContext: Record<string, string | undefined>;
|
chatContext: Record<string, string | undefined>;
|
||||||
@@ -14,7 +13,14 @@ type ChatPanelInnerProps = {
|
|||||||
onTurnComplete?: (hadToolCalls: boolean) => void;
|
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, {
|
const chat = useChat(undefined, undefined, {
|
||||||
replaceUrl: false,
|
replaceUrl: false,
|
||||||
projectScoped: scoped,
|
projectScoped: scoped,
|
||||||
@@ -31,7 +37,6 @@ const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext
|
|||||||
className="h-full"
|
className="h-full"
|
||||||
chat={chat}
|
chat={chat}
|
||||||
cwd={cwdParam}
|
cwd={cwdParam}
|
||||||
sandboxed={sandboxed}
|
|
||||||
replaceUrl={false}
|
replaceUrl={false}
|
||||||
promptPrefix={promptPrefix}
|
promptPrefix={promptPrefix}
|
||||||
{...chatContext}
|
{...chatContext}
|
||||||
@@ -42,8 +47,6 @@ const ChatPanelInner = ({ scoped, sandboxed, cwdParam, promptPrefix, chatContext
|
|||||||
export const ChatPanelWrapper = () => {
|
export const ChatPanelWrapper = () => {
|
||||||
const { dashboardId, cwd, root, promptPrefix } = useWorkspace();
|
const { dashboardId, cwd, root, promptPrefix } = useWorkspace();
|
||||||
const scoped = cwd !== '~';
|
const scoped = cwd !== '~';
|
||||||
const hostRoot = root === '~' || root === 'officer.dev';
|
|
||||||
const sandboxed = !hostRoot;
|
|
||||||
|
|
||||||
const chatContext =
|
const chatContext =
|
||||||
dashboardId === 'email' || dashboardId === 'screens/email'
|
dashboardId === 'email' || dashboardId === 'screens/email'
|
||||||
@@ -73,7 +76,6 @@ export const ChatPanelWrapper = () => {
|
|||||||
return (
|
return (
|
||||||
<ChatPanelInner
|
<ChatPanelInner
|
||||||
scoped={scoped}
|
scoped={scoped}
|
||||||
sandboxed={sandboxed}
|
|
||||||
cwdParam={cwdParam}
|
cwdParam={cwdParam}
|
||||||
promptPrefix={promptPrefix}
|
promptPrefix={promptPrefix}
|
||||||
chatContext={chatContext}
|
chatContext={chatContext}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type EmbeddableChatProps = {
|
|||||||
promptPrefix?: string;
|
promptPrefix?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
cwd?: { root?: string; path: string };
|
cwd?: { root?: string; path: string };
|
||||||
sandboxed?: boolean;
|
|
||||||
replaceUrl?: boolean;
|
replaceUrl?: boolean;
|
||||||
autoSend?: boolean;
|
autoSend?: boolean;
|
||||||
chat?: UseChatType;
|
chat?: UseChatType;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ type UseEmbeddableChatParams = {
|
|||||||
defaultInput?: string;
|
defaultInput?: string;
|
||||||
promptPrefix?: string;
|
promptPrefix?: string;
|
||||||
cwd?: { root?: string; path: string };
|
cwd?: { root?: string; path: string };
|
||||||
sandboxed?: boolean;
|
|
||||||
replaceUrl?: boolean;
|
replaceUrl?: boolean;
|
||||||
autoSend?: boolean;
|
autoSend?: boolean;
|
||||||
chat?: UseChatType;
|
chat?: UseChatType;
|
||||||
@@ -26,15 +25,7 @@ type UseEmbeddableChatParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
|
export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComplete?: () => void) {
|
||||||
const {
|
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
|
||||||
initialMessage,
|
|
||||||
defaultInput = '',
|
|
||||||
promptPrefix,
|
|
||||||
cwd,
|
|
||||||
sandboxed,
|
|
||||||
autoSend = false,
|
|
||||||
chat: externalChat,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
const internalChat = useChat(params.sessionId, params.initialModel, {
|
const internalChat = useChat(params.sessionId, params.initialModel, {
|
||||||
replaceUrl: params.replaceUrl ?? false,
|
replaceUrl: params.replaceUrl ?? false,
|
||||||
@@ -111,7 +102,6 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
images.length > 0 ? images : undefined,
|
images.length > 0 ? images : undefined,
|
||||||
cwd,
|
cwd,
|
||||||
undefined,
|
undefined,
|
||||||
sandboxed,
|
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
displayText,
|
displayText,
|
||||||
);
|
);
|
||||||
@@ -208,7 +198,6 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
|||||||
initialMessage.images,
|
initialMessage.images,
|
||||||
initialMessage.cwd,
|
initialMessage.cwd,
|
||||||
undefined,
|
undefined,
|
||||||
sandboxed,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [initialMessage, isConnected]);
|
}, [initialMessage, isConnected]);
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ type NewChatProps = {
|
|||||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
|
function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const { user } = useAuth();
|
|
||||||
const isSuperAdmin = user?.role === 'Super Admin';
|
|
||||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||||
|
|
||||||
// Refresh the /chat list — Claude has just written/appended this session's transcript.
|
// 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',
|
context: 'chat',
|
||||||
});
|
});
|
||||||
|
|
||||||
const sandboxed = !isSuperAdmin;
|
|
||||||
// Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions).
|
// Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions).
|
||||||
const [activeCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
|
const [activeCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
|
||||||
const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd;
|
const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd;
|
||||||
@@ -103,7 +100,6 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages }: NewChatPro
|
|||||||
initialMessage={initialMessage}
|
initialMessage={initialMessage}
|
||||||
defaultInput={locationState?.prefillInput ?? ''}
|
defaultInput={locationState?.prefillInput ?? ''}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
sandboxed={sandboxed}
|
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { DesktopView } from './DesktopView';
|
import { DesktopView } from './DesktopView';
|
||||||
|
|
||||||
export const DesktopWrapper = () => {
|
export const DesktopWrapper = () => <DesktopView className="h-full w-full" />;
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
if (user?.role !== 'Super Admin') {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Remote Desktop requires Super Admin permissions.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <DesktopView className="h-full w-full" />;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -32,13 +32,5 @@ export const CliampPanelBody = () => {
|
|||||||
});
|
});
|
||||||
}, [setSearchParams]);
|
}, [setSearchParams]);
|
||||||
|
|
||||||
return (
|
return <TerminalView className="h-full w-full" wsPath={wsPath} onExit={handleExit} autoFocus />;
|
||||||
<TerminalView
|
|
||||||
className="h-full w-full"
|
|
||||||
wsPath={wsPath}
|
|
||||||
sandboxed={false}
|
|
||||||
onExit={handleExit}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
+269
-69
@@ -12,7 +12,13 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import type { TaskSummary } from '../../useTasks';
|
import type { TaskSummary } from '../../useTasks';
|
||||||
import { useTaskRunner } from './useTaskRunner';
|
import { useTaskRunner } from './useTaskRunner';
|
||||||
import { usePipelineRunner } from './usePipelineRunner';
|
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 playDing = () => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
@@ -46,11 +52,17 @@ type AgenticTaskRunnerProps = {
|
|||||||
cwd: { root?: string; path: string };
|
cwd: { root?: string; path: string };
|
||||||
initialModel: string | null;
|
initialModel: string | null;
|
||||||
taskInfo: TaskInfo;
|
taskInfo: TaskInfo;
|
||||||
sandboxed?: boolean;
|
|
||||||
context: Record<string, string>;
|
context: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AgenticTaskRunner = ({ taskDirName, defaultInput, cwd, initialModel, taskInfo, sandboxed, context }: AgenticTaskRunnerProps) => {
|
const AgenticTaskRunner = ({
|
||||||
|
taskDirName,
|
||||||
|
defaultInput,
|
||||||
|
cwd,
|
||||||
|
initialModel,
|
||||||
|
taskInfo,
|
||||||
|
context,
|
||||||
|
}: AgenticTaskRunnerProps) => {
|
||||||
const [phase, setPhase] = useState<Phase>('ready');
|
const [phase, setPhase] = useState<Phase>('ready');
|
||||||
const chat = useChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
const chat = useChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||||
const availableModels = useUserVisibleModels();
|
const availableModels = useUserVisibleModels();
|
||||||
@@ -159,7 +171,7 @@ const AgenticTaskRunner = ({ taskDirName, defaultInput, cwd, initialModel, taskI
|
|||||||
prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
prompt = `${taskBody.trim()}\n\n## Inputs\n\n${inputLines}${contextSection}`;
|
||||||
}
|
}
|
||||||
setPhase('running');
|
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;
|
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}`;
|
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.
|
// 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 chLabel = (n: number) =>
|
||||||
const audioMeta = (t: AudioTrack) => [chLabel(t.channels), t.codec].filter(Boolean).join(' ') + (t.bitrate ? ` ${t.bitrate}k` : '');
|
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.
|
// subtitle_edit input: per-subtitle keep flag + editable label, serialized to JSON in the form value.
|
||||||
type SubtitleEditEntry = { id: number; keep: boolean; label: string };
|
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));
|
const configurableInputs = Object.entries(inputDefs).filter(([key]) => !autoFilledKeys.has(key));
|
||||||
if (configurableInputs.length === 0) return null;
|
if (configurableInputs.length === 0) return null;
|
||||||
|
|
||||||
@@ -298,16 +322,21 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
|||||||
if (def.type === 'subtitle_edit') {
|
if (def.type === 'subtitle_edit') {
|
||||||
const tracks = subtitleTracks ?? [];
|
const tracks = subtitleTracks ?? [];
|
||||||
const byId = new Map(parseSubtitleSpec(values[key]).map((e) => [e.id, e]));
|
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<SubtitleEditEntry>) =>
|
const update = (id: number, patch: Partial<SubtitleEditEntry>) =>
|
||||||
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;
|
const keptCount = tracks.filter((t) => entryFor(t).keep).length;
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1.5">
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||||
{probing ? (
|
{probing ? (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" /> {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
<Loader2 className="h-3 w-3 animate-spin" />{' '}
|
||||||
|
{entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||||
</span>
|
</span>
|
||||||
) : tracks.length === 0 ? (
|
) : tracks.length === 0 ? (
|
||||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">No subtitles</span>
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">No subtitles</span>
|
||||||
@@ -372,18 +401,32 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
|||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||||
{probing ? (
|
{probing ? (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" /> {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
<Loader2 className="h-3 w-3 animate-spin" />{' '}
|
||||||
|
{entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||||
</span>
|
</span>
|
||||||
) : tracks.length === 0 ? (
|
) : tracks.length === 0 ? (
|
||||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||||
{tracks.map((t) => (
|
{tracks.map((t) => (
|
||||||
<label key={t.id} className="flex items-center gap-1.5 text-sm cursor-pointer text-duck-dark dark:text-foreground">
|
<label
|
||||||
<input type="checkbox" checked={selected.has(t.id)} onChange={() => toggle(t.id)} className="accent-duck-teal cursor-pointer" />
|
key={t.id}
|
||||||
|
className="flex items-center gap-1.5 text-sm cursor-pointer text-duck-dark dark:text-foreground"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(t.id)}
|
||||||
|
onChange={() => toggle(t.id)}
|
||||||
|
className="accent-duck-teal cursor-pointer"
|
||||||
|
/>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
{trackLabel(t)}
|
{trackLabel(t)}
|
||||||
{isAudio && <span className="text-duck-dark/40 dark:text-foreground/40"> · {(t as AudioTrack).channels}ch</span>}
|
{isAudio && (
|
||||||
|
<span className="text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
{' '}
|
||||||
|
· {(t as AudioTrack).channels}ch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -398,7 +441,9 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
|||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center justify-between gap-4">
|
<div key={key} className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||||
|
{def.description ?? key}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
|
<div className="flex items-center gap-1 shrink-0 bg-duck-dark/5 dark:bg-foreground/5 rounded-lg p-0.5">
|
||||||
<button
|
<button
|
||||||
@@ -441,7 +486,9 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
|||||||
// Default: text input for string/number
|
// Default: text input for string/number
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1">
|
<div key={key} className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">{def.description ?? key}</label>
|
<label className="text-xs font-medium text-duck-dark/70 dark:text-foreground/70">
|
||||||
|
{def.description ?? key}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type={def.type === 'number' ? 'number' : 'text'}
|
type={def.type === 'number' ? 'number' : 'text'}
|
||||||
value={values[key] ?? ''}
|
value={values[key] ?? ''}
|
||||||
@@ -515,14 +562,27 @@ const FolderSummary = ({ folder, keepAll = false, onKeepAllChange }: FolderSumma
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1.5 text-sm text-duck-dark dark:text-foreground">
|
<div className="flex flex-col gap-1.5 text-sm text-duck-dark dark:text-foreground">
|
||||||
<label className="flex items-start gap-2 cursor-pointer">
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
<input type="radio" checked={!keepAll} onChange={() => onKeepAllChange(false)} className="accent-duck-teal cursor-pointer mt-0.5" />
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={!keepAll}
|
||||||
|
onChange={() => onKeepAllChange(false)}
|
||||||
|
className="accent-duck-teal cursor-pointer mt-0.5"
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
Convert the {majorityCount} matching
|
Convert the {majorityCount} matching
|
||||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50"> — pick tracks below; the other {skipped.length} are skipped</span>
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||||
|
{' '}
|
||||||
|
— pick tracks below; the other {skipped.length} are skipped
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-start gap-2 cursor-pointer">
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
<input type="radio" checked={keepAll} onChange={() => onKeepAllChange(true)} className="accent-duck-teal cursor-pointer mt-0.5" />
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={keepAll}
|
||||||
|
onChange={() => onKeepAllChange(true)}
|
||||||
|
className="accent-duck-teal cursor-pointer mt-0.5"
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
Convert all {fileCount} — keep every track
|
Convert all {fileCount} — keep every track
|
||||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50"> — nothing skipped</span>
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50"> — nothing skipped</span>
|
||||||
@@ -552,7 +612,14 @@ type PickerKinds = { audio: boolean; subs: boolean; subEdit: boolean };
|
|||||||
// Per-group selection: audio/subs are kept-id csv (or 'none'); subEdit is the keep+label list.
|
// Per-group selection: audio/subs are kept-id csv (or 'none'); subEdit is the keep+label list.
|
||||||
type GroupSel = { audio: string; subs: string; subEdit: SubtitleEditEntry[] };
|
type GroupSel = { audio: string; subs: string; subEdit: SubtitleEditEntry[] };
|
||||||
|
|
||||||
const parseCsv = (csv: string) => new Set(csv.split(',').filter(Boolean).map(Number).filter((n) => !Number.isNaN(n)));
|
const parseCsv = (csv: string) =>
|
||||||
|
new Set(
|
||||||
|
csv
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(Number)
|
||||||
|
.filter((n) => !Number.isNaN(n)),
|
||||||
|
);
|
||||||
|
|
||||||
const pickerKinds = (defs: Record<string, TaskInputDef> | null): PickerKinds => ({
|
const pickerKinds = (defs: Record<string, TaskInputDef> | null): PickerKinds => ({
|
||||||
audio: Object.values(defs ?? {}).some((d) => d.type === 'audio_tracks'),
|
audio: Object.values(defs ?? {}).some((d) => d.type === 'audio_tracks'),
|
||||||
@@ -568,8 +635,10 @@ const defaultGroupSel = (g: FolderTrackGroup): GroupSel => ({
|
|||||||
|
|
||||||
// Does a group's selection change anything vs keep-all + original labels?
|
// Does a group's selection change anything vs keep-all + original labels?
|
||||||
const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => {
|
const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boolean => {
|
||||||
if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length)) return true;
|
if (has.audio && (s.audio === 'none' ? g.audioTracks.length > 0 : parseCsv(s.audio).size < g.audioTracks.length))
|
||||||
if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length)) return true;
|
return true;
|
||||||
|
if (has.subs && (s.subs === 'none' ? g.subtitleTracks.length > 0 : parseCsv(s.subs).size < g.subtitleTracks.length))
|
||||||
|
return true;
|
||||||
if (has.subEdit) {
|
if (has.subEdit) {
|
||||||
for (const t of g.subtitleTracks) {
|
for (const t of g.subtitleTracks) {
|
||||||
const e = s.subEdit.find((x) => x.id === t.id);
|
const e = s.subEdit.find((x) => x.id === t.id);
|
||||||
@@ -582,7 +651,8 @@ const groupChanges = (g: FolderTrackGroup, s: GroupSel, has: PickerKinds): boole
|
|||||||
// Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps
|
// Build the JSON group config the scripts consume ($INPUT_GROUP_CONFIG). allFiles=true (convert) keeps
|
||||||
// every group; otherwise only groups that actually change are included.
|
// every group; otherwise only groups that actually change are included.
|
||||||
const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => {
|
const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: PickerKinds, allFiles: boolean) => {
|
||||||
const spec = (csv: string, count: number) => (csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(','));
|
const spec = (csv: string, count: number) =>
|
||||||
|
csv === 'none' ? 'none' : parseCsv(csv).size >= count ? 'all' : [...parseCsv(csv)].sort((a, b) => a - b).join(',');
|
||||||
return groups
|
return groups
|
||||||
.map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) }))
|
.map((g, gi) => ({ g, s: sel[gi] ?? defaultGroupSel(g) }))
|
||||||
.filter(({ g, s }) => allFiles || groupChanges(g, s, has))
|
.filter(({ g, s }) => allFiles || groupChanges(g, s, has))
|
||||||
@@ -590,7 +660,13 @@ const buildGroupConfig = (groups: FolderTrackGroup[], sel: GroupSel[], has: Pick
|
|||||||
files: g.files,
|
files: g.files,
|
||||||
...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}),
|
...(has.audio ? { audio: spec(s.audio, g.audioTracks.length) } : {}),
|
||||||
...(has.subs ? { subs: spec(s.subs, g.subtitleTracks.length) } : {}),
|
...(has.subs ? { subs: spec(s.subs, g.subtitleTracks.length) } : {}),
|
||||||
...(has.subEdit ? { subEdit: g.subtitleTracks.map((t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) }) } : {}),
|
...(has.subEdit
|
||||||
|
? {
|
||||||
|
subEdit: g.subtitleTracks.map(
|
||||||
|
(t) => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -617,7 +693,11 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const label = (kind: string) => <span className="text-[11px] font-medium uppercase tracking-wide text-duck-dark/40 dark:text-foreground/40">{kind}</span>;
|
const label = (kind: string) => (
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wide text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
{kind}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-3">
|
||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||||
@@ -637,14 +717,23 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
|||||||
n.has(id) ? n.delete(id) : n.add(id);
|
n.has(id) ? n.delete(id) : n.add(id);
|
||||||
onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
|
onChange(gi, { subs: n.size ? [...n].sort((a, b) => a - b).join(',') : 'none' });
|
||||||
};
|
};
|
||||||
const entryFor = (t: SubtitleTrack): SubtitleEditEntry => s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
|
const entryFor = (t: SubtitleTrack): SubtitleEditEntry =>
|
||||||
|
s.subEdit.find((x) => x.id === t.id) ?? { id: t.id, keep: true, label: trackLabel(t) };
|
||||||
const updateEdit = (id: number, patch: Partial<SubtitleEditEntry>) =>
|
const updateEdit = (id: number, patch: Partial<SubtitleEditEntry>) =>
|
||||||
onChange(gi, { subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))) });
|
onChange(gi, {
|
||||||
|
subEdit: g.subtitleTracks.map((t) => (t.id === id ? { ...entryFor(t), ...patch } : entryFor(t))),
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div key={g.signature + gi} className="flex flex-col gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
<div
|
||||||
|
key={g.signature + gi}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
||||||
|
>
|
||||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
<span className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||||
Group {gi + 1}
|
Group {gi + 1}
|
||||||
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal"> · {g.count} file{g.count !== 1 ? 's' : ''}</span>
|
<span className="text-duck-dark/50 dark:text-foreground/50 font-normal">
|
||||||
|
{' '}
|
||||||
|
· {g.count} file{g.count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{has.audio && (
|
{has.audio && (
|
||||||
@@ -654,8 +743,16 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
|||||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
|
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
|
||||||
) : (
|
) : (
|
||||||
g.audioTracks.map((t) => (
|
g.audioTracks.map((t) => (
|
||||||
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
|
<label
|
||||||
<input type="checkbox" checked={aSel.has(t.id)} onChange={() => toggleA(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
|
key={t.id}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aSel.has(t.id)}
|
||||||
|
onChange={() => toggleA(t.id)}
|
||||||
|
className="accent-duck-teal cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
{trackLabel(t)}
|
{trackLabel(t)}
|
||||||
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
|
<span className="text-duck-dark/40 dark:text-foreground/40"> · {audioMeta(t)}</span>
|
||||||
@@ -673,8 +770,16 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
|||||||
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
|
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">none</span>
|
||||||
) : (
|
) : (
|
||||||
g.subtitleTracks.map((t) => (
|
g.subtitleTracks.map((t) => (
|
||||||
<label key={t.id} className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground">
|
<label
|
||||||
<input type="checkbox" checked={sSel.has(t.id)} onChange={() => toggleS(t.id)} className="accent-duck-teal cursor-pointer shrink-0" />
|
key={t.id}
|
||||||
|
className="flex items-center gap-2 text-sm cursor-pointer text-duck-dark dark:text-foreground"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={sSel.has(t.id)}
|
||||||
|
onChange={() => toggleS(t.id)}
|
||||||
|
className="accent-duck-teal cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
{trackLabel(t)}
|
{trackLabel(t)}
|
||||||
<span className="text-duck-dark/40 dark:text-foreground/40"> · {t.codec}</span>
|
<span className="text-duck-dark/40 dark:text-foreground/40"> · {t.codec}</span>
|
||||||
@@ -696,8 +801,15 @@ const PerGroupTrackConfig = ({ groups, has, sel, onChange, probing }: PerGroupTr
|
|||||||
return (
|
return (
|
||||||
<div key={t.id} className="flex items-center gap-2">
|
<div key={t.id} className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-2 cursor-pointer shrink-0">
|
<label className="flex items-center gap-2 cursor-pointer shrink-0">
|
||||||
<input type="checkbox" checked={e.keep} onChange={() => updateEdit(t.id, { keep: !e.keep })} className="accent-duck-teal cursor-pointer" />
|
<input
|
||||||
<span className="text-[10px] font-mono uppercase w-9 text-duck-dark/40 dark:text-foreground/40">{t.lang || 'und'}</span>
|
type="checkbox"
|
||||||
|
checked={e.keep}
|
||||||
|
onChange={() => updateEdit(t.id, { keep: !e.keep })}
|
||||||
|
className="accent-duck-teal cursor-pointer"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-mono uppercase w-9 text-duck-dark/40 dark:text-foreground/40">
|
||||||
|
{t.lang || 'und'}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -743,7 +855,16 @@ type ScriptRunnerProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames, onClose }: ScriptRunnerProps) => {
|
const ScriptRunner = ({
|
||||||
|
taskDirName,
|
||||||
|
autoInputs,
|
||||||
|
context,
|
||||||
|
cwd,
|
||||||
|
entryType,
|
||||||
|
filePath,
|
||||||
|
selectedNames,
|
||||||
|
onClose,
|
||||||
|
}: ScriptRunnerProps) => {
|
||||||
const runner = useTaskRunner();
|
const runner = useTaskRunner();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -779,7 +900,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
// Fetch task detail to get input definitions
|
// Fetch task detail to get input definitions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client
|
client
|
||||||
.get<{ inline?: boolean; inputs?: Record<string, TaskInputDef>; config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean } }>(`/tasks/${taskDirName}`)
|
.get<{
|
||||||
|
inline?: boolean;
|
||||||
|
inputs?: Record<string, TaskInputDef>;
|
||||||
|
config?: { folderKeepAll?: boolean; perGroupTracks?: boolean; perGroupAllFiles?: boolean };
|
||||||
|
}>(`/tasks/${taskDirName}`)
|
||||||
.then((task) => {
|
.then((task) => {
|
||||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||||
setInline(task.inline === true);
|
setInline(task.inline === true);
|
||||||
@@ -912,13 +1037,18 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
// Non-inline tasks become jobs — check if one is already running so we can offer Queue.
|
// Non-inline tasks become jobs — check if one is already running so we can offer Queue.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inline) return;
|
if (inline) return;
|
||||||
client.get<Array<unknown>>('/jobs?live=1').then((live) => setJobRunning(live.length > 0)).catch(() => {});
|
client
|
||||||
|
.get<Array<unknown>>('/jobs?live=1')
|
||||||
|
.then((live) => setJobRunning(live.length > 0))
|
||||||
|
.catch(() => {});
|
||||||
}, [inline]);
|
}, [inline]);
|
||||||
|
|
||||||
// Which per-group pickers this task declares, and whether the current selection is real work.
|
// Which per-group pickers this task declares, and whether the current selection is real work.
|
||||||
const has = pickerKinds(inputDefs);
|
const has = pickerKinds(inputDefs);
|
||||||
const perGroupHasWork =
|
const perGroupHasWork =
|
||||||
perGroupTracks && entryType === 'directory' && buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
|
perGroupTracks &&
|
||||||
|
entryType === 'directory' &&
|
||||||
|
buildGroupConfig(allGroups, groupSel, has, perGroupAllFiles).length > 0;
|
||||||
|
|
||||||
// Collect the final input map (per-group config / include list / keep-all overrides all fold in here).
|
// Collect the final input map (per-group config / include list / keep-all overrides all fold in here).
|
||||||
const buildAllInputs = (): Record<string, string> => {
|
const buildAllInputs = (): Record<string, string> => {
|
||||||
@@ -940,7 +1070,12 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
const runInline = () => runner.run(taskDirName, buildAllInputs(), cwd);
|
const runInline = () => runner.run(taskDirName, buildAllInputs(), cwd);
|
||||||
const submitJob = async (action: 'start' | 'queue') => {
|
const submitJob = async (action: 'start' | 'queue') => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = await client.post<{ jobId: string }>('/jobs', { taskDirName, inputs: buildAllInputs(), cwd, action });
|
const { jobId } = await client.post<{ jobId: string }>('/jobs', {
|
||||||
|
taskDirName,
|
||||||
|
inputs: buildAllInputs(),
|
||||||
|
cwd,
|
||||||
|
action,
|
||||||
|
});
|
||||||
setCreated({ jobId, action });
|
setCreated({ jobId, action });
|
||||||
} catch {
|
} catch {
|
||||||
/* stays on the modal so the user can retry */
|
/* stays on the modal so the user can retry */
|
||||||
@@ -956,12 +1091,17 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
{created.action === 'queue' ? 'Job queued' : 'Job started'}
|
{created.action === 'queue' ? 'Job queued' : 'Job started'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
|
<div className="text-sm text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||||
{created.action === 'queue' ? 'It will run when the current job finishes.' : "It’s running in the background."}
|
{created.action === 'queue'
|
||||||
|
? 'It will run when the current job finishes.'
|
||||||
|
: 'It’s running in the background.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => { onClose(); navigate(created.action === 'queue' ? '/jobs' : `/jobs/${created.jobId}`); }}
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate(created.action === 'queue' ? '/jobs' : `/jobs/${created.jobId}`);
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-duck-teal text-white text-sm font-medium hover:bg-duck-teal/90 cursor-pointer"
|
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-duck-teal text-white text-sm font-medium hover:bg-duck-teal/90 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" /> {created.action === 'queue' ? 'View queue' : 'View job'}
|
<ExternalLink className="h-4 w-4" /> {created.action === 'queue' ? 'View queue' : 'View job'}
|
||||||
@@ -1000,7 +1140,9 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
<FolderSummary
|
<FolderSummary
|
||||||
folder={folder}
|
folder={folder}
|
||||||
keepAll={keepAll}
|
keepAll={keepAll}
|
||||||
onKeepAllChange={folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined}
|
onKeepAllChange={
|
||||||
|
folderKeepAll && hasSelectablePickers && folder.skipped.length > 0 ? setKeepAll : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{inputDefs && (
|
{inputDefs && (
|
||||||
@@ -1022,10 +1164,15 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
<div className="shrink-0 flex items-center justify-center gap-3 border-t border-duck-dark/10 py-4">
|
<div className="shrink-0 flex items-center justify-center gap-3 border-t border-duck-dark/10 py-4">
|
||||||
{(() => {
|
{(() => {
|
||||||
const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork;
|
const noWork = perGroupTracks && entryType === 'directory' && !perGroupHasWork;
|
||||||
const base = 'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
|
const base =
|
||||||
|
'flex items-center gap-2 px-6 py-2.5 rounded-lg font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
|
||||||
if (inline) {
|
if (inline) {
|
||||||
return (
|
return (
|
||||||
<button onClick={runInline} disabled={!runner.isConnected || !inputDefs || probing || noWork} className={`${base} bg-duck-teal text-white hover:bg-duck-teal/90`}>
|
<button
|
||||||
|
onClick={runInline}
|
||||||
|
disabled={!runner.isConnected || !inputDefs || probing || noWork}
|
||||||
|
className={`${base} bg-duck-teal text-white hover:bg-duck-teal/90`}
|
||||||
|
>
|
||||||
<Play className="h-4 w-4" /> Run
|
<Play className="h-4 w-4" /> Run
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -1034,7 +1181,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{jobRunning && (
|
{jobRunning && (
|
||||||
<button onClick={() => submitJob('queue')} disabled={jobDisabled} className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`}>
|
<button
|
||||||
|
onClick={() => submitJob('queue')}
|
||||||
|
disabled={jobDisabled}
|
||||||
|
className={`${base} bg-duck-dark/10 dark:bg-foreground/10 text-duck-dark dark:text-foreground hover:bg-duck-dark/15 dark:hover:bg-foreground/15`}
|
||||||
|
>
|
||||||
Queue
|
Queue
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -1134,7 +1285,12 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
|
|
||||||
// Fetch task detail for inputs + check for concurrent steps
|
// Fetch task detail for inputs + check for concurrent steps
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.get<{ inputs?: Record<string, TaskInputDef>; config?: { steps?: Array<{ task: string; foreach?: string; concurrency?: string | boolean }> } }>(`/tasks/${taskDirName}`).then((task) => {
|
client
|
||||||
|
.get<{
|
||||||
|
inputs?: Record<string, TaskInputDef>;
|
||||||
|
config?: { steps?: Array<{ task: string; foreach?: string; concurrency?: string | boolean }> };
|
||||||
|
}>(`/tasks/${taskDirName}`)
|
||||||
|
.then((task) => {
|
||||||
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
const defs: Record<string, TaskInputDef> = task.inputs ?? {};
|
||||||
setInputDefs(defs);
|
setInputDefs(defs);
|
||||||
const initial: Record<string, string> = {};
|
const initial: Record<string, string> = {};
|
||||||
@@ -1290,9 +1446,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
{pipeline.currentStep.status === 'running' && (
|
{pipeline.currentStep.status === 'running' && (
|
||||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
{pipeline.currentStep.status === 'complete' && (
|
{pipeline.currentStep.status === 'complete' && <span className="ml-auto text-xs text-green-600">done</span>}
|
||||||
<span className="ml-auto text-xs text-green-600">done</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1310,9 +1464,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
{pDone + pError < pTotal && (
|
{pDone + pError < pTotal && (
|
||||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
)}
|
)}
|
||||||
{pDone + pError === pTotal && pTotal > 0 && (
|
{pDone + pError === pTotal && pTotal > 0 && <span className="ml-auto text-xs text-green-600">done</span>}
|
||||||
<span className="ml-auto text-xs text-green-600">done</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1339,11 +1491,15 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
<div className="px-4 py-3 space-y-1">
|
<div className="px-4 py-3 space-y-1">
|
||||||
{ps.iterations.map((it) => (
|
{ps.iterations.map((it) => (
|
||||||
<div key={it.label} className="flex items-center gap-2 py-1 px-2 rounded text-sm">
|
<div key={it.label} className="flex items-center gap-2 py-1 px-2 rounded text-sm">
|
||||||
{it.status === 'pending' && <span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />}
|
{it.status === 'pending' && (
|
||||||
|
<span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />
|
||||||
|
)}
|
||||||
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
|
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
|
||||||
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
|
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
|
||||||
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
|
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
|
||||||
<span className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}>
|
<span
|
||||||
|
className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}
|
||||||
|
>
|
||||||
{it.label}
|
{it.label}
|
||||||
</span>
|
</span>
|
||||||
{it.cost && (
|
{it.cost && (
|
||||||
@@ -1398,7 +1554,9 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
)}
|
)}
|
||||||
{pipeline.totalCost && (
|
{pipeline.totalCost && (
|
||||||
<span className="text-xs text-duck-dark/40 font-mono tabular-nums">
|
<span className="text-xs text-duck-dark/40 font-mono tabular-nums">
|
||||||
{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)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pipeline.skippedItems.length > 0 && (
|
{pipeline.skippedItems.length > 0 && (
|
||||||
@@ -1408,7 +1566,10 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
|||||||
)}
|
)}
|
||||||
{pipeline.jobId && (
|
{pipeline.jobId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onOpenChange(false); navigate(`/jobs/${pipeline.jobId}`); }}
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate(`/jobs/${pipeline.jobId}`);
|
||||||
|
}}
|
||||||
className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1 cursor-pointer"
|
className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
@@ -1430,12 +1591,23 @@ type TaskRunnerModalProps = {
|
|||||||
cwd?: { root?: string; path: string };
|
cwd?: { root?: string; path: string };
|
||||||
promptOverride?: string;
|
promptOverride?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
selectedNames?: string[];
|
selectedNames?: string[];
|
||||||
folderFullPath?: string;
|
folderFullPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride, description, sandboxed, selectedNames, folderFullPath }: TaskRunnerModalProps) => {
|
export const TaskRunnerModal = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
task,
|
||||||
|
entryName,
|
||||||
|
entryFullPath,
|
||||||
|
entryType,
|
||||||
|
cwd = { path: '' },
|
||||||
|
promptOverride,
|
||||||
|
description,
|
||||||
|
selectedNames,
|
||||||
|
folderFullPath,
|
||||||
|
}: TaskRunnerModalProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const taskSettings = settings.tasks;
|
const taskSettings = settings.tasks;
|
||||||
@@ -1448,16 +1620,27 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
const effectiveEntryType = multi ? 'directory' : entryType;
|
const effectiveEntryType = multi ? 'directory' : entryType;
|
||||||
|
|
||||||
// Agentic mode prompt (fallback if task has no body)
|
// Agentic mode prompt (fallback if task has no body)
|
||||||
const defaultInput = promptOverride
|
const defaultInput =
|
||||||
?? (entryRef && entryType
|
promptOverride ??
|
||||||
|
(entryRef && entryType
|
||||||
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
|
? `Execute the task "${task.name}" (${task.dirName}) on the ${entryType}: ${entryRef}`
|
||||||
: `Execute the task "${task.name}" (${task.dirName})`);
|
: `Execute the task "${task.name}" (${task.dirName})`);
|
||||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
const taskInfo: TaskInfo = {
|
||||||
|
taskName: task.name,
|
||||||
|
taskDirName: task.dirName,
|
||||||
|
entryName: entryName ?? '',
|
||||||
|
entryType: entryType ?? 'file',
|
||||||
|
};
|
||||||
|
|
||||||
// Context values for autofill
|
// Context values for autofill
|
||||||
// Build absolute path the agent sees (sandboxed: /data/home/..., non-sandboxed: ~/...)
|
// Path the agent sees, relative to the owner's home.
|
||||||
const homePrefix = sandboxed ? '/data/home' : '~';
|
const homePrefix = '~';
|
||||||
const entryRelPath = entryName && cwd.path ? `${homePrefix}/${cwd.path}/${entryName}` : entryName ? `${homePrefix}/${entryName}` : undefined;
|
const entryRelPath =
|
||||||
|
entryName && cwd.path
|
||||||
|
? `${homePrefix}/${cwd.path}/${entryName}`
|
||||||
|
: entryName
|
||||||
|
? `${homePrefix}/${entryName}`
|
||||||
|
: undefined;
|
||||||
const autofillContext: Record<string, string> = {};
|
const autofillContext: Record<string, string> = {};
|
||||||
if (entryName) autofillContext.entry_name = entryName;
|
if (entryName) autofillContext.entry_name = entryName;
|
||||||
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
||||||
@@ -1498,7 +1681,13 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
key="pipeline"
|
key="pipeline"
|
||||||
taskDirName={task.dirName}
|
taskDirName={task.dirName}
|
||||||
context={autofillContext}
|
context={autofillContext}
|
||||||
cwd={entryType === 'directory' && entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : cwd.path || undefined}
|
cwd={
|
||||||
|
entryType === 'directory' && entryName
|
||||||
|
? cwd.path
|
||||||
|
? `${cwd.path}/${entryName}`
|
||||||
|
: entryName
|
||||||
|
: cwd.path || undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : isScript ? (
|
) : isScript ? (
|
||||||
<ScriptRunner
|
<ScriptRunner
|
||||||
@@ -1508,7 +1697,15 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
context={autofillContext}
|
context={autofillContext}
|
||||||
cwd={cwd.path || undefined}
|
cwd={cwd.path || undefined}
|
||||||
entryType={effectiveEntryType}
|
entryType={effectiveEntryType}
|
||||||
filePath={multi ? cwd.path || undefined : entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
|
filePath={
|
||||||
|
multi
|
||||||
|
? cwd.path || undefined
|
||||||
|
: entryName
|
||||||
|
? cwd.path
|
||||||
|
? `${cwd.path}/${entryName}`
|
||||||
|
: entryName
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
selectedNames={multi ? selectedNames : undefined}
|
selectedNames={multi ? selectedNames : undefined}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
@@ -1517,10 +1714,13 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
|||||||
key="agentic"
|
key="agentic"
|
||||||
taskDirName={task.dirName}
|
taskDirName={task.dirName}
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
cwd={entryType === 'directory' && entryName ? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName } : cwd}
|
cwd={
|
||||||
|
entryType === 'directory' && entryName
|
||||||
|
? { ...cwd, path: cwd.path ? `${cwd.path}/${entryName}` : entryName }
|
||||||
|
: cwd
|
||||||
|
}
|
||||||
initialModel={null}
|
initialModel={null}
|
||||||
taskInfo={taskInfo}
|
taskInfo={taskInfo}
|
||||||
sandboxed={sandboxed}
|
|
||||||
context={autofillContext}
|
context={autofillContext}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ export const useFileBrowserApp = (
|
|||||||
const [cloneUrl, setCloneUrl] = useState('');
|
const [cloneUrl, setCloneUrl] = useState('');
|
||||||
const [cloning, setCloning] = useState(false);
|
const [cloning, setCloning] = useState(false);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry; selectedNames?: string[] } | null>(null);
|
const [runningTask, setRunningTask] = useState<{
|
||||||
|
task: TaskSummary;
|
||||||
|
entry: DirEntry;
|
||||||
|
selectedNames?: string[];
|
||||||
|
} | null>(null);
|
||||||
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
const [audioOnly, setAudioOnly] = useState(false);
|
const [audioOnly, setAudioOnly] = useState(false);
|
||||||
@@ -65,7 +69,7 @@ export const useFileBrowserApp = (
|
|||||||
filesRef.current = files;
|
filesRef.current = files;
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
currentPathRef.current = currentPath;
|
currentPathRef.current = currentPath;
|
||||||
const hiddenForced = user?.role === 'Super Admin' && currentPath === '/';
|
const hiddenForced = currentPath === '/';
|
||||||
const visibleEntries = showHidden && !hiddenForced ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
const visibleEntries = showHidden && !hiddenForced ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||||
|
|
||||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type PreviewContextValue = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
stopped: boolean;
|
stopped: boolean;
|
||||||
isSuperAdmin: boolean;
|
|
||||||
iframeKey: number;
|
iframeKey: number;
|
||||||
projects: ProjectDefinition[];
|
projects: ProjectDefinition[];
|
||||||
startServer: (slug: string) => void;
|
startServer: (slug: string) => void;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Globe, RefreshCw, Square, Play } from 'lucide-react';
|
|||||||
import { usePreview } from './PreviewContext';
|
import { usePreview } from './PreviewContext';
|
||||||
|
|
||||||
export const PreviewHeader = () => {
|
export const PreviewHeader = () => {
|
||||||
const { slug, url, port, stopped, isSuperAdmin, stopServer, restartServer } = usePreview();
|
const { slug, url, port, stopped, stopServer, restartServer } = usePreview();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -19,9 +19,7 @@ export const PreviewHeader = () => {
|
|||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{slug}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{slug}</span>
|
||||||
{isSuperAdmin && port && (
|
{port && <span className="text-[10px] font-mono opacity-40 shrink-0">:{port}</span>}
|
||||||
<span className="text-[10px] font-mono opacity-40 shrink-0">:{port}</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={stopServer}
|
onClick={stopServer}
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
|||||||
const [iframeKey, setIframeKey] = useState(0);
|
const [iframeKey, setIframeKey] = useState(0);
|
||||||
const [stopped, setStopped] = useState(false);
|
const [stopped, setStopped] = useState(false);
|
||||||
|
|
||||||
const isSuperAdmin = user?.role === 'Super Admin';
|
|
||||||
const cwdSlug = extractSlug(cwd);
|
const cwdSlug = extractSlug(cwd);
|
||||||
const slug = cwdSlug ?? selectedSlug;
|
const slug = cwdSlug ?? selectedSlug;
|
||||||
|
|
||||||
const startServer = useCallback(async (targetSlug: string) => {
|
const startServer = useCallback(
|
||||||
|
async (targetSlug: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStopped(false);
|
setStopped(false);
|
||||||
@@ -40,12 +40,15 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
|||||||
setUrl(token ? `${res.url}?token=${encodeURIComponent(token)}` : res.url);
|
setUrl(token ? `${res.url}?token=${encodeURIComponent(token)}` : res.url);
|
||||||
setPort(res.port);
|
setPort(res.port);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err && typeof err === 'object' && 'message' in err ? String(err.message) : 'Failed to start dev server';
|
const msg =
|
||||||
|
err && typeof err === 'object' && 'message' in err ? String(err.message) : 'Failed to start dev server';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [client]);
|
},
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
const stopServer = useCallback(async () => {
|
const stopServer = useCallback(async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
@@ -100,7 +103,9 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
|||||||
};
|
};
|
||||||
check();
|
check();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
// Poll status while a server is supposedly running — auto-restart if it died (e.g. idle timeout)
|
// Poll status while a server is supposedly running — auto-restart if it died (e.g. idle timeout)
|
||||||
@@ -130,8 +135,21 @@ export const PreviewProvider = ({ panelId, children }: PreviewProviderProps) =>
|
|||||||
return (
|
return (
|
||||||
<PreviewContext
|
<PreviewContext
|
||||||
value={{
|
value={{
|
||||||
slug, cwdSlug, url, port, loading, error, stopped, isSuperAdmin, iframeKey, projects,
|
slug,
|
||||||
startServer, stopServer, restartServer, refresh, setSelectedSlug, clearError,
|
cwdSlug,
|
||||||
|
url,
|
||||||
|
port,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
stopped,
|
||||||
|
iframeKey,
|
||||||
|
projects,
|
||||||
|
startServer,
|
||||||
|
stopServer,
|
||||||
|
restartServer,
|
||||||
|
refresh,
|
||||||
|
setSelectedSlug,
|
||||||
|
clearError,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
|
||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import type { TerminalConnectionState } from './Terminal';
|
import type { TerminalConnectionState } from './Terminal';
|
||||||
@@ -9,10 +8,12 @@ import { TerminalView } from './Terminal';
|
|||||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||||
|
|
||||||
export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||||
const { user } = useAuth();
|
|
||||||
const { dashboardId, cwd } = useWorkspace();
|
const { dashboardId, cwd } = useWorkspace();
|
||||||
const stateKey = dashboardId ? `ws-host-terminals-${dashboardId}` : 'ws-host-terminals-default';
|
const stateKey = dashboardId ? `ws-host-terminals-${dashboardId}` : 'ws-host-terminals-default';
|
||||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||||
|
stateKey,
|
||||||
|
EMPTY_TERMINALS,
|
||||||
|
);
|
||||||
const setTerminalsRef = useRef(setTerminals);
|
const setTerminalsRef = useRef(setTerminals);
|
||||||
setTerminalsRef.current = setTerminals;
|
setTerminalsRef.current = setTerminals;
|
||||||
|
|
||||||
@@ -33,14 +34,6 @@ export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
};
|
};
|
||||||
}, [panelId]);
|
}, [panelId]);
|
||||||
|
|
||||||
if (user?.role !== 'Super Admin') {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Host Terminal requires Super Admin permissions.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
const [, setConnState] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||||
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
const onConnectionChange = useCallback((state: TerminalConnectionState) => setConnState(state), [setConnState]);
|
||||||
|
|
||||||
@@ -50,7 +43,6 @@ export const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
<TerminalView
|
<TerminalView
|
||||||
className="h-full w-full p-2"
|
className="h-full w-full p-2"
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
sandboxed={false}
|
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
onConnectionChange={onConnectionChange}
|
onConnectionChange={onConnectionChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type TerminalViewProps = {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
wsPath?: string;
|
wsPath?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sandboxed?: boolean;
|
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
initialInput?: string;
|
initialInput?: string;
|
||||||
@@ -42,13 +41,19 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
|
|||||||
selectionBackground: '#3a3a5e',
|
selectionBackground: '#3a3a5e',
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string, cols?: number, rows?: number) => {
|
const buildWsUrl = (
|
||||||
|
wsPath: string,
|
||||||
|
sessionId?: string,
|
||||||
|
cwd?: string,
|
||||||
|
command?: string,
|
||||||
|
cols?: number,
|
||||||
|
rows?: number,
|
||||||
|
) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||||
const separator = wsPath.includes('?') ? '&' : '?';
|
const separator = wsPath.includes('?') ? '&' : '?';
|
||||||
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||||
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||||
if (sandboxed === false) url += '&sandboxed=false';
|
|
||||||
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
||||||
if (command) url += `&command=${encodeURIComponent(command)}`;
|
if (command) url += `&command=${encodeURIComponent(command)}`;
|
||||||
if (cols) url += `&cols=${cols}`;
|
if (cols) url += `&cols=${cols}`;
|
||||||
@@ -61,7 +66,6 @@ export const TerminalView = ({
|
|||||||
style,
|
style,
|
||||||
wsPath = '/api/terminal/ws',
|
wsPath = '/api/terminal/ws',
|
||||||
sessionId,
|
sessionId,
|
||||||
sandboxed = true,
|
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
initialInput,
|
initialInput,
|
||||||
@@ -155,7 +159,7 @@ export const TerminalView = ({
|
|||||||
const cols = term.cols;
|
const cols = term.cols;
|
||||||
const rows = term.rows;
|
const rows = term.rows;
|
||||||
|
|
||||||
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command, cols, rows));
|
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, cwd, command, cols, rows));
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
const cleanupWs = () => {
|
const cleanupWs = () => {
|
||||||
@@ -201,9 +205,15 @@ export const TerminalView = ({
|
|||||||
if (markerMatch) {
|
if (markerMatch) {
|
||||||
const exitCode = Number(markerMatch[1]);
|
const exitCode = Number(markerMatch[1]);
|
||||||
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
|
const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
|
||||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
const lines = raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
|
const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
|
||||||
const output = lines.slice(cmdLine >= 0 ? cmdLine + 1 : 0).join('\n').trim();
|
const output = lines
|
||||||
|
.slice(cmdLine >= 0 ? cmdLine + 1 : 0)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
commandDone = true;
|
commandDone = true;
|
||||||
onCommandDoneRef.current(exitCode, output);
|
onCommandDoneRef.current(exitCode, output);
|
||||||
}
|
}
|
||||||
@@ -300,7 +310,6 @@ export const TerminalView = ({
|
|||||||
isMounted,
|
isMounted,
|
||||||
wsPath,
|
wsPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
sandboxed,
|
|
||||||
cwd,
|
cwd,
|
||||||
command,
|
command,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
|||||||
@@ -4,22 +4,17 @@ import { useDashboardState } from 'state/useDashboardState';
|
|||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import type { TerminalConnectionState } from './Terminal';
|
import type { TerminalConnectionState } from './Terminal';
|
||||||
import { TerminalView } from './Terminal';
|
import { TerminalView } from './Terminal';
|
||||||
import { useTerminalMode } from './useTerminalMode';
|
|
||||||
|
|
||||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||||
|
|
||||||
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||||
const { dashboardId, cwd, root } = useWorkspace();
|
const { dashboardId, cwd } = useWorkspace();
|
||||||
const { mode } = useTerminalMode(panelId);
|
|
||||||
const hostRoot = root === '~' || root === 'officer.dev';
|
|
||||||
const sandboxed = !hostRoot && (cwd !== '~' || mode === 'sandboxed');
|
|
||||||
const stateKey = (() => {
|
const stateKey = (() => {
|
||||||
const hostSuffix = mode === 'host' ? 'host-' : '';
|
|
||||||
const wsMatch = dashboardId?.match(/^ws-layout-(.+)$/);
|
const wsMatch = dashboardId?.match(/^ws-layout-(.+)$/);
|
||||||
if (wsMatch) return `ws-${hostSuffix}terminals-${wsMatch[1]}`;
|
if (wsMatch) return `ws-terminals-${wsMatch[1]}`;
|
||||||
const projMatch = dashboardId?.match(/^proj-layout-(.+)$/);
|
const projMatch = dashboardId?.match(/^proj-layout-(.+)$/);
|
||||||
if (projMatch) return `proj-${hostSuffix}terminals-${projMatch[1]}`;
|
if (projMatch) return `proj-terminals-${projMatch[1]}`;
|
||||||
return `ws-${hostSuffix}terminals-default`;
|
return 'ws-terminals-default';
|
||||||
})();
|
})();
|
||||||
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
const { value: terminals, setValue: setTerminals } = useDashboardState<Record<string, string>>(
|
||||||
stateKey,
|
stateKey,
|
||||||
@@ -55,7 +50,6 @@ export const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
<TerminalView
|
<TerminalView
|
||||||
className="h-full w-full p-2"
|
className="h-full w-full p-2"
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
sandboxed={sandboxed}
|
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
onConnectionChange={onConnectionChange}
|
onConnectionChange={onConnectionChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useGlobal } from 'hooks/useGlobal';
|
|
||||||
|
|
||||||
type TerminalMode = 'sandboxed' | 'host';
|
|
||||||
|
|
||||||
export const useTerminalMode = (panelId: string) => {
|
|
||||||
const [mode, setMode] = useGlobal<TerminalMode>(`terminal-mode-${panelId}`, 'sandboxed');
|
|
||||||
|
|
||||||
const toggle = () => setMode((prev) => (prev === 'sandboxed' ? 'host' : 'sandboxed'));
|
|
||||||
|
|
||||||
return { mode, setMode, toggle };
|
|
||||||
};
|
|
||||||
@@ -220,7 +220,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
images?: { filename: string; dataUrl: string }[],
|
images?: { filename: string; dataUrl: string }[],
|
||||||
cwdParam?: { root?: string; path: string },
|
cwdParam?: { root?: string; path: string },
|
||||||
groupSlug?: string | null,
|
groupSlug?: string | null,
|
||||||
sandboxed?: boolean,
|
|
||||||
thinking?: string | null,
|
thinking?: string | null,
|
||||||
displayText?: string,
|
displayText?: string,
|
||||||
) {
|
) {
|
||||||
@@ -257,7 +256,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
|
|||||||
...(selectedModel ? { model: selectedModel } : {}),
|
...(selectedModel ? { model: selectedModel } : {}),
|
||||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||||
...(cwdParam?.root ? { cwdRoot: cwdParam.root } : {}),
|
...(cwdParam?.root ? { cwdRoot: cwdParam.root } : {}),
|
||||||
...(sandboxed !== undefined ? { sandboxed } : {}),
|
|
||||||
...(groupSlug !== undefined ? { groupSlug } : {}),
|
...(groupSlug !== undefined ? { groupSlug } : {}),
|
||||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||||
...(imageData?.length ? { images: imageData } : {}),
|
...(imageData?.length ? { images: imageData } : {}),
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ const QUERY_KEY = ['DOCK'];
|
|||||||
|
|
||||||
type DockItemLike = {
|
type DockItemLike = {
|
||||||
to: string;
|
to: string;
|
||||||
role?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDock<T extends DockItemLike>(allDockItems: T[], defaultPaths?: string[]) {
|
export function useDock<T extends DockItemLike>(allDockItems: T[], defaultPaths?: string[]) {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: dockPaths = null } = useQuery<string[] | null>({
|
const { data: dockPaths = null } = useQuery<string[] | null>({
|
||||||
@@ -27,15 +26,10 @@ export function useDock<T extends DockItemLike>(allDockItems: T[], defaultPaths?
|
|||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const byPath = new Map(allDockItems.map((item) => [item.to, item]));
|
const byPath = new Map(allDockItems.map((item) => [item.to, item]));
|
||||||
return activePaths
|
return activePaths.map((path) => byPath.get(path)).filter((item): item is T => !!item);
|
||||||
.map((path) => byPath.get(path))
|
}, [activePaths, allDockItems]);
|
||||||
.filter((item): item is T => !!item && (!item.role || item.role === user?.role));
|
|
||||||
}, [activePaths, allDockItems, user?.role]);
|
|
||||||
|
|
||||||
const allItems = useMemo(
|
const allItems = allDockItems;
|
||||||
() => allDockItems.filter((item) => !item.role || item.role === user?.role),
|
|
||||||
[allDockItems, user?.role],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setItems = useCallback(
|
const setItems = useCallback(
|
||||||
(paths: string[]) => {
|
(paths: string[]) => {
|
||||||
|
|||||||
@@ -72,17 +72,12 @@ export function useVisibleModels() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Models visible to the current user: system policy (members) or all (admins), minus per-user hidden. */
|
/** Models visible to the owner: every model the server knows about, minus the ones they hid. */
|
||||||
export function useUserVisibleModels() {
|
export function useUserVisibleModels() {
|
||||||
const allModels = useModels();
|
const base = useModels();
|
||||||
const policyModels = useVisibleModels();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const isAdmin = user?.role !== 'Member';
|
|
||||||
const base = isAdmin ? allModels : policyModels;
|
|
||||||
const hidden = settings.chat.hiddenModels;
|
const hidden = settings.chat.hiddenModels;
|
||||||
|
|
||||||
if (!hidden || hidden.length === 0) return base;
|
if (!hidden || hidden.length === 0) return base;
|
||||||
|
|
||||||
const hiddenSet = new Set(hidden);
|
const hiddenSet = new Set(hidden);
|
||||||
|
|||||||
Reference in New Issue
Block a user