Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/backend/src/api/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {workspacePackRoutes} from './routes/workspace-pack.js';
import {workspaceAppsRoutes} from './routes/workspace-apps.js';
import {tradingPaperRuntimeRoutes} from './routes/trading-paper-runtime.js';
import {tradingPaperAccountSetupRoutes} from './routes/trading-paper-account-setup.js';
import {tradingPaperInstallRoutes} from './routes/trading-paper-install.js';
Expand Down Expand Up @@ -362,6 +364,7 @@ export async function buildServer(options: BuildServerOptions = {}): Promise<Fas
await fastify.register(agentConnectRoutes);
await fastify.register(genvrRoutes);
await fastify.register(connectionsRoutes);
await fastify.register(workspaceAppsRoutes);
await fastify.register(memoryImportRoutes); // CLI-02: owner-only safe import (preview -> quarantine proposals)
await fastify.register(discoveryRoutes); // DISCOVER-01: what coding agents left on this computer (owner, this computer only)
await fastify.register(credentialsRoutes); // 🔐 Credentials System
Expand Down Expand Up @@ -396,6 +399,7 @@ export async function buildServer(options: BuildServerOptions = {}): Promise<Fas
await fastify.register(taskBoardRoutes); // BOARD-01: company task board
await fastify.register(terminalRoutes); // TERM-01: owner-only, loopback-only shell (off unless AGENT_TERMINAL=true)
await fastify.register(personalityLibraryRoutes);
await fastify.register(workspacePackRoutes);
await fastify.register(agentPersonalityRoutes); // CORE-01: owner-only versioned personality
await fastify.register(storageRoutes); // 📦 Local Storage (Cache + CDN)
await fastify.register(uploadsRoutes); // 🔒 Runtime uploads in .data/uploads (CL-05)
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/api/routes/workspace-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type {FastifyInstance} from 'fastify';
import {listWorkspaceApps,type WorkspaceAppsSources} from '../../services/workspace-apps.js';

export async function workspaceAppsRoutes(app:FastifyInstance,options:{sources?:WorkspaceAppsSources}={}){
app.get('/api/apps',{config:{access:'owner'}},async(request,reply)=>{
if(!request.authUser)return reply.code(401).send({code:'unauthenticated'});
if(request.authUser.role!=='owner')return reply.code(403).send({code:'forbidden'});
if(Object.keys(request.query as object).length)return reply.code(400).send({code:'invalid_input'});
reply.header('Cache-Control','no-store');
try{return await listWorkspaceApps(request.authUser.id,options.sources);}
catch{return reply.code(503).send({code:'apps_unavailable'});}
});
}
11 changes: 11 additions & 0 deletions packages/backend/src/api/routes/workspace-pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type {FastifyInstance} from 'fastify';
import {WorkspacePacks,PackError} from '../../services/workspace-pack.js';
const exact=(v:any,keys:string[])=>!!v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).length===keys.length&&keys.every(k=>Object.hasOwn(v,k));
export async function workspacePackRoutes(app:FastifyInstance,options:{store?:WorkspacePacks}={}){
const store=options.store??new WorkspacePacks();for(const action of ['export','preview','import'] as const)app.post('/api/workspace-pack/'+action,{bodyLimit:2_100_000,config:{access:'owner',rateLimit:{max:10,timeWindow:'1 minute'}}},async(req,reply)=>{
reply.header('Cache-Control','no-store');if(!req.authUser)return reply.code(401).send({code:'unauthenticated'});if(req.authUser.role!=='owner')return reply.code(403).send({code:'forbidden'});const b=req.body as any;if(Object.keys(req.query as object).length)return reply.code(400).send({code:'invalid_pack'});
try{if(action==='export'){if(!exact(b,['agents','personalities','specialties','skills'])||!Object.values(b).every(x=>typeof x==='boolean')||!Object.values(b).some(Boolean))throw new PackError('invalid_pack');return {pack:await store.export(b)};}
if(!exact(b,action==='preview'?['pack']:['pack','fingerprint','confirmed'])||action==='import'&&(b.confirmed!==true||typeof b.fingerprint!=='string'||!/^[a-f0-9]{64}$/.test(b.fingerprint)))throw new PackError('invalid_pack');return action==='preview'?await store.preview(b.pack):await store.import(req.authUser.id,b.pack,b.fingerprint);
}catch(error){return reply.code(error instanceof PackError?error.status:400).send({code:error instanceof PackError?error.code:'invalid_pack'});}
});
}
38 changes: 15 additions & 23 deletions packages/backend/src/services/personality-library-profile.ts

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions packages/backend/src/services/workspace-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {listServers,connectionState,type McpServerView} from '../mcp-client/index.js';
import {genvrConnection,type GenvrConnection} from './genvr-account.js';

export type WorkspaceApp = {
id:string; name:string; kind:'builtin'|'mcp'|'api'; version:string|null; summary:string;
state:'included'|'disabled'|'connected'|'connecting'|'disconnected'|'attention'|'not_connected'|'expired';
sourceId:string|null; permissions:string[]; tools:number; removable:boolean;
};
export const BUNDLED_APPS:ReadonlyArray<WorkspaceApp> = [
{id:'terminal',name:'Terminal',kind:'builtin',version:null,summary:'Command sessions on this computer.',state:'included',sourceId:null,permissions:['Host account access when you start a shell'],tools:0,removable:false},
{id:'trading',name:'Trading preview',kind:'builtin',version:null,summary:'Market terminal with sample data and reviewed paper setup.',state:'included',sourceId:null,permissions:['Paper setup requires owner review','Live orders are not connected'],tools:0,removable:false},
];
export interface WorkspaceAppsSources {
servers:()=>Promise<McpServerView[]>;
account:(ownerId:string)=>Promise<GenvrConnection>;
state:(serverId:string)=>string;
}
const defaults:WorkspaceAppsSources={servers:listServers,account:genvrConnection,state:connectionState};
/** A projection of existing registries. Reading this list never installs, connects or grants tools. */
export async function listWorkspaceApps(ownerId:string,sources:WorkspaceAppsSources=defaults):Promise<{items:WorkspaceApp[]}> {
const [servers,account]=await Promise.all([sources.servers(),sources.account(ownerId)]);
const items:WorkspaceApp[]=BUNDLED_APPS.map(x=>({...x,permissions:[...x.permissions]}));
items.push({id:'genvr',name:'GenVR',kind:'api',version:null,summary:'Virtual worlds with your existing GenVR account connection.',state:account.status==='error'?'attention':account.status,sourceId:'genvr',permissions:['Account access is reviewed in Connections','World publishing needs owner approval'],tools:0,removable:false});
const seen=new Set(items.map(x=>x.id));
for(const server of servers){
const id='mcp:'+server.id;
if(seen.has(id))continue;
seen.add(id);
const live=sources.state(server.id);
const state:WorkspaceApp['state']=!server.enabled?'disabled':!server.active||server.lastError?'attention':live==='connected'?'connected':live==='connecting'?'connecting':'disconnected';
items.push({id,name:server.name,kind:'mcp',version:null,summary:server.description||'Tools from a configured connection.',state,sourceId:server.id,permissions:['Tools require a separate grant for each agent',...(server.transport==='stdio'?['Local program launch requires owner approval']:['Connects to the configured service'])],tools:server.tools.length,removable:true});
}
return {items};
}
57 changes: 57 additions & 0 deletions packages/backend/src/services/workspace-pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {createHash} from 'node:crypto';
import {q,sql,one,run,lockName} from '../repositories/sql-steps.js';
import {PersonalityLibrary,type LibraryEntry} from './personality-library.js';
import {profile,validateProfile,type Profile} from './personality-library-profile.js';
import {getCurrentPersonality,exportPersonalityFile} from './personality-store.js';
import {insertAgentConfigStep} from '../repositories/agents-config.js';
import {CentralSkills,centralSkills} from '../skills/central-store.js';
import {SpecialtyTemplates} from './specialty-templates.js';
import {PERMISSION_CATEGORIES} from '../tools/agent-permissions.js';
import {redactImportText} from '../memory/import/redact.js';
import {prepareSkillMarkdown} from '../skills/markdown-bundle.js';
import {initializeAgentDir} from './agent-files.js';
export class PackError extends Error{constructor(readonly code:string,readonly status=400){super(code);}}
type Skill={id:string;name:string;files:{path:string;content:string}[]};
type Specialty={id:string;name:string;department:string;description:string;instructions:string;skills:string[]};
type Staff={id:string;name:string;description:string;role:'main'|'manager'|'specialist'|'evaluator'|null;department:string|null;model:string;provider:string;personality:string;skills:string[];specialties:string[]};
export type WorkspacePack={format:'dpro-workspace-pack';version:1;personalities:{id:string;profile:Profile}[];skills:Skill[];specialties:Specialty[];agents:Staff[]};
const object=(x:any)=>!!x&&typeof x==='object'&&!Array.isArray(x);
const exact=(x:any,keys:string[])=>object(x)&&Object.keys(x).length===keys.length&&keys.every(k=>Object.hasOwn(x,k));
const text=(x:any,n=1000)=>typeof x==='string'&&x.length<=n&&!/[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/.test(x);
const id=(x:any)=>typeof x==='string'&&/^[a-z][a-z0-9-]{0,63}$/.test(x);
const stable=(x:any):string=>JSON.stringify(x&&typeof x==='object'?(Array.isArray(x)?x.map(v=>JSON.parse(stable(v))):Object.fromEntries(Object.keys(x).sort().map(k=>[k,JSON.parse(stable(x[k]))]))):x);
const hash=(x:any)=>createHash('sha256').update(stable(x)).digest('hex');
function uid(x:string){const h=hash(x);return h.slice(0,8)+'-'+h.slice(8,12)+'-4'+h.slice(13,16)+'-a'+h.slice(17,20)+'-'+h.slice(20,32);}
export function validateWorkspacePack(raw:unknown):WorkspacePack{
const p=raw as WorkspacePack;if(!exact(p,['format','version','personalities','skills','specialties','agents'])||p.format!=='dpro-workspace-pack'||p.version!==1||Buffer.byteLength(JSON.stringify(p))>2_000_000)throw new PackError('invalid_pack');
for(const [key,max]of [['personalities',250],['skills',100],['specialties',300],['agents',100]] as const){if(!Array.isArray(p[key])||p[key].length>max||p[key].some(x=>!id(x?.id))||new Set(p[key].map(x=>x.id)).size!==p[key].length)throw new PackError('invalid_pack');}
const refs=(a:unknown,items:{id:string}[])=>Array.isArray(a)&&a.length<=100&&new Set(a).size===a.length&&a.every(v=>items.some(x=>x.id===v));
for(const x of p.personalities){if(!exact(x,['id','profile']))throw new PackError('invalid_pack');validateProfile(x.profile);}
for(const x of p.skills){if(!exact(x,['id','name','files'])||!text(x.name,100)||!Array.isArray(x.files)||x.files.length<1||x.files.length>200)throw new PackError('invalid_pack');const seen=new Set();for(const f of x.files){if(!exact(f,['path','content'])||!text(f.path,400)||f.path.split('/').some(k=>!k||k.startsWith('.')||k.includes('\\'))||!f.path.endsWith('.md')||seen.has(f.path)||!text(f.content,200000))throw new PackError('invalid_pack');seen.add(f.path);}const main=x.files.find(f=>f.path==='SKILL.md');if(!main||prepareSkillMarkdown(main.content,x.name).redactions)throw new PackError('invalid_pack');}
for(const x of p.specialties)if(!exact(x,['id','name','department','description','instructions','skills'])||!text(x.name,80)||!x.name.trim()||!text(x.department,64)||!x.department.trim()||!text(x.description,1800)||!text(x.instructions,30000)||!x.instructions.trim()||!refs(x.skills,p.skills))throw new PackError('invalid_pack');
for(const x of p.agents)if(!exact(x,['id','name','description','role','department','model','provider','personality','skills','specialties'])||!text(x.name,80)||!x.name.trim()||!text(x.description,1000)||![null,'main','manager','specialist','evaluator'].includes(x.role)||!(x.department===null||text(x.department,64))||!text(x.model,150)||!['claude','openai','gemini','ollama','lmstudio'].includes(x.provider)||!p.personalities.some(v=>v.id===x.personality)||!refs(x.skills,p.skills)||!refs(x.specialties,p.specialties))throw new PackError('invalid_pack');
const secret=(v:any):boolean=>typeof v==='string'?redactImportText(v).total>0:Array.isArray(v)?v.some(secret):object(v)?Object.values(v).some(secret):false;
if(secret(p))throw new PackError('secret_found');return structuredClone(p);
}
export class WorkspacePacks{
private busy=new Set<string>();constructor(private skills:CentralSkills=centralSkills(),private specialties=new SpecialtyTemplates(),private library=new PersonalityLibrary()){}
async export(selection:{agents:boolean;personalities:boolean;specialties:boolean;skills:boolean}){
const pack:WorkspacePack={format:'dpro-workspace-pack',version:1,personalities:[],skills:[],specialties:[],agents:[]},personalityIds=new Map<string,string>(),skillIds=new Map<string,string>(),specialtyIds=new Map<string,string>();
if(selection.personalities||selection.agents)for(const e of (await this.library.list()).filter(e=>!e.archived)){const id='personality-'+(pack.personalities.length+1);personalityIds.set(e.id,id);pack.personalities.push({id,profile:e.profile});}
if(selection.skills)for(const row of await this.skills.list()){const read=await this.skills.read(row.id),id='skill-'+(pack.skills.length+1);skillIds.set(row.id,id);pack.skills.push({id,name:row.name,files:read.files.map(f=>({...f}))});}
if(selection.specialties)for(const row of await q.all<{id:string}>('SELECT id FROM specialty_templates ORDER BY name')){const t=await this.specialties.get(row.id),id='specialty-'+(pack.specialties.length+1);specialtyIds.set(t.id,id);pack.specialties.push({id,name:t.name,department:t.department,description:t.description,instructions:t.instructions,skills:[...new Set([...(t.config.primarySkill?[t.config.primarySkill]:[]),...t.config.skills])].flatMap(id=>skillIds.has(id)?[skillIds.get(id)!]:[])});}
if(selection.agents)for(const row of await q.all<any>('SELECT id,name,description,role,department,model,provider FROM agents_config ORDER BY name')){const current=await getCurrentPersonality(row.id),assignment=await this.library.assignment(row.id);const effective=assignment?.profile??profile('Individual style',current?.traits,current?.dialect);let personality=pack.personalities.find(p=>stable(p.profile)===stable(effective))?.id;if(!personality){personality='personality-'+(pack.personalities.length+1);pack.personalities.push({id:personality,profile:effective});}const skills=await q.all<{skill_id:string}>('SELECT skill_id FROM central_agent_skills WHERE agent_id=?',row.id),specialties=await q.all<{template_id:string}>('SELECT template_id FROM agent_specialties WHERE agent_id=?',row.id);pack.agents.push({id:'agent-'+(pack.agents.length+1),name:row.name,description:row.description??'',role:row.role??null,department:row.department??null,model:row.model,provider:row.provider,personality,skills:skills.flatMap(s=>skillIds.has(s.skill_id)?[skillIds.get(s.skill_id)!]:[]),specialties:specialties.flatMap(s=>specialtyIds.has(s.template_id)?[specialtyIds.get(s.template_id)!]:[])});}
return validateWorkspacePack(pack);
}
async preview(raw:unknown){const p=validateWorkspacePack(raw);for(const skill of p.skills){const existing=await q.one<{id:string;content_hash:string}>('SELECT id,content_hash FROM central_skills WHERE name=?',skill.name);if(existing){const files=skill.files.map(f=>({path:f.path,content:f.content.replace(/\r\n?/g,'\n').trim()+'\n'})).sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);if(existing.content_hash!==createHash('sha256').update(JSON.stringify(files)).digest('hex'))throw new PackError('skill_name_conflict',409);}}
return {fingerprint:hash(p),counts:{agents:p.agents.length,personalities:p.personalities.length,specialties:p.specialties.length,skills:p.skills.length},names:{agents:p.agents.map(x=>x.name),personalities:p.personalities.map(x=>x.profile.name),specialties:p.specialties.map(x=>x.name),skills:p.skills.map(x=>x.name)}};
}
async import(owner:string,raw:unknown,fingerprint:string){const p=validateWorkspacePack(raw);if(hash(p)!==fingerprint)throw new PackError('preview_changed',409);if(this.busy.has(owner))throw new PackError('pack_busy',409);this.busy.add(owner);const seed=owner+':'+fingerprint,profiles=new Map<string,{id:string;revision:number}>(),skills=new Map<string,string>(),specialties=new Map<string,string>();let completed=0;
try{await this.preview(p);for(const x of p.skills){const r=await this.skills.installPortable(x.name,x.files);skills.set(x.id,r.skill.id);completed++;}
for(const x of p.personalities){let entry:LibraryEntry|undefined=(await this.library.list()).find(e=>!e.archived&&stable(e.profile)===stable(x.profile));const key='shared-'+uid(seed+':personality:'+x.id);if(!entry){try{entry=await this.library.read(key);}catch(e){if((e as any).code!=='not_found')throw e;entry=await this.library.save(owner,key,{revision:0,profile:x.profile});}}profiles.set(x.id,{id:entry.id,revision:entry.revision});completed++;}
for(const x of p.specialties){const key=uid(seed+':specialty:'+x.id);if(!await q.one('SELECT id FROM specialty_templates WHERE id=?',key))await this.specialties.save(key,{name:x.name,department:x.department,description:x.description,instructions:x.instructions,revision:0,config:{primarySkill:null,skills:x.skills.map(id=>skills.get(id)!),tools:[],memoryRead:[],memoryWrite:[],sharedMemory:false,modes:Object.fromEntries(PERMISSION_CATEGORIES.map(c=>[c,'ask']))}});specialties.set(x.id,key);completed++;}
const agentIds=[];for(const x of p.agents){const key='shared-'+uid(seed+':agent:'+x.id);agentIds.push(key);if(!await q.one('SELECT id FROM agents_config WHERE id=?',key)){const selected=profiles.get(x.personality)!;await sql().tx(function*(){yield* lockName('workspace-pack:'+owner);if(yield* one('SELECT id FROM agents_config WHERE id=?',key))return;yield* insertAgentConfigStep({id:key,name:(x.name.slice(0,65)+' '+uid(seed+':agent:'+x.id).slice(0,6)),language:null,body:{description:x.description,model:x.model,provider:x.provider,libraryPersonality:selected},role:x.role,department:x.department,explicit:null,userId:owner});yield* run('INSERT INTO central_skill_assignments(agent_id,revision) VALUES(?,1)',key);for(const ref of x.skills)yield* run('INSERT INTO central_agent_skills(agent_id,skill_id) VALUES(?,?)',key,skills.get(ref)!);for(const ref of x.specialties)yield* run('INSERT INTO agent_specialties(agent_id,template_id,linked_by) VALUES(?,?,?)',key,specialties.get(ref)!,owner);},{immediate:true});}await initializeAgentDir(key);const current=await getCurrentPersonality(key);if(current)await exportPersonalityFile(current);completed++;}
return {fingerprint,completed,agentIds,counts:{agents:p.agents.length,personalities:p.personalities.length,specialties:p.specialties.length,skills:p.skills.length}};
}catch(e){if(e instanceof PackError)throw e;throw new PackError(completed?'import_incomplete':'import_unavailable',503);}finally{this.busy.delete(owner);}
}
}
Loading
Loading