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
@@ -3,4 +3,3 @@ export * from './user-middleware';
|
||||
export * from './origin-middleware';
|
||||
export * from './origin-validation';
|
||||
export * from './rate-limiter';
|
||||
export * from './super-admin-middleware';
|
||||
|
||||
@@ -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 { 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) {
|
||||
// Duress lockdown: reject every authenticated request, cutting off all existing sessions.
|
||||
if (isLockdown()) {
|
||||
@@ -68,9 +52,6 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMethodAllowed(user.role, ctx.req.method)) {
|
||||
throw errors.FORBIDDEN('Insufficient permissions');
|
||||
}
|
||||
ctx.set('user', user);
|
||||
return next();
|
||||
} catch (ex) {
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
} from '../../_middlewares';
|
||||
import { signinHandler } from './signin';
|
||||
import { signoutHandler } from './signout';
|
||||
import { signupHandler } from './signup';
|
||||
import { verifyHandler } from './verify';
|
||||
import { verifyTokenHandler } from './verify-token';
|
||||
import { resendVerificationHandler } from './resend-verification';
|
||||
import { changePasswordHandler } from './change-password';
|
||||
import { forgotPasswordHandler } from './forgot-password';
|
||||
import { resetPasswordHandler } from './reset-password';
|
||||
@@ -39,11 +36,10 @@ authRouter.post('/signout', userMiddleware, signoutHandler);
|
||||
authRouter.post('/revoke', userMiddleware, revokeHandler);
|
||||
// Trigger the panic lockdown — authenticated, no password in the body.
|
||||
authRouter.post('/panic', userMiddleware, panicHandler);
|
||||
authRouter.post('/signup', signupRateLimiter, signupHandler);
|
||||
// Creates the single server-owner account. Only succeeds while the user table is empty.
|
||||
authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler);
|
||||
authRouter.post('/verify', verifyHandler);
|
||||
// Validates a password-reset link before the reset form is shown.
|
||||
authRouter.post('/verify-token', verifyTokenHandler);
|
||||
authRouter.post('/resend-verification', resendVerificationHandler);
|
||||
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
|
||||
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
|
||||
authRouter.post('/reset-password', resetPasswordHandler);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { validatePassword } from './validate-password';
|
||||
import { validateUsername } from './validate-username';
|
||||
import { provisionUserEnvironment } from '../users/provision';
|
||||
|
||||
// Single-step super-admin bootstrap: the first user is created directly as an active Super Admin, with
|
||||
// no email-verification round-trip. Gated to an empty user table (registration is otherwise closed).
|
||||
// Single-step bootstrap for the one account Officer supports: the server owner is created directly as
|
||||
// active, with no email-verification round-trip. Gated to an empty user table.
|
||||
export const bootstrapHandler: Handler = async function (ctx) {
|
||||
const body = ctx.get('body');
|
||||
|
||||
@@ -35,7 +35,6 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
password: passwordHash,
|
||||
name: name.trim(),
|
||||
username: validUsername,
|
||||
role: 'Super Admin',
|
||||
status: 'Active',
|
||||
});
|
||||
|
||||
|
||||
@@ -168,13 +168,12 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
|
||||
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
|
||||
|
||||
const { id, name, username, role } = dbUser;
|
||||
const { id, name, username } = dbUser;
|
||||
const token = await sign({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
role,
|
||||
passkeys: passkeys.length,
|
||||
});
|
||||
|
||||
@@ -185,7 +184,6 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
role,
|
||||
passkeys: passkeys.length,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
if (!isValidPassword) throw errors.UNAUTHORIZED();
|
||||
|
||||
const { id, name, username, role } = dbUser;
|
||||
const { id, name, username } = dbUser;
|
||||
|
||||
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
|
||||
const tokenUser = { id, email, name, username, passkeys: passkeys.length };
|
||||
|
||||
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
|
||||
return ctx.json({ user: tokenUser });
|
||||
|
||||
@@ -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 * as errors from '@@/custom-errors';
|
||||
|
||||
// Validates a password-reset link before the reset form is rendered. Officer is single-user, so the
|
||||
// account-verification and invitation flows this used to serve no longer exist — the sole account is
|
||||
// created directly by /auth/bootstrap.
|
||||
export const verifyTokenHandler: Handler = async function (ctx) {
|
||||
const { verificationCode } = ctx.get('body');
|
||||
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
|
||||
|
||||
let userInfo: User;
|
||||
let userInfo: User & { purpose?: string };
|
||||
try {
|
||||
userInfo = (await verify(verificationCode)) as User;
|
||||
userInfo = (await verify(verificationCode)) as User & { purpose?: string };
|
||||
} catch {
|
||||
throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
}
|
||||
|
||||
// Bootstrap token: has email but no id (user not yet created)
|
||||
if (userInfo?.email && !userInfo?.id) {
|
||||
return ctx.json({ ok: true, email: userInfo.email, flow: 'bootstrap' });
|
||||
}
|
||||
|
||||
if (userInfo?.purpose !== 'reset-password') throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
|
||||
|
||||
const user = await getUserById(userInfo.id);
|
||||
if (!user) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Reset-password tokens skip the verification status check
|
||||
const isResetToken = (userInfo as Record<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' });
|
||||
return ctx.json({ ok: true, email: user.email });
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
||||
// 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
|
||||
// own copy; Claude's files are authoritative.
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ export type ClientMessage =
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
groupSlug?: string;
|
||||
attachmentIds?: string[];
|
||||
thinking?: ThinkingLevel;
|
||||
@@ -148,7 +147,6 @@ export type UserSession = {
|
||||
userId?: number;
|
||||
cwd: string;
|
||||
model: string;
|
||||
sandboxed?: boolean;
|
||||
piProcess: any | null;
|
||||
ws: any | null;
|
||||
lastActivity: number;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import { getHomeDirForRole, getEmailAccountsDir } from '../../../servers/data-path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
||||
import { getUserSettings, getEmailAccounts } from 'officerdb';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
@@ -34,28 +34,21 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const resolveCwd = (email: string, role: string, cwd?: string) => {
|
||||
const root = getHomeDirForRole(email, role);
|
||||
const resolveCwd = (email: string, cwd?: string) => {
|
||||
const root = getOwnerHomeDir(email);
|
||||
if (!cwd || cwd === '~') return root;
|
||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) {
|
||||
// Super Admin: trust absolute paths as-is
|
||||
if (role === 'Super Admin') return cwd;
|
||||
return join(root, cwd.slice(1));
|
||||
}
|
||||
// The server owner is the only account — absolute paths are theirs to use.
|
||||
if (cwd.startsWith('/')) return cwd;
|
||||
return join(root, cwd);
|
||||
};
|
||||
|
||||
export const resolveBaseCwd = (email: string, role: string, cwd?: string) => {
|
||||
return resolveCwd(email, role, cwd);
|
||||
};
|
||||
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd);
|
||||
|
||||
// The email chat runs from the selected account's storage dir:
|
||||
// DATA_PATH/<owner>/email_accounts/<accountEmail>
|
||||
@@ -81,13 +74,11 @@ async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?
|
||||
async function resolveChatCwd(
|
||||
msg: { context?: string; contextId?: string; cwd?: string },
|
||||
email: string,
|
||||
role: string,
|
||||
userId: number,
|
||||
): Promise<string> {
|
||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||
if (msg.context === 'chat')
|
||||
return msg.cwd?.trim() ? resolveCwd(email, role, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||
return resolveCwd(email, role, msg.cwd);
|
||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||
return resolveCwd(email, msg.cwd);
|
||||
}
|
||||
|
||||
const wsToSessionMap = new WeakMap<any, string>();
|
||||
@@ -295,7 +286,6 @@ async function handleChat(
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
groupSlug?: string;
|
||||
attachmentIds?: string[];
|
||||
thinking?: string;
|
||||
@@ -336,14 +326,13 @@ async function handleClaudeCodeChat(
|
||||
contextId?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
resumeSessionId?: string;
|
||||
},
|
||||
effectivePrompt: string,
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
|
||||
const cwd = await resolveChatCwd(msg, email, ws.data.role, userId);
|
||||
const cwd = await resolveChatCwd(msg, email, userId);
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
@@ -389,7 +378,6 @@ async function handleClaudeCodeChat(
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
role: ws.data.role,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onEvent,
|
||||
});
|
||||
@@ -416,14 +404,13 @@ async function handleOpenCodeChat(
|
||||
contextId?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
resumeSessionId?: string;
|
||||
},
|
||||
effectivePrompt: string,
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
|
||||
const cwd = await resolveChatCwd(msg, email, ws.data.role, userId);
|
||||
const cwd = await resolveChatCwd(msg, email, userId);
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
@@ -468,7 +455,6 @@ async function handleOpenCodeChat(
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
role: ws.data.role,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { spawn, type Subprocess } from 'bun';
|
||||
import { resolve, normalize, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDirForRole } from '@@/data-path';
|
||||
import { getOwnerHomeDir } from '@@/data-path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ASOUNDRC_PATH = join(__dirname, 'asoundrc');
|
||||
@@ -11,7 +11,6 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
files: string;
|
||||
};
|
||||
|
||||
@@ -59,7 +58,9 @@ const findCliamp = (): string | null => {
|
||||
try {
|
||||
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
|
||||
if (stat.exitCode === 0) return bin;
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -68,7 +69,7 @@ const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
export const cliampWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
const { email, role, files: filesParam } = ws.data;
|
||||
const { email, files: filesParam } = ws.data;
|
||||
|
||||
if (!filesParam) {
|
||||
sendOutput(ws, '\r\n[Error] No files specified.\r\n');
|
||||
@@ -81,7 +82,7 @@ export const cliampWebsocket = {
|
||||
return;
|
||||
}
|
||||
|
||||
const homeDir = getHomeDirForRole(email, role);
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const rawFiles = [filesParam];
|
||||
|
||||
// Resolve paths relative to user home dir
|
||||
@@ -187,7 +188,11 @@ export const cliampWebsocket = {
|
||||
const session = sessions.get(ws);
|
||||
if (session) {
|
||||
session.closed = true;
|
||||
try { session.proc.kill(); } catch { /* ignore */ }
|
||||
try {
|
||||
session.proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
sessions.delete(ws);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ export const desktopRouter = createRouter();
|
||||
|
||||
desktopRouter.get('/vnc-password', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const password = await getVncPassword(user.email, user.role);
|
||||
const password = await getVncPassword(user.email);
|
||||
if (!password) {
|
||||
return ctx.json({ error: 'VNC password not configured' }, 500);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { join } from 'node:path';
|
||||
import { getHomeDirForRole } from '@@/data-path';
|
||||
import { getOwnerHomeDir } from '@@/data-path';
|
||||
|
||||
function getVncDir(email: string, role: string | null): string {
|
||||
return join(getHomeDirForRole(email, role), '.vnc');
|
||||
}
|
||||
const getVncDir = (email: string): string => join(getOwnerHomeDir(email), '.vnc');
|
||||
|
||||
export async function getVncPassword(email: string, role: string | null): Promise<string | null> {
|
||||
const file = Bun.file(join(getVncDir(email, role), 'password'));
|
||||
export async function getVncPassword(email: string): Promise<string | null> {
|
||||
const file = Bun.file(join(getVncDir(email), 'password'));
|
||||
if (!(await file.exists())) return null;
|
||||
return (await file.text()).trim();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun';
|
||||
import type { Socket } from 'bun';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string };
|
||||
type WSData = { userId: number; email: string; username: string; sessionId?: string };
|
||||
|
||||
type VncSession = {
|
||||
tcpSocket: Socket<{ ws: ServerWebSocket<WSData> }> | null;
|
||||
@@ -21,7 +21,6 @@ export const desktopWebsocket = {
|
||||
const result = await sidecar.startVnc({
|
||||
email: ws.data.email,
|
||||
username: ws.data.username,
|
||||
role: ws.data.role,
|
||||
});
|
||||
port = result.port;
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createRouter } from '@@/create-router';
|
||||
import { resolve, dirname, join, parse as parsePath } from 'node:path';
|
||||
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { getHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
@@ -43,16 +43,12 @@ async function syncSeedDir(seedDir: string, targetDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) {
|
||||
async function seedHomeDir(homeDir: string) {
|
||||
for (const dir of DEFAULT_HOME_DIRS) {
|
||||
const target = join(homeDir, dir);
|
||||
if (dir === 'Onboarding') {
|
||||
if (isSuperAdmin) {
|
||||
await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding'));
|
||||
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding'));
|
||||
} else {
|
||||
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding'));
|
||||
}
|
||||
await syncSeedDir(ONBOARDING_ADMIN_SEED, join(homeDir, 'Onboarding'));
|
||||
await syncSeedDir(ONBOARDING_SEED, join(homeDir, 'Onboarding', 'User_Onboarding'));
|
||||
} else if (!existsSync(target)) {
|
||||
await mkdir(target, { recursive: true });
|
||||
}
|
||||
@@ -61,17 +57,14 @@ async function seedHomeDir(homeDir: string, isSuperAdmin: boolean) {
|
||||
|
||||
export const router = createRouter();
|
||||
|
||||
type UserCtx = { email: string; role: string | null };
|
||||
type UserCtx = { email: string };
|
||||
|
||||
function getUserDataDir(email: string): string {
|
||||
return join(DATA_PATH, email);
|
||||
}
|
||||
|
||||
function getRootDir(user: UserCtx, root?: string): string {
|
||||
if (!root || root === 'home') {
|
||||
if (user.role === 'Super Admin' && process.env.HOME_DIR) return process.env.HOME_DIR;
|
||||
return getHomeDir(user.email);
|
||||
}
|
||||
if (!root || root === 'home') return getOwnerHomeDir(user.email);
|
||||
if (root === 'user-data') return getUserDataDir(user.email);
|
||||
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
||||
}
|
||||
@@ -117,7 +110,24 @@ async function ensureAudioRemux(email: string, absPath: string, relPath: string,
|
||||
const tmp = `${base}.tmp.${ext}`;
|
||||
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
|
||||
const proc = Bun.spawn(
|
||||
['ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp],
|
||||
[
|
||||
'ffmpeg',
|
||||
'-v',
|
||||
'error',
|
||||
'-i',
|
||||
absPath,
|
||||
'-map',
|
||||
'0:v',
|
||||
'-map',
|
||||
`0:a:${track}`,
|
||||
'-c',
|
||||
'copy',
|
||||
'-dn',
|
||||
'-sn',
|
||||
...movflags,
|
||||
'-y',
|
||||
tmp,
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
);
|
||||
const code = await proc.exited;
|
||||
@@ -147,7 +157,7 @@ router.get('/ls', async (ctx) => {
|
||||
|
||||
// Auto-create dir if missing (only for user home root)
|
||||
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
|
||||
await seedHomeDir(rootDir, user.role === 'Super Admin');
|
||||
await seedHomeDir(rootDir);
|
||||
await mkdir(absPath, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -326,7 +336,17 @@ router.get('/raw', async (ctx) => {
|
||||
});
|
||||
|
||||
// List a video's text-based subtitle tracks (for the in-browser player's selector)
|
||||
const TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'microdvd']);
|
||||
const TEXT_SUBTITLE_CODECS = new Set([
|
||||
'subrip',
|
||||
'srt',
|
||||
'ass',
|
||||
'ssa',
|
||||
'mov_text',
|
||||
'webvtt',
|
||||
'text',
|
||||
'subviewer',
|
||||
'microdvd',
|
||||
]);
|
||||
|
||||
router.get('/subtitles', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -336,7 +356,18 @@ router.get('/subtitles', async (ctx) => {
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=codec_name:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
[
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
's',
|
||||
'-show_entries',
|
||||
'stream=codec_name:stream_tags=language,title,handler_name',
|
||||
'-of',
|
||||
'json',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
@@ -396,13 +427,29 @@ router.get('/audio-tracks', async (ctx) => {
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-select_streams', 'a', '-show_entries', 'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
[
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'error',
|
||||
'-select_streams',
|
||||
'a',
|
||||
'-show_entries',
|
||||
'stream=channels,codec_name,bit_rate:stream_tags=language,title,handler_name',
|
||||
'-of',
|
||||
'json',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
type ProbeAudio = { channels?: number; codec_name?: string; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
type ProbeAudio = {
|
||||
channels?: number;
|
||||
codec_name?: string;
|
||||
bit_rate?: string;
|
||||
tags?: { language?: string; title?: string; handler_name?: string };
|
||||
};
|
||||
let streams: ProbeAudio[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
|
||||
@@ -427,23 +474,64 @@ router.get('/audio-tracks', async (ctx) => {
|
||||
// odd files out when they don't. Two files "match" when their audio (language + channel count) and
|
||||
// subtitle (language) streams line up in order; per-episode titles are ignored (they always differ).
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg',
|
||||
'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb',
|
||||
'mp4',
|
||||
'mkv',
|
||||
'webm',
|
||||
'mov',
|
||||
'avi',
|
||||
'wmv',
|
||||
'flv',
|
||||
'm4v',
|
||||
'mpg',
|
||||
'mpeg',
|
||||
'ts',
|
||||
'm2ts',
|
||||
'mts',
|
||||
'3gp',
|
||||
'ogv',
|
||||
'vob',
|
||||
'divx',
|
||||
'asf',
|
||||
'f4v',
|
||||
'rm',
|
||||
'rmvb',
|
||||
]);
|
||||
|
||||
type FolderAudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string };
|
||||
type FolderAudioTrack = {
|
||||
id: number;
|
||||
codec: string;
|
||||
channels: number;
|
||||
bitrate: number | null;
|
||||
lang: string;
|
||||
title: string;
|
||||
};
|
||||
type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string };
|
||||
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };
|
||||
|
||||
async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name', '-of', 'json', absPath],
|
||||
[
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'stream=codec_type,codec_name,channels,bit_rate:stream_tags=language,title,handler_name',
|
||||
'-of',
|
||||
'json',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; bit_rate?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
type ProbeStream = {
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
channels?: number;
|
||||
bit_rate?: string;
|
||||
tags?: { language?: string; title?: string; handler_name?: string };
|
||||
};
|
||||
let streams: ProbeStream[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
|
||||
@@ -453,7 +541,14 @@ async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
|
||||
|
||||
const audio = streams
|
||||
.filter((s) => s.codec_type === 'audio')
|
||||
.map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, bitrate: kbps(s.bit_rate), lang: s.tags?.language ?? '', title: trackName(s.tags) }));
|
||||
.map((s, id) => ({
|
||||
id,
|
||||
codec: s.codec_name ?? '',
|
||||
channels: s.channels ?? 0,
|
||||
bitrate: kbps(s.bit_rate),
|
||||
lang: s.tags?.language ?? '',
|
||||
title: trackName(s.tags),
|
||||
}));
|
||||
// subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned
|
||||
// before filtering out image-based tracks that can't become soft subs.
|
||||
const subtitle = streams
|
||||
@@ -490,12 +585,20 @@ router.get('/probe-folder', async (ctx) => {
|
||||
for (let i = 0; i < files.length; i += CONCURRENCY) {
|
||||
const batch = files.slice(i, i + CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
batch.map(async (file) => ({ file, tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))) })),
|
||||
batch.map(async (file) => ({
|
||||
file,
|
||||
tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))),
|
||||
})),
|
||||
);
|
||||
probed.push(...results);
|
||||
}
|
||||
|
||||
type Group = { signature: string; files: string[]; audioTracks: FolderAudioTrack[]; subtitleTracks: FolderSubtitleTrack[] };
|
||||
type Group = {
|
||||
signature: string;
|
||||
files: string[];
|
||||
audioTracks: FolderAudioTrack[];
|
||||
subtitleTracks: FolderSubtitleTrack[];
|
||||
};
|
||||
const groupsMap = new Map<string, Group>();
|
||||
for (const { file, tracks } of probed) {
|
||||
const sig = layoutSignature(tracks);
|
||||
@@ -1161,7 +1264,9 @@ async function runReclipDownload(jobId: string, url: string, absPath: string, au
|
||||
for (;;) {
|
||||
if (Date.now() > deadline) throw new Error('Download timed out');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, { signal: AbortSignal.timeout(15_000) }).catch(() => null);
|
||||
const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, {
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
}).catch(() => null);
|
||||
if (!stRes?.ok) continue;
|
||||
const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null };
|
||||
if (st.status === 'error') throw new Error(st.error || 'ReClip download failed');
|
||||
|
||||
@@ -34,7 +34,7 @@ integrationsRouter.get('/', async (ctx) => {
|
||||
return ctx.json([]);
|
||||
});
|
||||
|
||||
// --- Enterprise: Apify config (Super Admin only) ---
|
||||
// --- Apify config ---
|
||||
|
||||
type ApifyConfig = { apiToken: string };
|
||||
|
||||
@@ -47,15 +47,10 @@ export const readApifyConfig = async (): Promise<ApifyConfig | null> => {
|
||||
};
|
||||
|
||||
integrationsRouter.get('/apify/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
||||
return ctx.json(await readApifyConfig());
|
||||
});
|
||||
|
||||
integrationsRouter.put('/apify/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
||||
|
||||
const body = ctx.get('body') as { apiToken?: string };
|
||||
const config = { apiToken: body.apiToken ?? '' };
|
||||
|
||||
@@ -68,18 +63,13 @@ integrationsRouter.get('/apify/status', async (ctx) => {
|
||||
return ctx.json({ configured: !!config?.apiToken });
|
||||
});
|
||||
|
||||
// --- Enterprise: Google OAuth config (Super Admin only) ---
|
||||
// --- Google OAuth config ---
|
||||
|
||||
integrationsRouter.get('/google/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
||||
return ctx.json(await readGoogleConfig());
|
||||
});
|
||||
|
||||
integrationsRouter.put('/google/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
||||
|
||||
const body = ctx.get('body') as { clientId?: string; clientSecret?: string };
|
||||
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
|
||||
|
||||
@@ -88,9 +78,6 @@ integrationsRouter.put('/google/config', async (ctx) => {
|
||||
});
|
||||
|
||||
integrationsRouter.get('/google/verify', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw FORBIDDEN();
|
||||
|
||||
const config = await readGoogleConfig();
|
||||
if (!config?.clientId || !config?.clientSecret) {
|
||||
return ctx.json({ valid: false, error: 'Missing credentials' });
|
||||
@@ -197,7 +184,11 @@ integrationsRouter.post('/google/gmail-proxy', async (ctx) => {
|
||||
const upstream = await fetch(url, init);
|
||||
const text = await upstream.text();
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
|
||||
return ctx.json({ status: upstream.status, ok: upstream.ok, body: parsed });
|
||||
});
|
||||
|
||||
@@ -2,8 +2,7 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, createWriteStream } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getHomeDirForRole, DATA_PATH } from '../../data-path';
|
||||
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '../../data-path';
|
||||
import { killTree } from './process-tree';
|
||||
|
||||
// Script-job event stream. `stdout`/`stderr` are high-frequency (live-only + persisted to the log
|
||||
@@ -18,8 +17,6 @@ export type ScriptEvent =
|
||||
export type ExecuteScriptParams = {
|
||||
jobId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
@@ -28,9 +25,21 @@ export type ExecuteScriptParams = {
|
||||
};
|
||||
|
||||
const getRunner = (language: string): string[] =>
|
||||
language === 'python' ? ['python3'] : language === 'typescript' ? ['bun', 'run'] : language === 'javascript' ? ['node'] : ['bash'];
|
||||
language === 'python'
|
||||
? ['python3']
|
||||
: language === 'typescript'
|
||||
? ['bun', 'run']
|
||||
: language === 'javascript'
|
||||
? ['node']
|
||||
: ['bash'];
|
||||
const getFileName = (language: string): string =>
|
||||
language === 'python' ? 'run.py' : language === 'typescript' ? 'index.ts' : language === 'javascript' ? 'index.js' : 'run.sh';
|
||||
language === 'python'
|
||||
? 'run.py'
|
||||
: language === 'typescript'
|
||||
? 'index.ts'
|
||||
: language === 'javascript'
|
||||
? 'index.js'
|
||||
: 'run.sh';
|
||||
|
||||
function materializeScript(language: string, implementation: string): string {
|
||||
const dir = join(tmpdir(), `officer-task-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
@@ -57,7 +66,7 @@ export const jobLogPath = (jobId: string) => join(DATA_PATH, 'jobs', `${jobId}.l
|
||||
// output to a durable log file. Resolves with the process exit code; throws only on spawn failure or
|
||||
// when aborted (the manager maps those to failed/stopped).
|
||||
export async function executeScript(params: ExecuteScriptParams): Promise<{ exitCode: number }> {
|
||||
const { jobId, email, role, sandboxed, inputs, abortSignal, emit } = params;
|
||||
const { jobId, email, inputs, abortSignal, emit } = params;
|
||||
|
||||
const task = await getTaskByDirName(params.taskDirName);
|
||||
if (!task) throw new Error(`Task not found: ${params.taskDirName}`);
|
||||
@@ -70,35 +79,21 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
const positionalArgs = buildArgs(inputs, task.args);
|
||||
const cmd = [...getRunner(language), scriptPath, ...positionalArgs];
|
||||
|
||||
const homeDir = getHomeDirForRole(email, role);
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = params.cwd ? (isAbsolute(params.cwd) ? params.cwd : join(homeDir, params.cwd)) : homeDir;
|
||||
|
||||
let spawnCmd: string[];
|
||||
let spawnEnv: Record<string, string>;
|
||||
let spawnCwd: string;
|
||||
|
||||
if (sandboxed) {
|
||||
const prefix = buildSandboxPrefix(email);
|
||||
const suffix = buildRunuserSuffix();
|
||||
const userDataPrefix = join(DATA_PATH, email);
|
||||
const translatePath = (v: string) => (v.startsWith(userDataPrefix) ? '/data' + v.slice(userDataPrefix.length) : v);
|
||||
const envArgs: string[] = [];
|
||||
for (const [key, value] of Object.entries(inputEnv)) envArgs.push('--setenv', key, translatePath(value));
|
||||
const sandboxCmd = cmd.map((arg) => translatePath(arg));
|
||||
const scriptDir = join(scriptPath, '..');
|
||||
spawnCmd = [...prefix, '--ro-bind', scriptDir, scriptDir, ...envArgs, ...suffix, ...sandboxCmd];
|
||||
spawnEnv = {};
|
||||
spawnCwd = '/';
|
||||
} else {
|
||||
spawnCmd = cmd;
|
||||
spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
spawnCwd = cwd;
|
||||
}
|
||||
const spawnCmd = cmd;
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
|
||||
mkdirSync(join(DATA_PATH, 'jobs'), { recursive: true });
|
||||
const log = createWriteStream(jobLogPath(jobId), { flags: 'w' });
|
||||
const cleanup = () => {
|
||||
try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
emit({ type: 'started', taskName: task.name });
|
||||
@@ -109,7 +104,11 @@ export async function executeScript(params: ExecuteScriptParams): Promise<{ exit
|
||||
const abortPoll = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
clearInterval(abortPoll);
|
||||
try { killTree(proc.pid); } catch { /* already dead */ }
|
||||
try {
|
||||
killTree(proc.pid);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
|
||||
@@ -5,9 +5,8 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getHomeDirForRole, getHomeDir } from '../../data-path';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import { resolveBaseCwd } from '../chat/websocket';
|
||||
import { SANDBOX_HOME } from '../../sidecar/sandbox';
|
||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||
import type { ChatEvent, MessageCost } from '../chat/types';
|
||||
import * as jobManager from './pipeline-job-manager';
|
||||
@@ -41,7 +40,12 @@ type PipelineConfig = {
|
||||
// Messages sent to client
|
||||
export type OutMessage =
|
||||
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string; concurrency?: number }> }
|
||||
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||
| {
|
||||
type: 'step:start';
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
iteration?: { current: number; total: number; label: string };
|
||||
}
|
||||
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
|
||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
||||
@@ -51,8 +55,22 @@ export type OutMessage =
|
||||
| { type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||
| { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<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: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
@@ -67,7 +85,6 @@ type RunStepParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
taskDirName: string;
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
@@ -92,14 +109,28 @@ async function refreshProxyToken(): Promise<void> {
|
||||
const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const WAITING_INTERVAL_MS = 10 * 1000; // emit "waiting" every 10s
|
||||
|
||||
async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, model, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
|
||||
async function runAgenticStep({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName,
|
||||
prompt,
|
||||
cwd,
|
||||
model,
|
||||
abortSignal,
|
||||
emit,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
}: RunStepParams): Promise<MessageCost> {
|
||||
const sessionId = randomUUID();
|
||||
const isClaudeCode = model.startsWith('claude-code');
|
||||
|
||||
// Ensure fresh OAuth token before spawning Claude Code
|
||||
if (isClaudeCode) await refreshProxyToken();
|
||||
|
||||
console.log(`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`);
|
||||
console.log(
|
||||
`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`,
|
||||
);
|
||||
|
||||
return new Promise<MessageCost>(async (resolve, reject) => {
|
||||
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
|
||||
@@ -129,19 +160,42 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
|
||||
break;
|
||||
case 'tool:start':
|
||||
emit({ type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput, stepIndex, iterationLabel });
|
||||
emit({
|
||||
type: 'tool:start',
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
toolInput: event.toolInput,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
});
|
||||
break;
|
||||
case 'tool:result':
|
||||
emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
|
||||
emit({
|
||||
type: 'tool:result',
|
||||
toolCallId: event.toolCallId,
|
||||
output: event.output,
|
||||
isError: event.isError,
|
||||
stepIndex,
|
||||
iterationLabel,
|
||||
});
|
||||
break;
|
||||
case 'result':
|
||||
settle(() => { cleanup?.(); resolve(event.cost); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
resolve(event.cost);
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
settle(() => { cleanup?.(); reject(new Error(event.message)); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error(event.message));
|
||||
});
|
||||
break;
|
||||
case 'stopped':
|
||||
settle(() => { cleanup?.(); reject(new Error('Step was stopped')); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error('Step was stopped'));
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -149,14 +203,20 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
// Poll for abort signal and activity timeout
|
||||
const abortPoll = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error('Pipeline was stopped'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Activity timeout (skip for Claude Code which has its own mechanisms)
|
||||
if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) {
|
||||
const elapsed = Math.round((Date.now() - stepStart) / 1000);
|
||||
console.error(`[pipeline] step ${stepIndex} timed out after ${elapsed}s of inactivity (session=${sessionId})`);
|
||||
settle(() => { cleanup?.(); reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`)); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`));
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -176,12 +236,14 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
role,
|
||||
onEvent,
|
||||
});
|
||||
cleanup = handle.kill;
|
||||
} catch (err) {
|
||||
settle(() => { cleanup?.(); reject(err); });
|
||||
settle(() => {
|
||||
cleanup?.();
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -192,16 +254,6 @@ function resolveInputTemplate(template: string, variables: Record<string, string
|
||||
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 {
|
||||
const inputLines = Object.entries(inputs)
|
||||
.filter(([, v]) => v.trim())
|
||||
@@ -219,7 +271,6 @@ function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targe
|
||||
|
||||
type RunScriptStepParams = {
|
||||
email: string;
|
||||
role: string;
|
||||
task: { name: string; implementation: string; language: string; args?: string[] | null };
|
||||
inputs: Record<string, string>;
|
||||
cwd: string;
|
||||
@@ -230,25 +281,43 @@ type RunScriptStepParams = {
|
||||
|
||||
function getRunner(language: string): string[] {
|
||||
switch (language) {
|
||||
case 'bash': return ['bash'];
|
||||
case 'python': return ['python3'];
|
||||
case 'typescript': return ['bun', 'run'];
|
||||
case 'javascript': return ['node'];
|
||||
default: return ['bash'];
|
||||
case 'bash':
|
||||
return ['bash'];
|
||||
case 'python':
|
||||
return ['python3'];
|
||||
case 'typescript':
|
||||
return ['bun', 'run'];
|
||||
case 'javascript':
|
||||
return ['node'];
|
||||
default:
|
||||
return ['bash'];
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(language: string): string {
|
||||
switch (language) {
|
||||
case 'bash': return 'run.sh';
|
||||
case 'python': return 'run.py';
|
||||
case 'typescript': return 'index.ts';
|
||||
case 'javascript': return 'index.js';
|
||||
default: return 'run.sh';
|
||||
case 'bash':
|
||||
return 'run.sh';
|
||||
case 'python':
|
||||
return 'run.py';
|
||||
case 'typescript':
|
||||
return 'index.ts';
|
||||
case 'javascript':
|
||||
return 'index.js';
|
||||
default:
|
||||
return 'run.sh';
|
||||
}
|
||||
}
|
||||
|
||||
async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit, stepIndex }: RunScriptStepParams): Promise<void> {
|
||||
async function runScriptStep({
|
||||
email,
|
||||
task,
|
||||
inputs,
|
||||
cwd,
|
||||
abortSignal,
|
||||
emit,
|
||||
stepIndex,
|
||||
}: RunScriptStepParams): Promise<void> {
|
||||
const language = task.language ?? 'bash';
|
||||
|
||||
// Write script to temp file
|
||||
@@ -260,7 +329,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const cleanup = () => {
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
// Build env vars from inputs
|
||||
@@ -275,7 +348,7 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
||||
const runner = getRunner(language);
|
||||
const cmd = [...runner, scriptPath, ...positionalArgs];
|
||||
|
||||
const spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
|
||||
console.log(`[pipeline] running script step ${stepIndex}: ${task.name} (cwd=${cwd})`);
|
||||
|
||||
@@ -305,7 +378,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
|
||||
// Check abort periodically
|
||||
const abortCheck = setInterval(() => {
|
||||
if (abortSignal.aborted) {
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -336,7 +413,6 @@ type ForeachParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
stepIdx: number;
|
||||
step: PipelineStep;
|
||||
stepTask: { name: string; body: string };
|
||||
@@ -352,10 +428,22 @@ type ForeachParams = {
|
||||
};
|
||||
|
||||
async function runForeach({
|
||||
userId, email, username, role, stepIdx, step, stepTask, subdirs, baseCwd,
|
||||
inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
stepIdx,
|
||||
step,
|
||||
stepTask,
|
||||
subdirs,
|
||||
baseCwd,
|
||||
inputs,
|
||||
cwd,
|
||||
abortSignal,
|
||||
totalCost,
|
||||
emit,
|
||||
concurrency,
|
||||
model,
|
||||
}: ForeachParams) {
|
||||
|
||||
// Determine skip vs run
|
||||
const toSkip: string[] = [];
|
||||
const toRun: string[] = [];
|
||||
@@ -400,13 +488,15 @@ async function runForeach({
|
||||
}
|
||||
|
||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
|
||||
const targetDir = toAgentPath(resolvedCwd, email, role);
|
||||
const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir);
|
||||
const resolvedCwd = resolveBaseCwd(email, cwdRelative);
|
||||
const targetDir = resolvedCwd;
|
||||
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
|
||||
|
||||
try {
|
||||
const cost = await runAgenticStep({
|
||||
userId, email, username, role,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName: step.task,
|
||||
prompt,
|
||||
cwd: resolvedCwd,
|
||||
@@ -424,12 +514,19 @@ async function runForeach({
|
||||
emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost });
|
||||
} catch (err) {
|
||||
if (!abortSignal.aborted) {
|
||||
emit({ type: 'iteration:error', stepIndex: stepIdx, label: subdir, error: err instanceof Error ? err.message : String(err) });
|
||||
emit({
|
||||
type: 'iteration:error',
|
||||
stepIndex: stepIdx,
|
||||
label: subdir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const p = run().then(() => { executing.delete(p); });
|
||||
const p = run().then(() => {
|
||||
executing.delete(p);
|
||||
});
|
||||
executing.add(p);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
@@ -446,7 +543,6 @@ export type ExecutePipelineParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
taskDirName: string;
|
||||
inputs: Record<string, string>;
|
||||
cwd?: string;
|
||||
@@ -456,7 +552,18 @@ export type ExecutePipelineParams = {
|
||||
emit: EmitEvent;
|
||||
};
|
||||
|
||||
export async function executePipeline({ userId, email, username, role, taskDirName, inputs, cwd, model: modelOverride, startAt, abortSignal, emit }: ExecutePipelineParams): Promise<void> {
|
||||
export async function executePipeline({
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName,
|
||||
inputs,
|
||||
cwd,
|
||||
model: modelOverride,
|
||||
startAt,
|
||||
abortSignal,
|
||||
emit,
|
||||
}: ExecutePipelineParams): Promise<void> {
|
||||
const pipelineTask = await getTaskByDirName(taskDirName);
|
||||
if (!pipelineTask) {
|
||||
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
|
||||
@@ -473,7 +580,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
||||
return;
|
||||
}
|
||||
|
||||
const baseCwd = resolveBaseCwd(email, role, cwd);
|
||||
const baseCwd = resolveBaseCwd(email, cwd);
|
||||
let model = modelOverride || (await resolveModel(userId));
|
||||
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
||||
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
||||
@@ -532,8 +639,13 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
||||
|
||||
try {
|
||||
await runScriptStep({
|
||||
email, role,
|
||||
task: { name: stepTask.name, implementation: stepTask.implementation, language: stepTask.language ?? 'bash', args: stepTask.args as string[] | null },
|
||||
email,
|
||||
task: {
|
||||
name: stepTask.name,
|
||||
implementation: stepTask.implementation,
|
||||
language: stepTask.language ?? 'bash',
|
||||
args: stepTask.args as string[] | null,
|
||||
},
|
||||
inputs: resolvedInputs,
|
||||
cwd: baseCwd,
|
||||
abortSignal,
|
||||
@@ -566,19 +678,33 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
|
||||
const concurrency = step.concurrency ? runtimeConcurrency : 1;
|
||||
|
||||
await runForeach({
|
||||
userId, email, username, role,
|
||||
stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body! },
|
||||
subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
stepIdx,
|
||||
step,
|
||||
stepTask: { name: stepTask.name, body: stepTask.body! },
|
||||
subdirs,
|
||||
baseCwd,
|
||||
inputs,
|
||||
cwd,
|
||||
abortSignal,
|
||||
totalCost,
|
||||
emit,
|
||||
concurrency,
|
||||
model,
|
||||
});
|
||||
} else {
|
||||
// Single execution step
|
||||
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
|
||||
|
||||
const targetDir = toAgentPath(baseCwd, email, role);
|
||||
const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir);
|
||||
const targetDir = baseCwd;
|
||||
const prompt = buildStepPrompt(stepTask.body!, resolvedInputs, targetDir);
|
||||
|
||||
const cost = await runAgenticStep({
|
||||
userId, email, username, role,
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
taskDirName: step.task,
|
||||
prompt,
|
||||
cwd: baseCwd,
|
||||
@@ -609,8 +735,6 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
};
|
||||
|
||||
type ClientMessage =
|
||||
@@ -633,7 +757,7 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'run': {
|
||||
const { userId, email, username, role } = ws.data;
|
||||
const { userId, email, username } = ws.data;
|
||||
|
||||
// Resolve task name for the DB record
|
||||
const task = await getTaskByDirName(msg.taskDirName);
|
||||
@@ -646,7 +770,6 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
userId,
|
||||
email,
|
||||
username,
|
||||
role,
|
||||
taskDirName: msg.taskDirName,
|
||||
taskName: task.name,
|
||||
inputs: msg.inputs,
|
||||
@@ -672,7 +795,13 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
// Job not live — send the DB state
|
||||
const job = await jobManager.getJob(msg.jobId);
|
||||
if (job) {
|
||||
send(ws, { type: 'job:state', jobId: msg.jobId, status: job.status, progress: job.progress, cost: job.totalCost });
|
||||
send(ws, {
|
||||
type: 'job:state',
|
||||
jobId: msg.jobId,
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
cost: job.totalCost,
|
||||
});
|
||||
} else {
|
||||
send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` });
|
||||
}
|
||||
@@ -682,7 +811,19 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
|
||||
|
||||
case 'list': {
|
||||
const jobs = await jobManager.getJobsForUser(ws.data.userId);
|
||||
send(ws, { type: 'job:list', jobs: jobs.map((j) => ({ id: j.id, taskDirName: j.taskDirName, taskName: j.taskName, status: j.status, isLive: j.isLive, totalCost: j.totalCost, createdAt: j.createdAt, completedAt: j.completedAt })) });
|
||||
send(ws, {
|
||||
type: 'job:list',
|
||||
jobs: jobs.map((j) => ({
|
||||
id: j.id,
|
||||
taskDirName: j.taskDirName,
|
||||
taskName: j.taskName,
|
||||
status: j.status,
|
||||
isLive: j.isLive,
|
||||
totalCost: j.totalCost,
|
||||
createdAt: j.createdAt,
|
||||
completedAt: j.completedAt,
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
};
|
||||
|
||||
type LiveJob = {
|
||||
@@ -74,9 +72,7 @@ type StartJobParams = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
mode?: JobMode; // defaults to 'pipeline' for back-compat with the existing pipeline caller
|
||||
sandboxed?: boolean; // script jobs only
|
||||
taskDirName: string;
|
||||
taskName: string;
|
||||
inputs: Record<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
|
||||
// 'pending' and gets promoted when the running job finishes. (Single user → one global queue.)
|
||||
export async function enqueueJob(params: StartJobParams, action: 'start' | 'queue'): Promise<{ jobId: string; status: 'running' | 'pending' }> {
|
||||
export async function enqueueJob(
|
||||
params: StartJobParams,
|
||||
action: 'start' | 'queue',
|
||||
): Promise<{ jobId: string; status: 'running' | 'pending' }> {
|
||||
const jobId = randomUUID();
|
||||
const mode: JobMode = params.mode ?? 'pipeline';
|
||||
const run = action === 'start' || runningCount() === 0;
|
||||
@@ -145,7 +144,11 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
if (event.type === 'step:complete' || event.type === 'iteration:complete') {
|
||||
const cost = 'cost' in event ? event.cost : undefined;
|
||||
if (cost) {
|
||||
const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
|
||||
const prev = (job.lastCost as { inputTokens: number; outputTokens: number; totalUSD: number } | null) ?? {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
};
|
||||
job.lastCost = {
|
||||
inputTokens: prev.inputTokens + cost.inputTokens,
|
||||
outputTokens: prev.outputTokens + cost.outputTokens,
|
||||
@@ -177,8 +180,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
? executeScript({
|
||||
jobId,
|
||||
email: params.email,
|
||||
role: params.role,
|
||||
sandboxed: params.sandboxed ?? false,
|
||||
taskDirName: params.taskDirName,
|
||||
inputs: params.inputs,
|
||||
cwd: params.cwd,
|
||||
@@ -189,7 +190,6 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
userId: params.userId,
|
||||
email: params.email,
|
||||
username: params.username,
|
||||
role: params.role,
|
||||
taskDirName: params.taskDirName,
|
||||
inputs: params.inputs,
|
||||
cwd: params.cwd,
|
||||
@@ -199,36 +199,38 @@ function launch(jobId: string, mode: JobMode, params: StartJobParams) {
|
||||
emit,
|
||||
});
|
||||
|
||||
runner.then(async (result) => {
|
||||
clearInterval(flushInterval);
|
||||
// Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void.
|
||||
const exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null;
|
||||
const failed = exitCode !== null && exitCode !== 0;
|
||||
await updatePipelineJob(jobId, {
|
||||
status: failed ? 'failed' : 'completed',
|
||||
exitCode,
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: failed ? `Script exited with code ${exitCode}` : undefined,
|
||||
completedAt: new Date(),
|
||||
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
}).catch(async (err) => {
|
||||
clearInterval(flushInterval);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isStopped = job.abortSignal.aborted;
|
||||
broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message });
|
||||
await updatePipelineJob(jobId, {
|
||||
status: isStopped ? 'stopped' : 'failed',
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: isStopped ? undefined : message,
|
||||
completedAt: new Date(),
|
||||
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
});
|
||||
runner
|
||||
.then(async (result) => {
|
||||
clearInterval(flushInterval);
|
||||
// Script jobs resolve with an exit code — a non-zero exit is a failure. Pipelines resolve void.
|
||||
const exitCode = result && typeof result === 'object' && 'exitCode' in result ? result.exitCode : null;
|
||||
const failed = exitCode !== null && exitCode !== 0;
|
||||
await updatePipelineJob(jobId, {
|
||||
status: failed ? 'failed' : 'completed',
|
||||
exitCode,
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: failed ? `Script exited with code ${exitCode}` : undefined,
|
||||
completedAt: new Date(),
|
||||
}).catch((err) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, err));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
})
|
||||
.catch(async (err) => {
|
||||
clearInterval(flushInterval);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isStopped = job.abortSignal.aborted;
|
||||
broadcast(job, isStopped ? { type: 'stopped' } : { type: 'error', message });
|
||||
await updatePipelineJob(jobId, {
|
||||
status: isStopped ? 'stopped' : 'failed',
|
||||
progress: job.lastProgress as Record<string, unknown>,
|
||||
totalCost: job.lastCost as Record<string, unknown>,
|
||||
error: isStopped ? undefined : message,
|
||||
completedAt: new Date(),
|
||||
}).catch((e) => console.error(`[pipeline-jobs] failed to finalize ${jobId}:`, e));
|
||||
liveJobs.delete(jobId);
|
||||
void promoteNext();
|
||||
});
|
||||
}
|
||||
|
||||
// When a job finishes (and nothing else is running), promote the oldest queued job. Also called on
|
||||
@@ -239,7 +241,9 @@ async function promoteNext(): Promise<void> {
|
||||
if (!next) return;
|
||||
const user = await getUserById(next.userId);
|
||||
if (!user) {
|
||||
await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch(() => {});
|
||||
await updatePipelineJob(next.id, { status: 'failed', error: 'user not found', completedAt: new Date() }).catch(
|
||||
() => {},
|
||||
);
|
||||
return promoteNext();
|
||||
}
|
||||
await updatePipelineJob(next.id, { status: 'running', startedAt: new Date() });
|
||||
@@ -248,9 +252,7 @@ async function promoteNext(): Promise<void> {
|
||||
userId: next.userId,
|
||||
email: user.email,
|
||||
username: toShellUsername(user.username ?? '', user.email),
|
||||
role: user.role ?? '',
|
||||
mode: nextMode,
|
||||
sandboxed: (user.role ?? '') !== 'Super Admin',
|
||||
taskDirName: next.taskDirName,
|
||||
taskName: next.taskName,
|
||||
inputs: next.inputs as Record<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
|
||||
// is running (for the "running" badge's link).
|
||||
export async function getCounts(userId: number): Promise<{ running: number; runningJobId: string | null; queued: number }> {
|
||||
export async function getCounts(
|
||||
userId: number,
|
||||
): Promise<{ running: number; runningJobId: string | null; queued: number }> {
|
||||
let running = 0;
|
||||
let runningJobId: string | null = null;
|
||||
for (const [id, job] of liveJobs) {
|
||||
@@ -386,7 +390,11 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
||||
return {
|
||||
...p,
|
||||
currentStepIndex: event.stepIndex,
|
||||
parallel: { taskName: event.taskName, concurrency: event.concurrency, iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })) },
|
||||
parallel: {
|
||||
taskName: event.taskName,
|
||||
concurrency: event.concurrency,
|
||||
iterations: event.iterations.map((l) => ({ label: l, status: 'pending' })),
|
||||
},
|
||||
};
|
||||
|
||||
case 'iteration:start':
|
||||
@@ -396,7 +404,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
||||
...p,
|
||||
parallel: {
|
||||
...parallel,
|
||||
iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status: 'running' } : it),
|
||||
iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status: 'running' } : it)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -411,7 +419,7 @@ function extractProgress(event: JobEvent, prev: unknown): unknown {
|
||||
...p,
|
||||
parallel: {
|
||||
...parallel,
|
||||
iterations: parallel.iterations.map((it) => it.label === event.label ? { ...it, status } : it),
|
||||
iterations: parallel.iterations.map((it) => (it.label === event.label ? { ...it, status } : it)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,12 @@ pipelineJobsRouter.get('/', async (c) => {
|
||||
// job. Returns { jobId, status }. This is the REST creation path the phone / unattended runs use.
|
||||
pipelineJobsRouter.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json<{ taskDirName: string; inputs?: Record<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');
|
||||
|
||||
const task = await getTaskByDirName(body.taskDirName);
|
||||
@@ -52,9 +57,7 @@ pipelineJobsRouter.post('/', async (c) => {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
username: user.username ?? '',
|
||||
role: user.role ?? '',
|
||||
mode,
|
||||
sandboxed: (user.role ?? '') !== 'Super Admin',
|
||||
taskDirName: body.taskDirName,
|
||||
taskName: task.name,
|
||||
inputs: body.inputs ?? {},
|
||||
|
||||
@@ -2,15 +2,12 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getHomeDirForRole, DATA_PATH } from '../../data-path';
|
||||
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
|
||||
import { getOwnerHomeDir } from '../../data-path';
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
};
|
||||
|
||||
type RunMessage = {
|
||||
@@ -79,11 +76,19 @@ function descendantPids(root: number): number[] {
|
||||
function killTree(root: number) {
|
||||
const pids = [root, ...descendantPids(root)];
|
||||
for (const pid of pids) {
|
||||
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
for (const pid of pids) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
/* gone */
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -96,21 +101,31 @@ function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
|
||||
|
||||
function getRunner(language: string): string[] {
|
||||
switch (language) {
|
||||
case 'bash': return ['bash'];
|
||||
case 'python': return ['python3'];
|
||||
case 'typescript': return ['bun', 'run'];
|
||||
case 'javascript': return ['node'];
|
||||
default: return ['bash'];
|
||||
case 'bash':
|
||||
return ['bash'];
|
||||
case 'python':
|
||||
return ['python3'];
|
||||
case 'typescript':
|
||||
return ['bun', 'run'];
|
||||
case 'javascript':
|
||||
return ['node'];
|
||||
default:
|
||||
return ['bash'];
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(language: string): string {
|
||||
switch (language) {
|
||||
case 'bash': return 'run.sh';
|
||||
case 'python': return 'run.py';
|
||||
case 'typescript': return 'index.ts';
|
||||
case 'javascript': return 'index.js';
|
||||
default: return 'run.sh';
|
||||
case 'bash':
|
||||
return 'run.sh';
|
||||
case 'python':
|
||||
return 'run.py';
|
||||
case 'typescript':
|
||||
return 'index.ts';
|
||||
case 'javascript':
|
||||
return 'index.js';
|
||||
default:
|
||||
return 'run.sh';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +157,7 @@ function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null):
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
// (a missing cwd surfaces as ENOENT naming the binary, not the directory)
|
||||
const homeDir = getHomeDirForRole(email, role);
|
||||
const homeDir = getOwnerHomeDir(email);
|
||||
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
|
||||
|
||||
let spawnCmd: string[];
|
||||
let spawnEnv: Record<string, string>;
|
||||
let spawnCwd: string;
|
||||
|
||||
if (sandboxed) {
|
||||
const prefix = buildSandboxPrefix(email);
|
||||
const suffix = buildRunuserSuffix();
|
||||
|
||||
// Translate paths in inputs and args: DATA_PATH/{email}/... → /data/...
|
||||
const userDataPrefix = join(DATA_PATH, email);
|
||||
const translatePath = (v: string) => v.startsWith(userDataPrefix) ? '/data' + v.slice(userDataPrefix.length) : v;
|
||||
|
||||
const envArgs: string[] = [];
|
||||
for (const [key, value] of Object.entries(inputEnv)) {
|
||||
envArgs.push('--setenv', key, translatePath(value));
|
||||
}
|
||||
|
||||
// Translate positional args too
|
||||
const sandboxCmd = cmd.map((arg) => translatePath(arg));
|
||||
|
||||
// Script is in /tmp which is a tmpfs inside bwrap — need to bind-mount the host tmp dir
|
||||
const scriptDir = join(scriptPath, '..');
|
||||
const extraMounts = ['--ro-bind', scriptDir, scriptDir];
|
||||
|
||||
spawnCmd = [...prefix, ...extraMounts, ...envArgs, ...suffix, ...sandboxCmd];
|
||||
spawnEnv = {};
|
||||
spawnCwd = '/';
|
||||
} else {
|
||||
spawnCmd = cmd;
|
||||
spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
|
||||
spawnCwd = cwd;
|
||||
}
|
||||
const spawnCmd = cmd;
|
||||
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
|
||||
const spawnCwd = cwd;
|
||||
|
||||
const cleanup = () => {
|
||||
try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try {
|
||||
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
send(ws, { type: 'started', taskName: task.name });
|
||||
@@ -231,7 +221,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
activeProcs.set(ws, {
|
||||
proc,
|
||||
kill: () => {
|
||||
try { killTree(proc.pid); } catch { /* already dead */ }
|
||||
try {
|
||||
killTree(proc.pid);
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -239,7 +233,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
|
||||
// file for minutes with no output). Bun's default 120s idle timeout would otherwise close the
|
||||
// socket → close(ws) → killTree kills the task mid-run. A ping resets the idle timer.
|
||||
const keepAlive = setInterval(() => {
|
||||
try { ws.ping(); } catch { /* socket gone */ }
|
||||
try {
|
||||
ws.ping();
|
||||
} catch {
|
||||
/* socket gone */
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
const stdoutReader = proc.stdout.getReader();
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { join } from 'node:path';
|
||||
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
||||
import type { PtyInitConfig } from '../../sidecar/protocol';
|
||||
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../../sidecar/sandbox';
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
cols?: number;
|
||||
@@ -49,60 +44,28 @@ const resolveCwd = (home: string, cwd?: string) => {
|
||||
|
||||
export const terminalWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
const { email, username, role, sandboxed } = ws.data;
|
||||
const isHost = !sandboxed && role === 'Super Admin';
|
||||
const { email, username } = ws.data;
|
||||
|
||||
console.log(
|
||||
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
|
||||
);
|
||||
console.log(`[terminal] open: email=${email} username=${username}`);
|
||||
|
||||
if (!isTerminalConnected()) {
|
||||
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = ws.data.sessionId ?? (isHost ? `host-${ws.data.userId}` : `default-${ws.data.userId}`);
|
||||
const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`;
|
||||
|
||||
// Build PTY init config
|
||||
let config: PtyInitConfig;
|
||||
|
||||
if (isHost) {
|
||||
config = {
|
||||
sessionId,
|
||||
host: true,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME!,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
};
|
||||
} else {
|
||||
const homeDir = getHomeDir(email);
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
// Build bwrap command for sandboxed terminal
|
||||
const prefix = buildSandboxPrefix(email);
|
||||
prefix.push('--setenv', 'ZDOTDIR', SANDBOX_HOME);
|
||||
prefix.push('--setenv', 'ZSH', `${SANDBOX_HOME}/.oh-my-zsh`);
|
||||
prefix.push('--setenv', 'SHELL', '/bin/zsh');
|
||||
prefix.push('--setenv', 'USER', username);
|
||||
prefix.push('--setenv', 'LOGNAME', username);
|
||||
prefix.push('--setenv', 'OFFICER_TERMINAL_USER', email);
|
||||
prefix.push('--setenv', 'TERM', 'xterm-256color');
|
||||
const bwrapArgs = [...prefix, ...buildRunuserSuffix(), '/bin/zsh', '-i'];
|
||||
|
||||
config = {
|
||||
sessionId,
|
||||
shell: { command: bwrapArgs[0]!, args: bwrapArgs.slice(1) },
|
||||
cwd: SANDBOX_HOME,
|
||||
homeDir,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
};
|
||||
}
|
||||
// The server owner is the only account, so the terminal is always a plain host shell.
|
||||
const config: PtyInitConfig = {
|
||||
sessionId,
|
||||
host: true,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME!,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
};
|
||||
|
||||
// Subscribe to events for this session
|
||||
const unsubOutput = on('pty:output', (msg) => {
|
||||
|
||||
@@ -83,10 +83,3 @@ async function seedShellConfigs(homeDir: string): Promise<void> {
|
||||
// Ensure .local/bin exists
|
||||
mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true });
|
||||
}
|
||||
|
||||
export function deprovisionUserEnvironment(email: string, _username: string): boolean {
|
||||
// User data directories are intentionally kept on disk.
|
||||
// This function exists for API compatibility.
|
||||
console.log(`[provision] deprovision called for ${email} (no-op, data kept on disk)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,111 +1,10 @@
|
||||
import { getUsers, getUserByEmail, getUserById, createUser, deleteUser } from 'officerdb';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { sign } from '@@/jwt';
|
||||
import { USER_ROLES } from 'definitions';
|
||||
import { sendMail } from 'emailer';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { originMiddleware } from '@@/_middlewares';
|
||||
import { updateUserHandler } from './update-user';
|
||||
import { deprovisionUserEnvironment } from './provision';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
|
||||
// List all users (Super Admin only)
|
||||
usersRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const users = await getUsers();
|
||||
const sanitized = users.map(({ password, ...rest }) => rest);
|
||||
|
||||
return ctx.json(sanitized);
|
||||
});
|
||||
|
||||
// Self-update (any authenticated user)
|
||||
// Self-update. Officer is single-user: the server owner is the only account, so there is no user
|
||||
// listing, invitation or deletion — the account is created once by /auth/bootstrap.
|
||||
usersRouter.put('/', updateUserHandler);
|
||||
|
||||
// Invite a new user (Super Admin only)
|
||||
usersRouter.post('/invite', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const { email, role } = ctx.get('body');
|
||||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw errors.BAD_REQUEST('Invalid email address');
|
||||
}
|
||||
|
||||
const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
const assignedRole =
|
||||
typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number])
|
||||
? (role as (typeof USER_ROLES)[number])
|
||||
: ('Member' as const);
|
||||
|
||||
const existing = await getUserByEmail(email);
|
||||
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
||||
|
||||
const dbUser = await createUser({
|
||||
email,
|
||||
role: assignedRole,
|
||||
status: 'Invited',
|
||||
});
|
||||
|
||||
const origin = ctx.get('origin');
|
||||
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'UserInvite',
|
||||
subject: 'You have been invited to officer.dev',
|
||||
to: email,
|
||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||
});
|
||||
|
||||
const { password, ...safeUser } = dbUser;
|
||||
return ctx.json(safeUser);
|
||||
});
|
||||
|
||||
// Resend invite (Super Admin only, status must be Invited)
|
||||
usersRouter.post('/:id/resend-invite', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||
|
||||
const target = await getUserById(id);
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status');
|
||||
|
||||
const origin = ctx.get('origin');
|
||||
const verificationCode = await sign({ id: target.id, email: target.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'UserInvite',
|
||||
subject: 'You have been invited to officer.dev',
|
||||
to: target.email,
|
||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// Delete a user (Super Admin only, cannot delete self)
|
||||
usersRouter.delete('/:id', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||
if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself');
|
||||
|
||||
const target = await getUserById(id);
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Deprovision user environment before deleting from database
|
||||
deprovisionUserEnvironment(target.email, target.username ?? '');
|
||||
|
||||
await deleteUser(id);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -17,9 +17,6 @@ export const channelsRouter = createRouter();
|
||||
// ── Admin: Discord config ──
|
||||
|
||||
channelsRouter.get('/discord/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const integration = await getServerIntegration('discord');
|
||||
if (!integration) return ctx.json({ configured: false });
|
||||
|
||||
@@ -36,9 +33,6 @@ channelsRouter.get('/discord/config', async (ctx) => {
|
||||
});
|
||||
|
||||
channelsRouter.put('/discord/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const botToken = body.botToken as string | undefined;
|
||||
const enabled = body.enabled as boolean | undefined;
|
||||
@@ -131,9 +125,6 @@ channelsRouter.delete('/discord/connection', async (ctx) => {
|
||||
// ── Admin: Telegram config ──
|
||||
|
||||
channelsRouter.get('/telegram/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const integration = await getServerIntegration('telegram');
|
||||
if (!integration) return ctx.json({ configured: false });
|
||||
|
||||
@@ -150,9 +141,6 @@ channelsRouter.get('/telegram/config', async (ctx) => {
|
||||
});
|
||||
|
||||
channelsRouter.put('/telegram/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const botToken = body.botToken as string | undefined;
|
||||
const enabled = body.enabled as boolean | undefined;
|
||||
@@ -245,9 +233,6 @@ channelsRouter.delete('/telegram/connection', async (ctx) => {
|
||||
// ── Admin: WhatsApp config ──
|
||||
|
||||
channelsRouter.get('/whatsapp/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const integration = await getServerIntegration('whatsapp');
|
||||
|
||||
return ctx.json({
|
||||
@@ -259,9 +244,6 @@ channelsRouter.get('/whatsapp/config', async (ctx) => {
|
||||
});
|
||||
|
||||
channelsRouter.put('/whatsapp/config', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const enabled = body.enabled as boolean | undefined;
|
||||
|
||||
@@ -295,9 +277,6 @@ channelsRouter.get('/whatsapp/status', async (ctx) => {
|
||||
});
|
||||
|
||||
channelsRouter.get('/whatsapp/qr', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') return ctx.json({ error: 'Forbidden' }, 403);
|
||||
|
||||
const qr = getWhatsAppQR();
|
||||
return ctx.json({
|
||||
qr,
|
||||
|
||||
@@ -13,7 +13,6 @@ type SendAndAwaitParams = {
|
||||
context: string;
|
||||
contextId: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type SendAndAwaitResult = {
|
||||
@@ -83,7 +82,6 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndA
|
||||
prompt: params.prompt,
|
||||
sessionKey: sessionId,
|
||||
model,
|
||||
role: params.role,
|
||||
});
|
||||
} finally {
|
||||
releaseLock!();
|
||||
|
||||
@@ -9,7 +9,6 @@ type ClaudeCodeParams = {
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type ClaudeCodeResult = {
|
||||
@@ -38,7 +37,6 @@ type ClaudeCodeStreamingParams = {
|
||||
sessionKey: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
resumeSessionId?: string;
|
||||
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');
|
||||
|
||||
// The managed home under DATA_PATH — what provisioning seeds and what the generated Claude config
|
||||
// points at. Distinct from the owner's real login home below.
|
||||
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||
|
||||
export const getHomeDirForRole = (email: string, role: string | null): string =>
|
||||
role === 'Super Admin' && process.env.HOME_DIR ? process.env.HOME_DIR : getHomeDir(email);
|
||||
// Where the owner's sessions actually run: their real login home when HOME_DIR is set, so platform
|
||||
// terminals/chats/tasks share config and credentials with the shell they use outside Officer.
|
||||
export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email);
|
||||
|
||||
export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent');
|
||||
|
||||
|
||||
+1
-3
@@ -33,7 +33,7 @@ import { chatRouter } from './api/chat/chat';
|
||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||
import { broadcastPanelRefresh } from './api/terminal/websocket';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser, isOriginAllowed, superAdminMiddleware } from './_middlewares';
|
||||
import { userMiddleware, bodyParser, isOriginAllowed } from './_middlewares';
|
||||
|
||||
export { Hono };
|
||||
export { createRouter };
|
||||
@@ -77,7 +77,6 @@ const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
protectedRouter.use(userMiddleware);
|
||||
|
||||
serverSettingsRouter.use(superAdminMiddleware);
|
||||
protectedRouter.route('/server-settings', serverSettingsRouter);
|
||||
protectedRouter.route('/users', usersRouter);
|
||||
protectedRouter.route('/plans', plansRouter);
|
||||
@@ -104,7 +103,6 @@ protectedRouter.route('/bug-report', bugReportRouter);
|
||||
protectedRouter.route('/chat', chatRouter);
|
||||
protectedRouter.route('/pipeline-jobs', pipelineJobsRouter);
|
||||
protectedRouter.route('/jobs', pipelineJobsRouter); // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||
desktopRouter.use(superAdminMiddleware);
|
||||
protectedRouter.route('/desktop', desktopRouter);
|
||||
|
||||
honoServer.route('/api', protectedRouter);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { join } from 'node:path';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { ChatEvent } from '../../api/chat/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { parseStream } from './stream-parser';
|
||||
|
||||
@@ -16,26 +15,13 @@ const CLAUDE_BIN = '/usr/local/bin/claude';
|
||||
const HOST_HOME = process.env.HOME!;
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
|
||||
// Build full bwrap sandbox args for Claude (prefix + Anthropic env + runuser suffix)
|
||||
function buildSandboxArgs(email: string): string[] {
|
||||
const prefix = buildSandboxPrefix(email);
|
||||
|
||||
// Claude-specific env vars
|
||||
if (process.env.ANTHROPIC_BASE_URL) prefix.push('--setenv', 'ANTHROPIC_BASE_URL', process.env.ANTHROPIC_BASE_URL);
|
||||
if (process.env.ANTHROPIC_API_KEY) prefix.push('--setenv', 'ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY);
|
||||
|
||||
return [...prefix, ...buildRunuserSuffix()];
|
||||
}
|
||||
|
||||
// Active streaming processes
|
||||
const activeProcs = new Map<string, Subprocess>();
|
||||
|
||||
// MCP config paths, set by user-instance at startup
|
||||
let mcpSandboxPath: string | undefined; // path inside bwrap sandbox (/data/...)
|
||||
let mcpHostPath: string | undefined; // path on the host filesystem
|
||||
|
||||
export function setMcpConfigPath(sandboxPath: string, hostPath: string): void {
|
||||
mcpSandboxPath = sandboxPath;
|
||||
export function setMcpConfigPath(hostPath: string): void {
|
||||
mcpHostPath = hostPath;
|
||||
}
|
||||
|
||||
@@ -57,8 +43,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
|
||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
||||
|
||||
const isSuperAdmin = params.role === 'Super Admin';
|
||||
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
|
||||
const mcpConfig = mcpHostPath;
|
||||
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
@@ -68,10 +53,10 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
claudeArgs.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
||||
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions dir);
|
||||
// fall back to the host home for Super Admin, or the sandbox default otherwise.
|
||||
const spawnCwd = isSuperAdmin ? (params.cwd ?? HOST_HOME) : undefined;
|
||||
const spawnCmd = claudeArgs;
|
||||
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions
|
||||
// dir); fall back to the owner's host home.
|
||||
const spawnCwd = params.cwd ?? HOST_HOME;
|
||||
|
||||
const proc = Bun.spawn(spawnCmd, {
|
||||
stdin: 'pipe',
|
||||
@@ -156,8 +141,7 @@ export async function spawnClaudeStreaming(
|
||||
];
|
||||
|
||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||
const isSuperAdmin = params.role === 'Super Admin';
|
||||
const mcpConfig = isSuperAdmin ? mcpHostPath : mcpSandboxPath;
|
||||
const mcpConfig = mcpHostPath;
|
||||
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
@@ -171,10 +155,10 @@ export async function spawnClaudeStreaming(
|
||||
if (!existingSession) setClaudeSession(sessionKey, resumeId);
|
||||
}
|
||||
|
||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
||||
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions dir);
|
||||
// fall back to the host home for Super Admin, or the sandbox default otherwise.
|
||||
const spawnCwd = isSuperAdmin ? (params.cwd ?? HOST_HOME) : undefined;
|
||||
const spawnCmd = claudeArgs;
|
||||
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions
|
||||
// dir); fall back to the owner's host home.
|
||||
const spawnCwd = params.cwd ?? HOST_HOME;
|
||||
|
||||
const proc = Bun.spawn(spawnCmd, {
|
||||
stdin: 'ignore',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { homedir } from 'node:os';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock } from './state';
|
||||
import { setMcpConfigPath } from './claude-manager';
|
||||
import { SANDBOX_DATA } from '../sandbox';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
@@ -27,16 +26,12 @@ if (!dbUser) {
|
||||
console.error(`[user-instance] no user found for ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const OFFICER_AUTH_TOKEN = await sign(
|
||||
{ id: dbUser.id, email, username: dbUser.username, role: dbUser.role },
|
||||
'30d',
|
||||
);
|
||||
const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d');
|
||||
|
||||
// Single-user platform: the Super Admin runs Claude with no isolation — real HOME, real ~/.claude —
|
||||
// so 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.
|
||||
const homeDir =
|
||||
dbUser.role === 'Super Admin' ? (process.env.HOME_DIR ?? homedir()) : join(DATA_PATH, email, 'home');
|
||||
// Single-user platform: the owner runs Claude with no isolation — real HOME, real ~/.claude — so
|
||||
// platform sessions have perfect parity with terminal sessions (same config, credentials and
|
||||
// transcript store, interchangeable via `claude --resume`).
|
||||
const homeDir = process.env.HOME_DIR ?? homedir();
|
||||
const globalToolsDir = join(DATA_PATH, 'tools');
|
||||
|
||||
// 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();
|
||||
|
||||
// ── 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 ──
|
||||
|
||||
type McpPaths = { sandboxPath: string; hostPath: string };
|
||||
|
||||
function generateMcpConfig(): McpPaths {
|
||||
function generateMcpConfig(): string {
|
||||
const contextDir = join(DATA_PATH, email!, '.container-context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
|
||||
const userRoot = join(DATA_PATH, email!);
|
||||
|
||||
// Sandbox config (paths relative to /data mount)
|
||||
const sandboxToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [`${SANDBOX_DATA}/tools`] : [])].join(':');
|
||||
const sandboxConfig = {
|
||||
mcpServers: {
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
args: ['run', MCP_SERVER_SCRIPT],
|
||||
env: {
|
||||
PI_TOOLS_DIRS: sandboxToolsDirs,
|
||||
OFFICER_EMAIL_DB: `${SANDBOX_DATA}/${emailDbRel}`,
|
||||
MCP_TOOLS_LOG: `${SANDBOX_DATA}/logs/mcp-tools.log`,
|
||||
OFFICER_API_URL,
|
||||
OFFICER_AUTH_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(join(contextDir, 'mcp.json'), JSON.stringify(sandboxConfig));
|
||||
|
||||
// Host config (real filesystem paths, for Super Admin)
|
||||
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
|
||||
const hostConfig = {
|
||||
mcpServers: {
|
||||
@@ -125,27 +82,16 @@ function generateMcpConfig(): McpPaths {
|
||||
};
|
||||
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig));
|
||||
|
||||
return {
|
||||
sandboxPath: `${SANDBOX_DATA}/.container-context/mcp.json`,
|
||||
hostPath: join(contextDir, 'mcp-host.json'),
|
||||
};
|
||||
return join(contextDir, 'mcp-host.json');
|
||||
}
|
||||
|
||||
// ── Startup ──
|
||||
|
||||
// Only sandboxed users get the generated container CLAUDE.md. For the un-isolated Super Admin, HOME is
|
||||
// the real home, so writing it there would pollute the personal global ~/.claude/CLAUDE.md (loaded by
|
||||
// the terminal `claude` too) — parity means running as the user, not injecting platform context.
|
||||
if (dbUser.role !== 'Super Admin') {
|
||||
try {
|
||||
refreshClaudeMd();
|
||||
} catch (err) {
|
||||
console.error(`[claude:${email}] failed to refresh CLAUDE.md:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
// The owner runs un-isolated with HOME as their real home, so the generated container CLAUDE.md is
|
||||
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
||||
// terminal `claude` loads too.
|
||||
|
||||
const mcpPaths = generateMcpConfig();
|
||||
setMcpConfigPath(mcpPaths.sandboxPath, mcpPaths.hostPath);
|
||||
setMcpConfigPath(generateMcpConfig());
|
||||
|
||||
console.log(`[claude:${email}] started (HOME=${homeDir})`);
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ export type ClaudeSpawnParams = {
|
||||
prompt: string;
|
||||
sessionKey: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
@@ -82,7 +81,6 @@ export type ClaudeSpawnStreamingParams = {
|
||||
sessionKey: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
role?: string;
|
||||
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
|
||||
};
|
||||
|
||||
@@ -108,7 +106,6 @@ export type OpenCodeRunParams = {
|
||||
export type VncStartParams = {
|
||||
email: string;
|
||||
username: string;
|
||||
role: string | null;
|
||||
resolution?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { join } from 'node:path';
|
||||
import type { VncStartParams, VncSessionInfo } from '../protocol';
|
||||
import { getHomeDirForRole } from '@@/data-path';
|
||||
import { getOwnerHomeDir } from '@@/data-path';
|
||||
|
||||
// Mirrors the physical display instead of spawning a virtual desktop per user, so the
|
||||
// browser shows the same session as the screen. There is exactly one :0, hence one
|
||||
@@ -106,7 +106,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
|
||||
}
|
||||
mirror = null;
|
||||
|
||||
const homeDir = getHomeDirForRole(params.email, params.role);
|
||||
const homeDir = getOwnerHomeDir(params.email);
|
||||
const passwdFile = await ensureVncPassword(homeDir);
|
||||
|
||||
const xauthority = resolveXauthority();
|
||||
|
||||
Reference in New Issue
Block a user