Pi running inside the docker containers
This commit is contained in:
@@ -4,6 +4,7 @@ import { officerdb, count, Users } from 'officerdb';
|
|||||||
import { sign, verify } from '@@/jwt';
|
import { sign, verify } from '@@/jwt';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
||||||
import { validatePassword } from './validate-password';
|
import { validatePassword } from './validate-password';
|
||||||
|
|
||||||
export const bootstrapHandler: Handler = async function (ctx) {
|
export const bootstrapHandler: Handler = async function (ctx) {
|
||||||
@@ -59,5 +60,6 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
|||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
|
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
|
||||||
|
syncUserPiConfig(payload.email).catch(() => {});
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { officerdb, eq, and, Users, Passkeys } from 'officerdb';
|
import { officerdb, eq, and, Users, Passkeys } from 'officerdb';
|
||||||
import { sign } from '@@/jwt';
|
import { sign } from '@@/jwt';
|
||||||
import { getClaudeDir } from '@@/data-path';
|
import { getClaudeDir } from '@@/data-path';
|
||||||
|
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export const signinHandler: Handler = async function (ctx) {
|
|||||||
const { id, name, role } = dbUser;
|
const { id, name, role } = dbUser;
|
||||||
|
|
||||||
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
|
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
|
||||||
|
syncUserPiConfig(email).catch(() => {});
|
||||||
|
|
||||||
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
|
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
|
||||||
|
|
||||||
|
|||||||
@@ -6,26 +6,67 @@ import { logger } from "./logger";
|
|||||||
|
|
||||||
export type PiEventHandler = (event: PiEvent) => void;
|
export type PiEventHandler = (event: PiEvent) => void;
|
||||||
|
|
||||||
|
type SandboxOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTAINER_HOME = '/home/officer';
|
||||||
|
const CONTAINER_PI_CONFIG = '/home/officer/.pi/agent';
|
||||||
|
|
||||||
export async function spawnPi(
|
export async function spawnPi(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
model: string,
|
model: string,
|
||||||
onEvent: PiEventHandler
|
onEvent: PiEventHandler,
|
||||||
|
sandbox?: SandboxOptions,
|
||||||
): Promise<Subprocess> {
|
): Promise<Subprocess> {
|
||||||
const storedKeys = await readApiKeys();
|
let proc: Subprocess;
|
||||||
|
|
||||||
|
if (sandbox) {
|
||||||
|
const storedKeys = await readApiKeys();
|
||||||
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||||
|
const containerId = `officer-terminal-${sandbox.userId}`;
|
||||||
|
const piArgs = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||||
|
if (model) piArgs.push('--model', model);
|
||||||
|
|
||||||
|
// Build env flags: Pi config dir + all stored API keys
|
||||||
|
const envFlags = [
|
||||||
|
'-e', `PI_CODING_AGENT_DIR=${CONTAINER_PI_CONFIG}`,
|
||||||
|
'-e', `HOME=${CONTAINER_HOME}`,
|
||||||
|
];
|
||||||
|
for (const [key, value] of Object.entries(storedKeys)) {
|
||||||
|
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
proc = Bun.spawn([
|
||||||
|
dockerPath, 'exec', '-i',
|
||||||
|
'-w', CONTAINER_HOME,
|
||||||
|
...envFlags,
|
||||||
|
containerId,
|
||||||
|
...piArgs,
|
||||||
|
], {
|
||||||
|
stdin: 'pipe',
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
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 args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||||
if (model) args.push('--model', model);
|
if (model) args.push('--model', model);
|
||||||
|
|
||||||
const proc = Bun.spawn(args, {
|
proc = Bun.spawn(args, {
|
||||||
cwd,
|
cwd,
|
||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Read stdout JSON event stream (runs in background)
|
// Read stdout JSON event stream (runs in background)
|
||||||
const reader = proc.stdout.getReader();
|
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
||||||
|
const reader = stdout.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let streamBuffer = '';
|
let streamBuffer = '';
|
||||||
@@ -63,7 +104,8 @@ export async function spawnPi(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// Stderr → debug log
|
// Stderr → debug log
|
||||||
const stderrReader = proc.stderr.getReader();
|
const stderr = proc.stderr as ReadableStream<Uint8Array>;
|
||||||
|
const stderrReader = stderr.getReader();
|
||||||
const stderrDecoder = new TextDecoder();
|
const stderrDecoder = new TextDecoder();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type ClientMessage =
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
groupSlug?: string;
|
groupSlug?: string;
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
}
|
}
|
||||||
@@ -130,8 +131,10 @@ export type PiEvent =
|
|||||||
export type UserSession = {
|
export type UserSession = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
userId?: number;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
sandboxed?: boolean;
|
||||||
piProcess: any | null;
|
piProcess: any | null;
|
||||||
ws: any | null;
|
ws: any | null;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
|
|||||||
@@ -220,9 +220,9 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
|
|||||||
|
|
||||||
async function handleChat(
|
async function handleChat(
|
||||||
ws: ServerWebSocket<WSData>,
|
ws: ServerWebSocket<WSData>,
|
||||||
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; groupSlug?: string; attachmentIds?: string[] }
|
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[] }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { email } = ws.data;
|
const { email, userId } = ws.data;
|
||||||
const sessionId = msg.sessionId || randomUUID();
|
const sessionId = msg.sessionId || randomUUID();
|
||||||
|
|
||||||
// Use provided model, or fall back to user default, or use system default
|
// Use provided model, or fall back to user default, or use system default
|
||||||
@@ -251,18 +251,19 @@ async function handleChat(
|
|||||||
const cwd = msg.cwd || getHomeDir(email);
|
const cwd = msg.cwd || getHomeDir(email);
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
|
const sandboxed = msg.sandboxed ?? false;
|
||||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
|
||||||
|
session.sandboxed = sandboxed;
|
||||||
|
session.userId = userId;
|
||||||
sessionManager.attachWs(sessionId, ws);
|
sessionManager.attachWs(sessionId, ws);
|
||||||
wsToSessionMap.set(ws as any, sessionId);
|
wsToSessionMap.set(ws as any, sessionId);
|
||||||
|
|
||||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd });
|
sendToClient(ws, { type: 'session:init', sessionId, model, cwd });
|
||||||
|
|
||||||
// Spawn Pi process if not already running
|
|
||||||
if (!session.piProcess) {
|
if (!session.piProcess) {
|
||||||
try {
|
try {
|
||||||
const onEvent = createEventHandler(sessionId, model, cwd);
|
const onEvent = createEventHandler(sessionId, model, cwd);
|
||||||
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent);
|
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId } : undefined);
|
||||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd });
|
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||||
@@ -327,9 +328,10 @@ async function handleResume(
|
|||||||
// Spawn fresh Pi process if needed
|
// Spawn fresh Pi process if needed
|
||||||
if (!session.piProcess) {
|
if (!session.piProcess) {
|
||||||
try {
|
try {
|
||||||
|
const sandbox = session.sandboxed && session.userId ? { userId: session.userId } : undefined;
|
||||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
|
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
|
||||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent);
|
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
|
||||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model });
|
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { join } from 'node:path';
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '../../data-path';
|
||||||
import { syncLocalProvidersToPiConfig } from './sync-pi-config';
|
import { syncLocalProvidersToPiConfig } from './sync-pi-config';
|
||||||
|
import { syncAllUserPiConfigs } from './sync-user-pi-config';
|
||||||
import { invalidateModelCache } from '../pi/list-models';
|
import { invalidateModelCache } from '../pi/list-models';
|
||||||
|
|
||||||
export const piMonoRouter = createRouter();
|
export const piMonoRouter = createRouter();
|
||||||
|
|
||||||
const API_KEYS_FILE = join(DATA_PATH, 'pi_mono_api_keys.json');
|
const API_KEYS_FILE = join(DATA_PATH, 'pi_mono_api_keys.json');
|
||||||
const LOCAL_PROVIDERS_FILE = join(DATA_PATH, 'pi_mono_local_providers.json');
|
const LOCAL_PROVIDERS_FILE = join(DATA_PATH, 'pi_mono_local_providers.json');
|
||||||
|
const ACCESS_POLICY_FILE = join(DATA_PATH, 'pi_access_policy.json');
|
||||||
|
|
||||||
// --- Local provider types ---
|
// --- Local provider types ---
|
||||||
|
|
||||||
@@ -163,7 +165,25 @@ async function writeApiKeys(keys: Record<string, string>) {
|
|||||||
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
await Bun.write(API_KEYS_FILE, JSON.stringify(keys, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDERS: { key: string; env: string[] }[] = [
|
export type AccessPolicy = {
|
||||||
|
allowedModels: string[]; // e.g. ["anthropic:anthropic/claude-sonnet-4"]
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function readAccessPolicy(): Promise<AccessPolicy> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(ACCESS_POLICY_FILE);
|
||||||
|
if (!(await file.exists())) return { allowedModels: [] };
|
||||||
|
return (await file.json()) as AccessPolicy;
|
||||||
|
} catch {
|
||||||
|
return { allowedModels: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAccessPolicy(policy: AccessPolicy) {
|
||||||
|
await Bun.write(ACCESS_POLICY_FILE, JSON.stringify(policy, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDERS: { key: string; env: string[] }[] = [
|
||||||
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
|
{ key: 'OpenAI', env: ['OPENAI_API_KEY'] },
|
||||||
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
|
{ key: 'Google', env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] },
|
||||||
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
|
{ key: 'OpenCode Zen', env: ['OPENCODE_API_KEY'] },
|
||||||
@@ -216,6 +236,7 @@ piMonoRouter.put('/api-keys', async (ctx) => {
|
|||||||
}
|
}
|
||||||
await writeApiKeys(keys);
|
await writeApiKeys(keys);
|
||||||
invalidateModelCache();
|
invalidateModelCache();
|
||||||
|
syncAllUserPiConfigs().catch(() => {});
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,6 +325,7 @@ piMonoRouter.post('/local-providers', async (ctx) => {
|
|||||||
|
|
||||||
// Sync to Pi config so Pi knows about this provider
|
// Sync to Pi config so Pi knows about this provider
|
||||||
await syncLocalProvidersToPiConfig();
|
await syncLocalProvidersToPiConfig();
|
||||||
|
syncAllUserPiConfigs().catch(() => {});
|
||||||
|
|
||||||
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
return ctx.json({ ...provider, auth: provider.auth ? { type: provider.auth.type } : undefined });
|
||||||
});
|
});
|
||||||
@@ -317,10 +339,28 @@ piMonoRouter.delete('/local-providers/:id', async (ctx) => {
|
|||||||
|
|
||||||
// Sync to Pi config to remove this provider
|
// Sync to Pi config to remove this provider
|
||||||
await syncLocalProvidersToPiConfig();
|
await syncLocalProvidersToPiConfig();
|
||||||
|
syncAllUserPiConfigs().catch(() => {});
|
||||||
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Access policy ---
|
||||||
|
|
||||||
|
piMonoRouter.get('/access-policy', async (ctx) => {
|
||||||
|
const policy = await readAccessPolicy();
|
||||||
|
return ctx.json(policy);
|
||||||
|
});
|
||||||
|
|
||||||
|
piMonoRouter.put('/access-policy', async (ctx) => {
|
||||||
|
const body = await ctx.req.json<AccessPolicy>();
|
||||||
|
const policy: AccessPolicy = {
|
||||||
|
allowedModels: Array.isArray(body.allowedModels) ? body.allowedModels : [],
|
||||||
|
};
|
||||||
|
await writeAccessPolicy(policy);
|
||||||
|
await syncAllUserPiConfigs();
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
piMonoRouter.get('/local-providers/health', async (ctx) => {
|
piMonoRouter.get('/local-providers/health', async (ctx) => {
|
||||||
const providers = await readLocalProviders();
|
const providers = await readLocalProviders();
|
||||||
const results: Record<string, boolean> = {};
|
const results: Record<string, boolean> = {};
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { mkdir, copyFile } from 'node:fs/promises';
|
||||||
|
import { PI_CONFIG_DIR, getUserPiConfigDir } from '../../data-path';
|
||||||
|
import { readApiKeys, readAccessPolicy, PROVIDERS } from './pi-mono';
|
||||||
|
import { officerdb, Users } from 'officerdb';
|
||||||
|
|
||||||
|
const PI_MODELS_FILE = join(PI_CONFIG_DIR, 'models.json');
|
||||||
|
const PI_SETTINGS_FILE = join(PI_CONFIG_DIR, 'settings.json');
|
||||||
|
|
||||||
|
type PiModelConfig = {
|
||||||
|
providers: Record<string, {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
api: string;
|
||||||
|
models: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
reasoning: boolean;
|
||||||
|
input: string[];
|
||||||
|
contextWindow: number;
|
||||||
|
maxTokens: number;
|
||||||
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||||
|
}[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildFilteredConfigParams = {
|
||||||
|
appConfig: PiModelConfig;
|
||||||
|
allowedModels: string[];
|
||||||
|
apiKeys: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps lowercase provider names to their env var names.
|
||||||
|
* e.g. "openai" → ["OPENAI_API_KEY"], "google" → ["GOOGLE_API_KEY", "GEMINI_API_KEY"]
|
||||||
|
*/
|
||||||
|
function buildProviderEnvMap(): Record<string, string[]> {
|
||||||
|
const map: Record<string, string[]> = {};
|
||||||
|
for (const p of PROVIDERS) {
|
||||||
|
map[p.key.toLowerCase()] = p.env;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a filtered models.json config with API keys embedded.
|
||||||
|
* - If allowedModels is empty, all models pass through (same convention as enabledModels).
|
||||||
|
* - Parse policy key: "anthropic:anthropic/claude-sonnet-4" → provider "anthropic", model id "claude-sonnet-4"
|
||||||
|
* - Local providers (officer-local-*) pass through unchanged with keys already embedded.
|
||||||
|
*/
|
||||||
|
function buildFilteredConfig({ appConfig, allowedModels, apiKeys }: BuildFilteredConfigParams): PiModelConfig {
|
||||||
|
const envMap = buildProviderEnvMap();
|
||||||
|
const hasPolicy = allowedModels.length > 0;
|
||||||
|
|
||||||
|
// Pre-parse policy into a map: provider → Set<modelId>
|
||||||
|
const policyMap = new Map<string, Set<string>>();
|
||||||
|
if (hasPolicy) {
|
||||||
|
for (const key of allowedModels) {
|
||||||
|
const colonIdx = key.indexOf(':');
|
||||||
|
if (colonIdx === -1) continue;
|
||||||
|
const provider = key.slice(0, colonIdx);
|
||||||
|
const fullModelId = key.slice(colonIdx + 1);
|
||||||
|
// Strip "provider/" prefix to get the models.json model id
|
||||||
|
const slashIdx = fullModelId.indexOf('/');
|
||||||
|
const modelId = slashIdx !== -1 ? fullModelId.slice(slashIdx + 1) : fullModelId;
|
||||||
|
|
||||||
|
if (!policyMap.has(provider)) policyMap.set(provider, new Set());
|
||||||
|
policyMap.get(provider)!.add(modelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: PiModelConfig = { providers: {} };
|
||||||
|
|
||||||
|
for (const [providerId, providerConfig] of Object.entries(appConfig.providers)) {
|
||||||
|
// Local providers pass through unchanged
|
||||||
|
if (providerId.startsWith('officer-local-')) {
|
||||||
|
result.providers[providerId] = providerConfig;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerLower = providerId.toLowerCase();
|
||||||
|
|
||||||
|
// Filter models if policy is set
|
||||||
|
let models = providerConfig.models;
|
||||||
|
if (hasPolicy) {
|
||||||
|
const allowedSet = policyMap.get(providerLower);
|
||||||
|
if (!allowedSet) continue; // provider not in policy at all
|
||||||
|
models = models.filter((m) => allowedSet.has(m.id));
|
||||||
|
if (models.length === 0) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed API key
|
||||||
|
let apiKey = providerConfig.apiKey;
|
||||||
|
const envVars = envMap[providerLower];
|
||||||
|
if (envVars) {
|
||||||
|
for (const envVar of envVars) {
|
||||||
|
const value = apiKeys[envVar];
|
||||||
|
if (value?.trim()) {
|
||||||
|
apiKey = value.trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.providers[providerId] = { ...providerConfig, models, apiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAppModelsConfig(): Promise<PiModelConfig> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(PI_MODELS_FILE);
|
||||||
|
if (!(await file.exists())) return { providers: {} };
|
||||||
|
return (await file.json()) as PiModelConfig;
|
||||||
|
} catch {
|
||||||
|
return { providers: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncUserPiConfig(email: string): Promise<void> {
|
||||||
|
const [appConfig, policy, apiKeys] = await Promise.all([
|
||||||
|
readAppModelsConfig(),
|
||||||
|
readAccessPolicy(),
|
||||||
|
readApiKeys(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filtered = buildFilteredConfig({
|
||||||
|
appConfig,
|
||||||
|
allowedModels: policy.allowedModels,
|
||||||
|
apiKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userDir = getUserPiConfigDir(email);
|
||||||
|
await mkdir(userDir, { recursive: true });
|
||||||
|
await Bun.write(join(userDir, 'models.json'), JSON.stringify(filtered, null, 2));
|
||||||
|
|
||||||
|
// Copy settings.json from app-level Pi config
|
||||||
|
await copyFile(PI_SETTINGS_FILE, join(userDir, 'settings.json')).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncAllUserPiConfigs(): Promise<void> {
|
||||||
|
const users = await officerdb.select({ email: Users.email }).from(Users);
|
||||||
|
if (users.length === 0) return;
|
||||||
|
|
||||||
|
const [appConfig, policy, apiKeys] = await Promise.all([
|
||||||
|
readAppModelsConfig(),
|
||||||
|
readAccessPolicy(),
|
||||||
|
readApiKeys(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const filtered = buildFilteredConfig({
|
||||||
|
appConfig,
|
||||||
|
allowedModels: policy.allowedModels,
|
||||||
|
apiKeys,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configJson = JSON.stringify(filtered, null, 2);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
const userDir = getUserPiConfigDir(user.email);
|
||||||
|
await mkdir(userDir, { recursive: true });
|
||||||
|
await Bun.write(join(userDir, 'models.json'), configJson);
|
||||||
|
await copyFile(PI_SETTINGS_FILE, join(userDir, 'settings.json')).catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[sync] Failed to sync Pi config for ${user.email}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[bootstrap] Synced Pi config for ${users.length} user(s)`);
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ RUN apt-get update \
|
|||||||
python3 make gcc g++ zsh git curl wget ca-certificates \
|
python3 make gcc g++ zsh git curl wget ca-certificates \
|
||||||
sudo gosu locales \
|
sudo gosu locales \
|
||||||
zip unzip tree btop net-tools tmux \
|
zip unzip tree btop net-tools tmux \
|
||||||
|
procps psmisc lsof less file man-db \
|
||||||
|
ripgrep fd-find jq htop \
|
||||||
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
|
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
|
||||||
|
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
|
||||||
&& apt-get clean
|
&& apt-get clean
|
||||||
|
|
||||||
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
|
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
|
||||||
@@ -47,6 +50,8 @@ RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LA
|
|||||||
&& rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
|
&& rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
|
||||||
|
|
||||||
|
|
||||||
|
RUN npm install -g @mariozechner/pi-coding-agent
|
||||||
|
|
||||||
RUN mkdir -p /home/officer/Documents /home/officer/Downloads /home/officer/Music /home/officer/Videos /home/officer/Pictures /home/officer/Desktop /home/officer/Projects
|
RUN mkdir -p /home/officer/Documents /home/officer/Downloads /home/officer/Music /home/officer/Videos /home/officer/Pictures /home/officer/Desktop /home/officer/Projects
|
||||||
|
|
||||||
WORKDIR /home/officer
|
WORKDIR /home/officer
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdirSync, statSync } from 'node:fs';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir } from '@@/data-path';
|
||||||
|
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
|
||||||
import { officerdb, Users } from 'officerdb';
|
import { officerdb, Users } from 'officerdb';
|
||||||
|
|
||||||
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
||||||
@@ -274,6 +275,9 @@ export const initTerminalSidecars = async () => {
|
|||||||
mkdirSync(dirname(homeDir), { recursive: true });
|
mkdirSync(dirname(homeDir), { recursive: true });
|
||||||
mkdirSync(homeDir, { recursive: true });
|
mkdirSync(homeDir, { recursive: true });
|
||||||
try {
|
try {
|
||||||
|
await syncUserPiConfig(user.email).catch((err) => {
|
||||||
|
console.error(`[terminal] failed to sync Pi config for ${user.email}:`, err);
|
||||||
|
});
|
||||||
await ensureDockerContainer(user.email, user.id, homeDir);
|
await ensureDockerContainer(user.email, user.id, homeDir);
|
||||||
console.log(`[terminal] sidecar ready for ${user.email}`);
|
console.log(`[terminal] sidecar ready for ${user.email}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
|
||||||
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
|
||||||
|
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
|
||||||
|
|
||||||
mkdirSync(DATA_PATH, { recursive: true });
|
mkdirSync(DATA_PATH, { recursive: true });
|
||||||
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
mkdirSync(PI_CONFIG_DIR, { recursive: true });
|
||||||
@@ -69,4 +70,8 @@ function seedPiConfig(): void {
|
|||||||
await syncLocalProvidersToPiConfig().catch(err => {
|
await syncLocalProvidersToPiConfig().catch(err => {
|
||||||
console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
|
console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncAllUserPiConfigs().catch(err => {
|
||||||
|
console.error('[bootstrap] Failed to sync user Pi configs:', err);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const getArchivedSessionDir = (email: string, sessionId: string) =>
|
|||||||
|
|
||||||
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
|
||||||
|
|
||||||
|
export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent');
|
||||||
|
|
||||||
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');
|
export const getUserSettingsDir = (email: string) => join(DATA_PATH, email, 'settings');
|
||||||
|
|
||||||
export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'settings', 'settings.json');
|
export const getUserSettingsFile = (email: string) => join(DATA_PATH, email, 'settings', 'settings.json');
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type EmbeddableChatProps = {
|
|||||||
promptPrefix?: string;
|
promptPrefix?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
cwd?: { root?: string; path: string };
|
cwd?: { root?: string; path: string };
|
||||||
|
sandboxed?: boolean;
|
||||||
autoSend?: boolean;
|
autoSend?: boolean;
|
||||||
chat?: UsePiChatType;
|
chat?: UsePiChatType;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ type UseEmbeddableChatParams = {
|
|||||||
defaultInput?: string;
|
defaultInput?: string;
|
||||||
promptPrefix?: string;
|
promptPrefix?: string;
|
||||||
cwd?: { root?: string; path: string };
|
cwd?: { root?: string; path: string };
|
||||||
|
sandboxed?: boolean;
|
||||||
autoSend?: boolean;
|
autoSend?: boolean;
|
||||||
chat?: UsePiChatType;
|
chat?: UsePiChatType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
||||||
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
|
const { initialMessage, defaultInput = '', promptPrefix, cwd, sandboxed, autoSend = false, chat: externalChat } = params;
|
||||||
|
|
||||||
const internalChat = usePiChat(params.sessionId, params.initialModel);
|
const internalChat = usePiChat(params.sessionId, params.initialModel);
|
||||||
const chat = externalChat ?? internalChat;
|
const chat = externalChat ?? internalChat;
|
||||||
@@ -79,6 +80,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
|||||||
!sessionId && ids.length > 0 ? ids : undefined,
|
!sessionId && ids.length > 0 ? ids : undefined,
|
||||||
images.length > 0 ? images : undefined,
|
images.length > 0 ? images : undefined,
|
||||||
cwdForFirst,
|
cwdForFirst,
|
||||||
|
undefined,
|
||||||
|
sandboxed,
|
||||||
);
|
);
|
||||||
|
|
||||||
attachmentManager.clearAttachments();
|
attachmentManager.clearAttachments();
|
||||||
@@ -150,6 +153,8 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
|||||||
initialMessage.attachmentIds,
|
initialMessage.attachmentIds,
|
||||||
initialMessage.images,
|
initialMessage.images,
|
||||||
initialMessage.cwd,
|
initialMessage.cwd,
|
||||||
|
undefined,
|
||||||
|
sandboxed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [initialMessage, isConnected]);
|
}, [initialMessage, isConnected]);
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ function NewChat() {
|
|||||||
|
|
||||||
const chat = usePiChat(undefined, locationState?.model);
|
const chat = usePiChat(undefined, locationState?.model);
|
||||||
|
|
||||||
|
const sandboxed = cwdMode === 'user';
|
||||||
const cwd = cwdMode === 'host' ? { path: getHostHome() } : locationState?.cwd;
|
const cwd = cwdMode === 'host' ? { path: getHostHome() } : locationState?.cwd;
|
||||||
|
|
||||||
const initialMessage = locationState?.initialMessage
|
const initialMessage = locationState?.initialMessage
|
||||||
@@ -160,6 +161,7 @@ function NewChat() {
|
|||||||
initialMessage={initialMessage}
|
initialMessage={initialMessage}
|
||||||
defaultInput={locationState?.prefillInput ?? ''}
|
defaultInput={locationState?.prefillInput ?? ''}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
|
sandboxed={sandboxed}
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
|||||||
images?: { filename: string; dataUrl: string }[],
|
images?: { filename: string; dataUrl: string }[],
|
||||||
cwdParam?: { root?: string; path: string },
|
cwdParam?: { root?: string; path: string },
|
||||||
groupSlug?: string | null,
|
groupSlug?: string | null,
|
||||||
|
sandboxed?: boolean,
|
||||||
) {
|
) {
|
||||||
// Mark session as started on first message
|
// Mark session as started on first message
|
||||||
if (!hasStarted) {
|
if (!hasStarted) {
|
||||||
@@ -282,6 +283,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
|
|||||||
sessionId: sessionIdRef.current,
|
sessionId: sessionIdRef.current,
|
||||||
...(selectedModel ? { model: selectedModel } : {}),
|
...(selectedModel ? { model: selectedModel } : {}),
|
||||||
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
|
||||||
|
...(sandboxed !== undefined ? { sandboxed } : {}),
|
||||||
...(groupSlug !== undefined ? { groupSlug } : {}),
|
...(groupSlug !== undefined ? { groupSlug } : {}),
|
||||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||||
...(imageData?.length ? { images: imageData } : {}),
|
...(imageData?.length ? { images: imageData } : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user