This commit is contained in:
2026-02-23 22:52:27 +00:00
parent 8fb96c7cf8
commit 2126f3912e
35 changed files with 1126 additions and 137 deletions
+5 -2
View File
@@ -14,8 +14,11 @@ dockRouter.get('/', async (ctx) => {
const file = Bun.file(filePath);
if (await file.exists()) {
const data = await file.json();
return ctx.json(data);
try {
return ctx.json(await file.json());
} catch {
// corrupted file — treat as missing
}
}
return ctx.json(null);
@@ -0,0 +1,218 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '@@/data-path';
import { CustomError } from '../../custom-errors';
const configDir = `${homedir()}/.config/officer.dev`;
const googleConfigPath = join(configDir, 'google-oauth.json');
const GOOGLE_SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/userinfo.email',
];
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
export const readGoogleConfig = async () => {
try {
return await Bun.file(googleConfigPath).json();
} catch {
return null;
}
};
const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json');
const readUserGoogle = async (email: string) => {
try {
return await Bun.file(getUserGoogleFile(email)).json();
} catch {
return null;
}
};
const writeUserGoogle = async (email: string, data: Record<string, unknown>) => {
const filePath = getUserGoogleFile(email);
await ensureDir(filePath);
await Bun.write(filePath, JSON.stringify(data, null, 2));
};
export const integrationsRouter = createRouter();
integrationsRouter.get('/', async (ctx) => {
return ctx.json([]);
});
// --- Enterprise: Google OAuth config (Super Admin only) ---
integrationsRouter.get('/google/config', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
return ctx.json(await readGoogleConfig());
});
integrationsRouter.put('/google/config', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
const body = ctx.get('body') as { clientId?: string; clientSecret?: string };
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
await ensureDir(googleConfigPath);
await Bun.write(googleConfigPath, JSON.stringify(config, null, 2));
return ctx.json(config);
});
integrationsRouter.get('/google/verify', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
const config = await readGoogleConfig();
if (!config?.clientId || !config?.clientSecret) {
return ctx.json({ valid: false, error: 'Missing credentials' });
}
// Send a dummy token exchange — valid credentials return "invalid_grant",
// invalid credentials return "invalid_client"
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code: 'invalid_code',
redirect_uri: 'https://localhost',
grant_type: 'authorization_code',
}),
});
const body = await res.json();
const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error });
});
// --- Personal: Google account connection status ---
integrationsRouter.get('/google/status', async (ctx) => {
const email = ctx.get('user').email;
const config = await readGoogleConfig();
const connection = await readUserGoogle(email);
return ctx.json({
configured: !!(config?.clientId && config?.clientSecret),
connected: !!connection?.accessToken,
email: connection?.email ?? null,
});
});
integrationsRouter.delete('/google/connection', async (ctx) => {
const email = ctx.get('user').email;
const filePath = getUserGoogleFile(email);
const file = Bun.file(filePath);
if (await file.exists()) {
await Bun.write(filePath, '{}');
}
return ctx.json({ ok: true });
});
// --- OAuth flow: authorize (protected — user must be logged in) ---
integrationsRouter.get('/google/authorize', async (ctx) => {
const config = await readGoogleConfig();
if (!config?.clientId || !config?.clientSecret) {
throw new CustomError('Google OAuth not configured', 400);
}
const email = ctx.get('user').email;
const origin = ctx.req.query('origin');
if (!origin) throw new CustomError('Missing origin parameter', 400);
const redirectUri = `${origin}/api/integrations/google/callback`;
const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url');
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: GOOGLE_SCOPES.join(' '),
access_type: 'offline',
prompt: 'consent',
state,
});
return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
});
// --- OAuth callback (public — called by Google, exported for hono.ts) ---
export const googleCallbackHandler = async (ctx: any) => {
const code = ctx.req.query('code');
const stateParam = ctx.req.query('state');
const error = ctx.req.query('error');
if (error || !code || !stateParam) {
return ctx.redirect('/settings/integrations?google=error');
}
let email: string;
let redirectUri: string;
try {
const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
email = parsed.email;
redirectUri = parsed.redirectUri;
} catch {
return ctx.redirect('/settings/integrations?google=error');
}
const config = await readGoogleConfig();
if (!config?.clientId || !config?.clientSecret) {
return ctx.redirect('/settings/integrations?google=error');
}
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenResponse.ok) {
console.error('Google token exchange failed:', await tokenResponse.text());
return ctx.redirect('/settings/integrations?google=error');
}
const tokens = await tokenResponse.json();
// Fetch the user's Google email
const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
let googleEmail = email;
if (userinfoResponse.ok) {
const userinfo = await userinfoResponse.json();
googleEmail = userinfo.email ?? email;
}
await writeUserGoogle(email, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
email: googleEmail,
scope: tokens.scope,
});
return ctx.redirect('/settings/integrations?google=success');
};
+30 -2
View File
@@ -1,13 +1,33 @@
import { join, relative } from "path";
import { readdirSync, existsSync, mkdirSync } from "node:fs";
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readApiKeys } from "../server-settings/pi-mono";
import { PI_CONFIG_DIR } from "../../data-path";
import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { logger } from "./logger";
export type PiEventHandler = (event: PiEvent) => void;
function collectSkillFlags(email: string): string[] {
const flags: string[] = [];
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
for (const dir of dirs) {
if (!existsSync(dir)) continue;
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillFile = join(dir, entry.name, 'SKILL.md');
if (existsSync(skillFile)) {
flags.push('--skill', join(dir, entry.name));
}
}
}
return flags;
}
type SandboxOptions = {
userId: number;
username: string;
@@ -18,6 +38,7 @@ type SandboxOptions = {
export async function spawnPi(
cwd: string,
model: string,
email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
): Promise<Subprocess> {
@@ -59,9 +80,14 @@ export async function spawnPi(
logger.info('Spawned Pi in container', { containerId, model });
} else {
const storedKeys = await readApiKeys();
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
const skillFlags = collectSkillFlags(email);
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags];
if (model) args.push('--model', model);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
proc = Bun.spawn(args, {
cwd,
stdin: 'pipe',
@@ -69,6 +95,8 @@ export async function spawnPi(
stderr: 'pipe',
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
});
logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length });
}
// Read stdout JSON event stream (runs in background)
+20 -18
View File
@@ -36,23 +36,25 @@ type WSData = {
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const resolveRoot = (email: string, root?: string) => {
if (!root || root === 'home') return getHomeDir(email);
if (root === '~') return homedir();
if (root === 'officer.dev') return resolve(process.cwd(), '..');
return getHomeDir(email);
const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => {
const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email);
if (!cwd || cwd === '~') return root;
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
if (cwd.startsWith('/')) return join(root, cwd.slice(1));
return root;
};
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
return home;
const resolveHostCwd = (cwdRoot?: string, cwd?: string) => {
if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..');
const root = homedir();
if (!cwd || cwd === '~') return root;
if (cwd.startsWith('/')) return cwd;
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
return join(root, cwd);
};
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
const root = resolveRoot(email, cwdRoot);
return resolveCwd(root, cwd);
return resolveHostCwd(cwdRoot, cwd);
};
const wsToSessionMap = new WeakMap<any, string>();
@@ -271,11 +273,11 @@ async function handleChat(
});
const homeDir = getHomeDir(email);
const rootDir = resolveRoot(email, msg.cwdRoot);
const cwd = resolveCwd(rootDir, msg.cwd);
const groupSlug = msg.groupSlug || null;
const sandboxed = msg.sandboxed ?? false;
const cwd = sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
session.sandboxed = sandboxed;
session.userId = userId;
@@ -286,7 +288,7 @@ async function handleChat(
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
@@ -355,7 +357,7 @@ async function handleResume(
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox);
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
@@ -34,19 +34,22 @@ serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter);
const readSettings = async () => {
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
};
serverSettingsRouter.get('/settings', async (ctx) => {
const settings = await Bun.file(settingsPath).json();
return ctx.json(settings);
return ctx.json(await readSettings());
});
serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
const settings = await Bun.file(settingsPath).json();
const settings = await readSettings();
return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
});
serverSettingsRouter.put('/', async (ctx) => {
const body = await ctx.req.json();
const settings = await Bun.file(settingsPath).json();
const settings = await readSettings();
const updated = { ...settings, ...body };
await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
return ctx.json(updated);
+8 -5
View File
@@ -34,7 +34,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
return ctx.json(await file.json());
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
}
if (provider === 'opencode') {
@@ -44,7 +44,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]);
return ctx.json(await file.json());
try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
}
return ctx.json({ error: 'invalid provider' }, 400);
@@ -78,7 +78,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json();
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
@@ -88,7 +89,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getOpencodeSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json();
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
@@ -106,7 +108,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getPiMonoSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json();
let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true });
+11 -5
View File
@@ -27,8 +27,11 @@ settingsRouter.get('/settings', async (ctx) => {
const file = Bun.file(filePath);
if (await file.exists()) {
const data = await file.json();
return ctx.json(data);
try {
return ctx.json(await file.json());
} catch {
// corrupted — fall through to defaults
}
}
await ensureDir(filePath);
@@ -54,8 +57,11 @@ settingsRouter.get('/state', async (ctx) => {
const file = Bun.file(filePath);
if (await file.exists()) {
const data = await file.json();
return ctx.json(data);
try {
return ctx.json(await file.json());
} catch {
// corrupted — fall through to empty
}
}
await ensureDir(filePath);
@@ -72,7 +78,7 @@ settingsRouter.patch('/state', async (ctx) => {
let existing: Record<string, unknown> = {};
if (await file.exists()) {
existing = await file.json();
try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
}
const merged = { ...existing, ...body };
+9 -4
View File
@@ -72,9 +72,13 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
}
export async function readJsonFile(path: string): Promise<unknown | null> {
const file = Bun.file(path);
if (await file.exists()) return file.json();
return null;
try {
const file = Bun.file(path);
if (!(await file.exists())) return null;
return await file.json();
} catch {
return null;
}
}
export async function writeJsonFile(path: string, data: unknown) {
@@ -86,7 +90,8 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
const file = Bun.file(stateFile);
if (!(await file.exists())) return;
const state = (await file.json()) as Record<string, unknown>;
let state: Record<string, unknown>;
try { state = (await file.json()) as Record<string, unknown>; } catch { return; }
const wsKeys = Object.keys(state).filter(
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
);