workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent bd362dc586
commit 927267e041
98 changed files with 1176 additions and 802 deletions
+3 -2
View File
@@ -5,6 +5,7 @@ import { sign, verify } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
export const bootstrapHandler: Handler = async function (ctx) {
const body = ctx.get('body');
@@ -42,7 +43,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
const confirmPassword = body.confirmPassword as string;
if (!name || !name.trim()) throw errors.BAD_REQUEST('Name is required');
if (!username || !username.trim()) throw errors.BAD_REQUEST('Username is required');
const validUsername = validateUsername(username);
validatePassword(password);
if (password !== confirmPassword) throw errors.BAD_REQUEST('Passwords do not match');
@@ -52,7 +53,7 @@ export const bootstrapHandler: Handler = async function (ctx) {
email: payload.email,
password: passwordHash,
name: name.trim(),
username: username.trim(),
username: validUsername,
role: 'Super Admin',
status: 'Active',
});
+23
View File
@@ -0,0 +1,23 @@
import * as errors from '@@/custom-errors';
export function validateUsername(username: string | undefined): string {
if (!username || !username.trim()) {
throw errors.BAD_REQUEST('Username is required');
}
const trimmed = username.trim();
if (trimmed.includes('@')) {
throw errors.BAD_REQUEST('Username cannot be an email address');
}
if (trimmed.length < 2 || trimmed.length > 32) {
throw errors.BAD_REQUEST('Username must be between 2 and 32 characters');
}
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
throw errors.BAD_REQUEST('Username can only contain letters, numbers, dots, hyphens, and underscores');
}
return trimmed;
}
+2 -1
View File
@@ -5,6 +5,7 @@ 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';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
@@ -24,7 +25,7 @@ export const verifyHandler: Handler = async function (ctx) {
}
if (username && typeof username === 'string' && username.trim()) {
updates.username = username.trim();
updates.username = validateUsername(username);
}
if (password) {
@@ -2,35 +2,38 @@ import { mkdir, readdir, rm, cp } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { readdirSync } from 'node:fs';
import { createRouter } from '@@/create-router';
import { getDirs, migrateFromState, migrateHomepageToScreens, readAllWorkspacesState, resolveKey, writeJsonFile } from './utils';
import { getDirs, migrateDashboardsDir, migrateFromState, migrateHomepageToScreens, readAllDashboardsState, resolveKey, writeJsonFile } from './utils';
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
export const workspacesRouter = createRouter();
export const dashboardsRouter = createRouter();
// GET /workspaces
workspacesRouter.get('/', async (ctx) => {
// GET /dashboards
dashboardsRouter.get('/', async (ctx) => {
const email = ctx.get('user').email;
await migrateDashboardsDir(email);
const dirs = getDirs(email);
const dirFile = Bun.file(join(dirs.wsDir, 'index.json'));
const dirFile = Bun.file(join(dirs.dashDir, 'index.json'));
if (!(await dirFile.exists())) {
await migrateFromState(email, dirs);
}
await migrateHomepageToScreens(dirs, email);
const state = await readAllWorkspacesState(dirs);
const state = await readAllDashboardsState(dirs);
return ctx.json(state);
});
// PATCH /workspaces
workspacesRouter.patch('/', async (ctx) => {
// PATCH /dashboards
dashboardsRouter.patch('/', async (ctx) => {
const email = ctx.get('user').email;
const body = ctx.get('body') as Record<string, unknown>;
const dirs = getDirs(email);
await mkdir(dirs.wsDir, { recursive: true });
await mkdir(dirs.dashDir, { recursive: true });
for (const [key, value] of Object.entries(body)) {
// Handle proj-meta-{slug}: create/update/delete project
@@ -94,6 +97,6 @@ workspacesRouter.patch('/', async (ctx) => {
await writeJsonFile(mapping.file, value);
}
const state = await readAllWorkspacesState(dirs);
const state = await readAllDashboardsState(dirs);
return ctx.json(state);
});
@@ -1,3 +1,3 @@
export * from './workspaces';
export * from './dashboards';
export * from './types';
export * from './utils';
+3
View File
@@ -0,0 +1,3 @@
export type KeyMapping = { file: string; dir?: string };
export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string };
@@ -1,14 +1,14 @@
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { getUserWorkspacesDir, getUserHomepageWorkspaceDir, getUserStateFile, getUserProjectsDir } from '@@/data-path';
import { getUserDashboardsDir, getUserHomepageDashboardDir, getUserStateFile, getUserProjectsDir, DATA_PATH } from '@@/data-path';
import type { KeyMapping, ResolveDirs } from './types';
const RESERVED_DIRS = new Set(['screens']);
export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
if (key === 'workspaces') return { file: join(dirs.wsDir, 'index.json') };
if (key === 'ws-terminals-default') return { file: join(dirs.wsDir, 'default-terminals.json') };
if (key === 'ws-host-terminals-default') return { file: join(dirs.wsDir, 'default-host-terminals.json') };
if (key === 'workspaces') return { file: join(dirs.dashDir, 'index.json') };
if (key === 'ws-terminals-default') return { file: join(dirs.dashDir, 'default-terminals.json') };
if (key === 'ws-host-terminals-default') return { file: join(dirs.dashDir, 'default-host-terminals.json') };
// screens/{name} → screens/{name}/layout.json
const screensMatch = key.match(/^screens\/(.+)$/);
@@ -21,21 +21,21 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
const layoutMatch = key.match(/^ws-layout-(.+)$/);
if (layoutMatch) {
const id = layoutMatch[1]!;
const dir = join(dirs.wsDir, id);
const dir = join(dirs.dashDir, id);
return { file: join(dir, 'layout.json'), dir };
}
const terminalsMatch = key.match(/^ws-terminals-(.+)$/);
if (terminalsMatch) {
const id = terminalsMatch[1]!;
const dir = join(dirs.wsDir, id);
const dir = join(dirs.dashDir, id);
return { file: join(dir, 'terminals.json'), dir };
}
const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
if (hostTerminalsMatch) {
const id = hostTerminalsMatch[1]!;
const dir = join(dirs.wsDir, id);
const dir = join(dirs.dashDir, id);
return { file: join(dir, 'host-terminals.json'), dir };
}
@@ -97,7 +97,7 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
);
if (wsKeys.length === 0) return;
await mkdir(dirs.wsDir, { recursive: true });
await mkdir(dirs.dashDir, { recursive: true });
for (const key of wsKeys) {
const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key;
@@ -113,8 +113,7 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
}
export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) {
// Migrate from old ws-homepage/ dir to workspaces/screens/homepage/
const oldHomepageDir = getUserHomepageWorkspaceDir(email);
const oldHomepageDir = getUserHomepageDashboardDir(email);
const layoutFile = join(oldHomepageDir, 'layout.json');
if (!(await Bun.file(layoutFile).exists())) return;
@@ -131,8 +130,7 @@ export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string)
const remaining = await readdir(oldHomepageDir);
if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true });
// Also migrate from workspaces/ws-homepage/ if it exists
const oldWsHomepageDir = join(dirs.wsDir, 'ws-homepage');
const oldWsHomepageDir = join(dirs.dashDir, 'ws-homepage');
const oldWsLayout = join(oldWsHomepageDir, 'layout.json');
if (!(await Bun.file(oldWsLayout).exists())) return;
@@ -148,7 +146,24 @@ export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string)
if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true });
}
async function readWorkspaceDir(dirPath: string, id: string, result: Record<string, unknown>) {
// Migrate on-disk directory from old 'workspaces' name to 'dashboards'
export async function migrateDashboardsDir(email: string) {
const oldDir = join(DATA_PATH, email, 'workspaces');
const newDir = getUserDashboardsDir(email);
try {
const oldExists = await Bun.file(join(oldDir, 'index.json')).exists() || await readdir(oldDir).then(() => true).catch(() => false);
if (oldExists) {
const newExists = await readdir(newDir).then(() => true).catch(() => false);
if (!newExists) {
await rename(oldDir, newDir);
}
}
} catch {
// ignore — old dir doesn't exist
}
}
async function readDashboardDir(dirPath: string, id: string, result: Record<string, unknown>) {
const layout = await readJsonFile(join(dirPath, 'layout.json'));
if (layout !== null) result[`ws-layout-${id}`] = layout;
@@ -191,32 +206,32 @@ async function readScreensDir(screensDir: string, result: Record<string, unknown
}
}
export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
export async function readAllDashboardsState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = {};
const indexData = await readJsonFile(join(dirs.wsDir, 'index.json'));
const indexData = await readJsonFile(join(dirs.dashDir, 'index.json'));
if (indexData !== null) result['workspaces'] = indexData;
const defaultTerminals = await readJsonFile(join(dirs.wsDir, 'default-terminals.json'));
const defaultTerminals = await readJsonFile(join(dirs.dashDir, 'default-terminals.json'));
if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals;
const defaultHostTerminals = await readJsonFile(join(dirs.wsDir, 'default-host-terminals.json'));
const defaultHostTerminals = await readJsonFile(join(dirs.dashDir, 'default-host-terminals.json'));
if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals;
// Read screens
await readScreensDir(dirs.screensDir, result);
// Read per-workspace subdirs
// Read per-dashboard subdirs
let entries: import('node:fs').Dirent[] = [];
try {
entries = await readdir(dirs.wsDir, { withFileTypes: true });
entries = await readdir(dirs.dashDir, { withFileTypes: true });
} catch {
return result;
}
for (const entry of entries) {
if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue;
await readWorkspaceDir(join(dirs.wsDir, entry.name), entry.name, result);
await readDashboardDir(join(dirs.dashDir, entry.name), entry.name, result);
}
// Read project data — scan directories containing .officerdev/meta.json
@@ -241,8 +256,8 @@ export async function readAllWorkspacesState(dirs: ResolveDirs): Promise<Record<
export function getDirs(email: string): ResolveDirs {
return {
wsDir: getUserWorkspacesDir(email),
screensDir: join(getUserWorkspacesDir(email), 'screens'),
dashDir: getUserDashboardsDir(email),
screensDir: join(getUserDashboardsDir(email), 'screens'),
projDir: getUserProjectsDir(email),
};
}
+15 -15
View File
@@ -8,14 +8,12 @@ import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt';
import { readOcrConfig } from '@@/api/server-settings/ocr';
import { getUserSettings } from 'officerdb';
async function getUserTtsVoice(email: string): Promise<string | null> {
async function getUserTtsVoice(userId: number): Promise<string | null> {
try {
const settingsFile = Bun.file(getUserSettingsFile(email));
if (await settingsFile.exists()) {
const settings = (await settingsFile.json()) as { tts?: { voice?: string | null } };
return settings.tts?.voice ?? null;
}
const settings = await getUserSettings(userId) as { tts?: { voice?: string | null } };
return settings.tts?.voice ?? null;
} catch {}
return null;
}
@@ -387,7 +385,7 @@ router.post('/tts', async (ctx) => {
const ttsConfig = await readTtsConfig();
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
const userVoice = await getUserTtsVoice(user.email);
const userVoice = await getUserTtsVoice(user.id);
const voice = userVoice ?? ttsConfig.voice;
const userDataDir = getUserDataDir(user.email);
@@ -439,7 +437,7 @@ router.post('/tts-text', async (ctx) => {
const ttsConfig = await readTtsConfig();
if (!ttsConfig) throw errors.BAD_REQUEST('TTS not configured — set it up in Settings → Text to Speech');
const userVoice = await getUserTtsVoice(user.email);
const userVoice = await getUserTtsVoice(user.id);
const voice = userVoice ?? ttsConfig.voice;
const userDataDir = getUserDataDir(user.email);
@@ -906,16 +904,18 @@ router.post('/download-video', async (ctx) => {
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
const args = ['yt-dlp', '-o', '%(title)s.%(ext)s'];
await mkdir(absPath, { recursive: true });
const cookiesPath = join(DATA_PATH, '..', 'yt-dlp-cookies.txt');
const args = ['yt-dlp', '--js-runtimes', 'bun', '--cookies', cookiesPath, '-o', '%(title)s.%(ext)s'];
if (audioOnly) args.push('-x', '--audio-format', 'mp3');
args.push(url);
const proc = Bun.spawn(args, { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
const proc = Bun.spawn(args, { cwd: absPath, stdout: 'ignore', stderr: 'pipe' });
const stderrText = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'yt-dlp download failed');
throw errors.BAD_REQUEST(stderrText.trim() || 'yt-dlp download failed');
}
return ctx.json({ ok: true });
@@ -932,12 +932,12 @@ router.post('/git-clone', async (ctx) => {
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'ignore', stderr: 'pipe' });
const stderrText = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'git clone failed');
throw errors.BAD_REQUEST(stderrText.trim() || 'git clone failed');
}
return ctx.json({ ok: true });
+12 -2
View File
@@ -12,7 +12,7 @@ import {
} from 'officerdb';
const GOOGLE_SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://mail.google.com/',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/userinfo.email',
];
@@ -131,6 +131,15 @@ integrationsRouter.get('/google/status', async (ctx) => {
integrationsRouter.delete('/google/connection', async (ctx) => {
const user = ctx.get('user');
const connection = await getUserIntegration(user.id, 'google');
const connConfig = connection?.config as Record<string, unknown> | undefined;
// Revoke token at Google so the old grant is fully removed
const token = (connConfig?.refreshToken as string) || (connConfig?.accessToken as string);
if (token) {
fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, { method: 'POST' }).catch(() => {});
}
await deleteUserIntegration(user.id, 'google');
return ctx.json({ ok: true });
});
@@ -157,6 +166,7 @@ integrationsRouter.get('/google/authorize', async (ctx) => {
scope: GOOGLE_SCOPES.join(' '),
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'false',
state,
});
@@ -244,7 +254,7 @@ export const googleCallbackHandler = async (ctx: any) => {
// Auto-add /email to dock
try {
const existing = await getDockPaths(dbUser.id);
const paths = existing ?? ['/', '/files', '/automation', '/projects', '/workspaces', '/chat'];
const paths = existing ?? ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
if (!paths.includes('/email')) {
paths.push('/email');
+18 -37
View File
@@ -4,7 +4,7 @@ import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readSearxngConfig } from "../server-settings/searxng";
import { PI_CONFIG_DIR, DATA_PATH, SERVER_CONFIG_DIR, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { getServerIntegration, getUserIntegration } from "officerdb";
import { logger } from "./logger";
@@ -161,34 +161,6 @@ function buildResourcesEnv(): string {
return JSON.stringify(result);
}
async function ensureGoogleConfigFile(): Promise<string> {
const filePath = join(SERVER_CONFIG_DIR, 'google-oauth.json');
try {
const integration = await getServerIntegration('google');
if (integration?.config) {
mkdirSync(SERVER_CONFIG_DIR, { recursive: true });
writeFileSync(filePath, JSON.stringify(integration.config, null, 2));
}
} catch {
// No google config available
}
return filePath;
}
async function ensureGoogleTokenFile(userId: number, email: string): Promise<string> {
const dir = join(DATA_PATH, email, 'integrations');
const filePath = join(dir, 'google.json');
try {
const integration = await getUserIntegration(userId, 'google');
if (integration?.config) {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath, JSON.stringify(integration.config, null, 2));
}
} catch {
// No user google integration available
}
return filePath;
}
async function getApifyToken(): Promise<string> {
try {
@@ -217,6 +189,19 @@ async function getBrowserRelayEnv(userId: number): Promise<Record<string, string
}
}
async function resolveApiKeyForModel(model: string): Promise<string | null> {
const provider = model.split('/')[0];
if (!provider) return null;
try {
const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json'));
if (!(await authFile.exists())) return null;
const auth = await authFile.json() as Record<string, { key?: string }>;
return auth[provider]?.key?.trim() || null;
} catch {
return null;
}
}
type SandboxOptions = {
userId: number;
username: string;
@@ -270,10 +255,12 @@ export async function spawnPi(
if (model) piArgs.push('--model', model);
if (options?.sessionFile) piArgs.push('--session', options.sessionFile);
// Pass API key for the model's provider so the container doesn't need auth.json
const apiKey = await resolveApiKeyForModel(model);
if (apiKey) piArgs.push('--api-key', apiKey);
const resourcesEnv = buildResourcesEnv();
const googleConfigHost = await ensureGoogleConfigFile();
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
const apifyToken = await getApifyToken();
@@ -284,8 +271,6 @@ export async function spawnPi(
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
'-e', `PI_SEARXNG_URL=${searxng.url}`,
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []),
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
@@ -330,8 +315,6 @@ export async function spawnPi(
}
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const googleConfigPath = await ensureGoogleConfigFile();
const googleTokenPath = await ensureGoogleTokenFile(userId, email);
const browserRelayEnv = await getBrowserRelayEnv(userId);
const apifyTokenLocal = await getApifyToken();
@@ -349,8 +332,6 @@ export async function spawnPi(
PI_TOOLS_DIRS: toolsDirs,
PI_SEARXNG_URL: searxng.url,
OFFICER_RESOURCES: buildResourcesEnv(),
OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath,
OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}),
...browserRelayEnv,
+3
View File
@@ -533,6 +533,9 @@ export async function listUserSessions(baseCwd: string, filter?: SessionFilter):
if (filter.context === 'chat') {
// 'chat' matches sessions with no context or context='chat'
sessions = sessions.filter((s) => !s.context || s.context === 'chat');
} else if (filter.context === 'dashboard') {
// 'dashboard' matches both old 'workspace' and new 'dashboard' context
sessions = sessions.filter((s) => (s.context === 'dashboard' || s.context === 'workspace') && s.contextId === filter.contextId);
} else {
sessions = sessions.filter((s) => s.context === filter.context && s.contextId === filter.contextId);
}
@@ -350,6 +350,7 @@ export const PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode', piId: 'opencode' },
{ key: 'OpenCode Zen', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
@@ -414,6 +415,7 @@ const REMOTE_HEALTH_CONFIG: Record<string, {
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
};
+26 -37
View File
@@ -3,8 +3,8 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, SERVER_CONFIG_DIR, PI_CONFIG_DIR } from '@@/data-path';
import { getUsers, getServerIntegration, getUserIntegration } from 'officerdb';
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path';
import { getUsers } from 'officerdb';
const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; };
@@ -140,32 +140,6 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
const containerHome = `/home/${username}`;
// Write google-oauth config from DB to files for Docker mount
const googleConfigHost = join(SERVER_CONFIG_DIR, 'google-oauth.json');
let googleMounts: string[] = [];
try {
const googleIntegration = await getServerIntegration('google');
if (googleIntegration?.config) {
mkdirSync(SERVER_CONFIG_DIR, { recursive: true });
writeFileSync(googleConfigHost, JSON.stringify(googleIntegration.config, null, 2));
googleMounts = ['-v', `${googleConfigHost}:/officer/google-oauth.json:ro`];
}
} catch {
// No google config — skip mount
}
// Write per-user google token from DB for Docker mount
const userIntegrationsDir = join(DATA_PATH, email, 'integrations');
try {
const userGoogle = await getUserIntegration(userId, 'google');
if (userGoogle?.config) {
mkdirSync(userIntegrationsDir, { recursive: true });
writeFileSync(join(userIntegrationsDir, 'google.json'), JSON.stringify(userGoogle.config, null, 2));
}
} catch {
// No user google integration — skip
}
const run = Bun.spawnSync({
cmd: [
dockerPath,
@@ -196,8 +170,6 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
...googleMounts,
'-v', `${join(DATA_PATH, email, 'integrations')}:/officer/user/integrations:ro`,
'-v', `${join(DATA_PATH, email, 'emails.db')}:/officer/emails.db`,
'-w', containerHome,
tag,
@@ -207,7 +179,13 @@ const startDockerSidecar = async (port: number, homeDir: string, userId: number,
});
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
return { dockerId };
// Wait for entrypoint to finish (user creation, sidecar start)
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
if (await sidecarAlive(port)) return { dockerId };
}
throw new Error('Terminal sidecar did not start in time');
};
const stopDockerSidecar = (dockerId: string) => {
@@ -263,8 +241,19 @@ const dockerStart = (dockerId: string) => {
};
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
// Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root)
// Must check BEFORE mkdirSync overwrites them
const skillsDir = getUserSkillsDir(email);
let stale = false;
try {
const s = statSync(skillsDir);
if (s.uid === 0) stale = true;
} catch {
// doesn't exist yet — not stale, will be created below
}
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
mkdirSync(getUserSkillsDir(email), { recursive: true });
mkdirSync(skillsDir, { recursive: true });
mkdirSync(getUserToolsDir(email), { recursive: true });
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
@@ -278,9 +267,9 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
const existing = map[email];
if (existing && dockerContainerRunning(existing.dockerId)) {
// Recreate if resource mounts are missing (e.g. first run after feature was added)
if (!containerHasExpectedMounts(existing.dockerId)) {
console.log(`[terminal] recreating container for ${email}resource mounts missing`);
// Recreate if resource mounts are missing or data dir was recreated (stale mounts)
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
console.log(`[terminal] recreating container for ${email}mounts stale or missing`);
stopDockerSidecar(existing.dockerId);
} else {
console.log(`[terminal] reusing running container ${existing.dockerId} for ${email} on port ${existing.port}`);
@@ -289,7 +278,7 @@ export const ensureDockerContainer = async (email: string, userId: number, homeD
}
if (existing && dockerContainerExists(existing.dockerId)) {
if (!containerHasExpectedMounts(existing.dockerId)) {
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
stopDockerSidecar(existing.dockerId);
} else if (dockerStart(existing.dockerId)) {
return existing;
@@ -366,7 +355,7 @@ export const initTerminalSidecars = async () => {
mkdirSync(dirname(homeDir), { recursive: true });
mkdirSync(homeDir, { recursive: true });
try {
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
await ensureDockerContainer(user.email, user.id, homeDir, toShellUsername(user.username ?? '', user.email));
console.log(`[terminal] sidecar ready for ${user.email}`);
} catch (err) {
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
-3
View File
@@ -1,3 +0,0 @@
export type KeyMapping = { file: string; dir?: string };
export type ResolveDirs = { wsDir: string; screensDir: string; projDir: string };