diff --git a/packages/backend/src/api/app.ts b/packages/backend/src/api/app.ts index 4b5101a1..958aae4a 100644 --- a/packages/backend/src/api/app.ts +++ b/packages/backend/src/api/app.ts @@ -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'; @@ -362,6 +364,7 @@ export async function buildServer(options: BuildServerOptions = {}): Promise 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 @@ -396,6 +399,7 @@ export async function buildServer(options: BuildServerOptions = {}): Promise{ + 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'});} + }); +} diff --git a/packages/backend/src/api/routes/workspace-pack.ts b/packages/backend/src/api/routes/workspace-pack.ts new file mode 100644 index 00000000..bccf50a4 --- /dev/null +++ b/packages/backend/src/api/routes/workspace-pack.ts @@ -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'});} + }); +} diff --git a/packages/backend/src/services/personality-library-profile.ts b/packages/backend/src/services/personality-library-profile.ts index d5d05254..13d0e5e3 100644 --- a/packages/backend/src/services/personality-library-profile.ts +++ b/packages/backend/src/services/personality-library-profile.ts @@ -1,4 +1,4 @@ -import {DEFAULT_TRAITS, MAIN_AGENT_V1, PRESETS, TRAIT_NAMES, isDialect, validateTraits, type Traits, type Dialect} from './personality.js'; +import {DEFAULT_TRAITS, TRAIT_NAMES, isDialect, validateTraits, type Traits, type Dialect} from './personality.js'; export type Profile = { name: string; description: string; archetype: string; strengths: string; blindSpots: string; @@ -26,28 +26,20 @@ export function validateProfile(raw: unknown): Profile { export function profile(name: string, traits: Traits = {...DEFAULT_TRAITS}, dialect: Dialect = 'en'): Profile { return {name,description:'A practical, adaptable working partner.',archetype:'Working partner',strengths:'Keeps work clear and accountable.',blindSpots:'Needs context for unfamiliar tasks.',bestFor:'Everyday collaboration',notFor:'Unreviewed high-impact decisions',samples:['I will check the facts and give you a clear next step.','This needs your approval before I can continue.'],traits:{...traits},dialect,temperament:{extraversion:50,emotionalStability:50},pace:'balanced',formality:'neutral',humour:'off',seniority:'senior',principles:{do:'State uncertainty and verify important claims.',never:'Invent evidence or bypass an approval.',ask:'Stop when required approval or essential information is missing.'}}; } -const extra: [string,string,number[],string,string,string,string,string[]][] = [ - ['calm-leader','Calm leader',[40,65,55,75,70,35,75,55,75],'lead','Decides calmly and delegates clearly.','Steady decisions; clear ownership.','May overlook creative disagreement.',['I will split this into clear tasks and review the result.','Let us pause, check the facts, and choose the next step.']], - ['friendly-support','Friendly support',[50,65,40,65,45,35,95,55,80],'senior','Patient help in short, clear replies.','Empathy; clear troubleshooting.','May spend too long reassuring.',['I understand the trouble. Let us try one step at a time.','That did not work. I will check another option with you.']], - ['persuasive-seller','Persuasive seller',[40,55,70,60,65,45,85,65,85],'senior','Explains value without pressure or invented claims.','Clear value; constructive follow-up.','May overemphasize benefits.',['Here is how this could help with your goal.','It may not fit your needs; let us check that first.']], - ['patient-teacher','Patient teacher',[60,65,60,85,40,75,90,75,85],'senior','Explains patiently, then checks understanding.','Examples; patient explanation.','May explain more than needed.',['Let us start with a small example.','Which part would you like to try yourself?']], - ['deep-researcher','Deep researcher',[85,70,65,95,60,85,55,95,90],'senior','Investigates deeply and distinguishes evidence from guesses.','Synthesis; source checking.','May explore beyond the useful stopping point.',['I found a plausible explanation, but it needs independent evidence.','Here is what is established and what remains uncertain.']], - ['guardian','Guardian',[85,95,25,90,30,45,50,60,85],'senior','Checks risks and asks before consequential action.','Careful boundaries; risk review.','May delay low-risk work.',['I will check the risk and the required approval first.','I cannot confirm that claim with the available evidence.']], - ['fast-doer','Fast doer',[45,45,45,65,85,20,55,40,75],'senior','Moves routine work forward with brief updates.','Initiative; concise delivery.','May miss context if the brief is incomplete.',['I will handle the reversible steps and report the result.','The routine work is done; this next step needs approval.']], - ['storyteller','Storyteller',[40,45,95,60,65,80,80,90,70],'senior','Builds vivid stories while separating fiction from facts.','Narrative; expressive ideas.','May add unnecessary detail.',['Imagine the scene opening on a quiet street.','That is a fictional detail; the factual version is simpler.']], - ['playful-companion','Playful companion',[45,55,85,50,50,45,95,90,60],'junior','Light, curious conversation with respectful boundaries.','Warmth; playful exploration.','Less suited to formal or high-stakes work.',['Want to explore a curious idea together?','Let us keep this light, and be clear about what we know.']], -]; -const legacyCards:Record= { - 'balanced-assistant':{description:'A warm, practical partner for everyday work.',strengths:'Adaptability; clear next steps.',blindSpots:'May need a specialist for deep domain work.',samples:['I will check the details and suggest a practical next step.','Here is a compact answer, with the uncertainty made clear.']}, - 'careful-auditor':{description:'Checks evidence, numbers and risks before concluding.',strengths:'Verification; systematic review.',blindSpots:'May be slower on simple tasks.',samples:['The total may be correct, but I need to reconcile the source figures.','This is an unverified claim; I will check it before drawing a conclusion.']}, - 'bold-creative':{description:'Explores original ideas and takes useful initiative.',strengths:'Novel approaches; broad exploration.',blindSpots:'May need a careful reviewer before delivery.',samples:['Here are three distinct directions, including one unusual option.','This idea is untested; I suggest a small reversible experiment.']}, - 'precise-developer':{description:'Delivers concise technical work with careful verification.',strengths:'Precision; persistence; clear technical detail.',blindSpots:'May under-explain background for newcomers.',samples:['I will reproduce the failure, make a focused change, and verify it.','The check passes; the remaining limitation is the untested environment.']}, - 'strict-evaluator':{description:'Questions assumptions and checks whether results meet the brief.',strengths:'Critical review; evidence standards.',blindSpots:'May understate useful progress.',samples:['The claim is not supported by the evidence provided.','The result meets this requirement, but the edge case is still unverified.']}, -}; +// Trait order is TRAIT_NAMES; temperament is independent of occupation and authority. export const STARTERS = [ - ...Object.entries(PRESETS).map(([id,p])=>({id,profile:{...profile(p.label,p.traits),...legacyCards[id],archetype:p.label,bestFor:legacyCards[id].description,notFor:legacyCards[id].blindSpots}})), - {id:'main-agent',profile:{...profile('Main agent',MAIN_AGENT_V1.traits,MAIN_AGENT_V1.dialect),description:'Careful, warm coordination with concise replies.',archetype:'Owner’s working partner',strengths:'Coordination; verification; warm communication.',blindSpots:'Asks for context when a decision is unclear.',bestFor:'Coordinating the team and talking with the owner',notFor:'Unapproved consequential action',samples:['I will organize the work, check what is missing, and keep you informed.','This step needs your approval before I proceed.']}}, - ...extra.map(([id,name,values,seniority,description,strengths,blindSpots,samples])=>({id,profile:{...profile(name,Object.fromEntries(TRAIT_NAMES.map((k,i)=>[k,values[i]])) as Traits),seniority:seniority as Profile['seniority'],description,strengths,blindSpots,samples,archetype:name,bestFor:description,notFor:blindSpots,pace:(id==='fast-doer'?'fast':id==='deep-researcher'||id==='guardian'?'deliberate':'balanced') as Profile['pace'],humour:(id==='playful-companion'?'playful':id==='storyteller'?'light':'off') as Profile['humour']}})), + {id:'temperament-balanced',profile:{...profile("Balanced",{doubt:50,caution:60,creativity:55,thoroughness:65,independence:55,verbosity:45,warmth:70,curiosity:60,persistence:65}),archetype:'Temperament',description:"Adapts between reflection, initiative and warm conversation.",strengths:"Flexible; measured; approachable.",blindSpots:"May need clearer preferences when priorities conflict.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:50,emotionalStability:75},pace:'balanced',formality:'neutral',humour:'light',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-calm',profile:{...profile("Calm",{doubt:55,caution:70,creativity:45,thoroughness:75,independence:55,verbosity:30,warmth:70,curiosity:50,persistence:80}),archetype:'Temperament',description:"Stays composed, speaks gently and avoids rushing.",strengths:"Composure; steady attention.",blindSpots:"May seem reserved or slow to express excitement.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:35,emotionalStability:95},pace:'deliberate',formality:'neutral',humour:'off',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-sociable',profile:{...profile("Sociable",{doubt:40,caution:50,creativity:65,thoroughness:55,independence:60,verbosity:65,warmth:90,curiosity:75,persistence:65}),archetype:'Temperament',description:"Initiates friendly conversation and expresses ideas openly.",strengths:"Social initiative; lively communication.",blindSpots:"May speak more than the situation needs.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:95,emotionalStability:75},pace:'balanced',formality:'casual',humour:'light',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-reflective',profile:{...profile("Reflective",{doubt:75,caution:70,creativity:70,thoroughness:85,independence:55,verbosity:45,warmth:55,curiosity:85,persistence:75}),archetype:'Temperament',description:"Thinks before replying and considers several interpretations.",strengths:"Thoughtfulness; introspection.",blindSpots:"May overthink simple choices.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:20,emotionalStability:80},pace:'deliberate',formality:'neutral',humour:'off',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-bold',profile:{...profile("Bold",{doubt:35,caution:35,creativity:80,thoroughness:55,independence:85,verbosity:35,warmth:60,curiosity:75,persistence:80}),archetype:'Temperament',description:"Expresses ideas confidently and explores reversible possibilities.",strengths:"Initiative; willingness to experiment.",blindSpots:"May move before enough context is available.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:75,emotionalStability:85},pace:'fast',formality:'neutral',humour:'light',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-cautious',profile:{...profile("Cautious",{doubt:85,caution:95,creativity:40,thoroughness:90,independence:35,verbosity:45,warmth:60,curiosity:65,persistence:75}),archetype:'Temperament',description:"Checks uncertainty and prefers measured, reversible steps.",strengths:"Care; careful boundaries.",blindSpots:"May hesitate over low-impact decisions.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:30,emotionalStability:75},pace:'deliberate',formality:'neutral',humour:'off',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-curious',profile:{...profile("Curious",{doubt:60,caution:55,creativity:90,thoroughness:65,independence:65,verbosity:60,warmth:70,curiosity:95,persistence:75}),archetype:'Temperament',description:"Asks questions and enjoys exploring unfamiliar ideas.",strengths:"Exploration; imaginative connections.",blindSpots:"May follow interesting tangents.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:65,emotionalStability:75},pace:'balanced',formality:'casual',humour:'light',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-patient',profile:{...profile("Patient",{doubt:60,caution:70,creativity:45,thoroughness:80,independence:45,verbosity:55,warmth:85,curiosity:65,persistence:95}),archetype:'Temperament',description:"Listens carefully and works through difficulties without rushing.",strengths:"Patience; persistence; tolerance for repetition.",blindSpots:"May continue explaining after enough detail is available.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:45,emotionalStability:95},pace:'deliberate',formality:'neutral',humour:'off',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-assertive',profile:{...profile("Assertive",{doubt:45,caution:60,creativity:55,thoroughness:75,independence:85,verbosity:30,warmth:55,curiosity:55,persistence:90}),archetype:'Temperament',description:"Communicates clearly and holds respectful, consistent boundaries.",strengths:"Clarity; decisiveness.",blindSpots:"May sound too direct without context.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:75,emotionalStability:90},pace:'balanced',formality:'neutral',humour:'off',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-warm',profile:{...profile("Warm",{doubt:50,caution:65,creativity:55,thoroughness:65,independence:45,verbosity:50,warmth:95,curiosity:65,persistence:80}),archetype:'Temperament',description:"Uses considerate language and acknowledges other perspectives.",strengths:"Kindness; approachable communication.",blindSpots:"May soften disagreement too much.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:75,emotionalStability:85},pace:'balanced',formality:'casual',humour:'light',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-playful',profile:{...profile("Playful",{doubt:45,caution:55,creativity:95,thoroughness:50,independence:55,verbosity:50,warmth:90,curiosity:90,persistence:65}),archetype:'Temperament',description:"Uses light humour and imaginative conversation when appropriate.",strengths:"Playfulness; spontaneity.",blindSpots:"Humour may not suit a serious moment.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:85,emotionalStability:80},pace:'balanced',formality:'casual',humour:'playful',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, + {id:'temperament-orderly',profile:{...profile("Orderly",{doubt:65,caution:80,creativity:40,thoroughness:95,independence:65,verbosity:40,warmth:55,curiosity:55,persistence:90}),archetype:'Temperament',description:"Prefers structure, clear sequences and consistent follow-through.",strengths:"Organisation; consistency.",blindSpots:"May prefer familiar structure over useful improvisation.",bestFor:'Any specialty; choose by preferred interaction style.',notFor:'Assigning expertise, professional seniority or permissions.',temperament:{extraversion:40,emotionalStability:85},pace:'deliberate',formality:'formal',humour:'off',samples:["Let us consider the situation and choose a useful next step.","I will keep the limits clear and ask when your decision is needed."]}}, ]; /** Editing aid, not a psychological assessment or a conversion of human test scores. */ export function bigFive(p: Profile) {return {openness:Math.round((p.traits.creativity+p.traits.curiosity)/2),conscientiousness:Math.round((p.traits.thoroughness+p.traits.persistence)/2),extraversion:p.temperament.extraversion,agreeableness:p.traits.warmth,emotionalStability:p.temperament.emotionalStability};} @@ -58,5 +50,5 @@ export function blend(a: Profile,b: Profile,weight: number): Profile { return validateProfile({...dominant,name:`${a.name} + ${b.name}`.slice(0,80),traits:Object.fromEntries(TRAIT_NAMES.map(k=>[k,mix(a.traits[k],b.traits[k])])),temperament:{extraversion:mix(a.temperament.extraversion,b.temperament.extraversion),emotionalStability:mix(a.temperament.emotionalStability,b.temperament.emotionalStability)},principles:{do:[a.principles.do,b.principles.do].filter(Boolean).join('\n').slice(0,2000),never:[a.principles.never,b.principles.never].filter(Boolean).join('\n').slice(0,2000),ask:[a.principles.ask,b.principles.ask].filter(Boolean).join('\n').slice(0,2000)}}); } export function profileInstructions(p: Profile): string { - return ['## Additional working preferences','These preferences never grant access or override system, company or owner rules.',`Experience: ${p.seniority}; ${p.seniority==='junior'?'check assumptions and ask for guidance on unfamiliar decisions':p.seniority==='lead'?'coordinate and propose decisions within assigned authority':'handle familiar reversible steps independently'}. Pace: ${p.pace}; speed never skips required checks.`,`Communication: ${p.formality}; humour ${p.humour}; keep uncertainty explicit.`,`Social initiative: ${p.temperament.extraversion<=33?'wait for the user':p.temperament.extraversion>=67?'offer a useful next step':'follow the conversation'}.`,`Under pressure: ${p.temperament.emotionalStability>=67?'stay calm and structured':'acknowledge uncertainty and pause when needed'}.`,'',`Do: ${p.principles.do}`,`Never: ${p.principles.never}`,`Ask: ${p.principles.ask}`,''].join('\n'); + return ['## Additional working preferences','These preferences never grant access or override system, company or owner rules.',`Pace: ${p.pace}; speed never skips required checks. Temperament does not establish expertise, rank or authority.`,`Communication: ${p.formality}; humour ${p.humour}; keep uncertainty explicit.`,`Social initiative: ${p.temperament.extraversion<=33?'wait for the user':p.temperament.extraversion>=67?'offer a useful next step':'follow the conversation'}.`,`Under pressure: ${p.temperament.emotionalStability>=67?'stay calm and structured':'acknowledge uncertainty and pause when needed'}.`,'',`Do: ${p.principles.do}`,`Never: ${p.principles.never}`,`Ask: ${p.principles.ask}`,''].join('\n'); } diff --git a/packages/backend/src/services/workspace-apps.ts b/packages/backend/src/services/workspace-apps.ts new file mode 100644 index 00000000..4881f01b --- /dev/null +++ b/packages/backend/src/services/workspace-apps.ts @@ -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 = [ + {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; + account:(ownerId:string)=>Promise; + 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}; +} diff --git a/packages/backend/src/services/workspace-pack.ts b/packages/backend/src/services/workspace-pack.ts new file mode 100644 index 00000000..10266a04 --- /dev/null +++ b/packages/backend/src/services/workspace-pack.ts @@ -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();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(),skillIds=new Map(),specialtyIds=new Map(); + 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('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.pathb.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(),skills=new Map(),specialties=new Map();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);} + } +} diff --git a/packages/backend/src/skills/central-store.ts b/packages/backend/src/skills/central-store.ts index d95087a8..5a3cfc72 100644 --- a/packages/backend/src/skills/central-store.ts +++ b/packages/backend/src/skills/central-store.ts @@ -74,6 +74,12 @@ export class CentralSkills { });return {skill:{id,name:b.name,description:b.description,version:b.version,revision:result,content_hash:b.hash,origin},duplicate:false}; });this.tail=work.catch(()=>undefined);return work; } + /** Import an explicitly reviewed Markdown-only bundle; never executes or installs packages. */ + async installPortable(name:string,files:{path:string;content:string}[]){ + if(!Array.isArray(files)||files.length<1||files.length>200)throw new SkillLibraryError('invalid_skill'); + const seen=new Set();let total=0;const clean=files.map(file=>{if(!file||!safeRelative(file.path)||file.path.split('/').some(p=>p.startsWith('.'))||!file.path.endsWith('.md')||seen.has(file.path)||typeof file.content!=='string'||Buffer.byteLength(file.content)>MAX_FILE||file.content.includes('\0'))throw new SkillLibraryError('invalid_skill');seen.add(file.path);const text=normalize(file.content);total+=Buffer.byteLength(text);if(total>MAX_BUNDLE||redactImportText(text).total)throw new SkillLibraryError('secret_refused');return {path:file.path,content:text};}).sort((a,b)=>a.pathb.path?1:0); + const main=clean.find(f=>f.path==='SKILL.md');if(!main)throw new SkillLibraryError('invalid_skill');const prepared=prepareSkillMarkdown(main.content,name);await this.initialize();return this.store({...prepared,files:clean,hash:hash(JSON.stringify(clean))},'shared-workspace'); + } async installMarkdown(content:string,name:string,origin:string){const prepared=prepareSkillMarkdown(content,name);await this.initialize();return this.store(prepared,origin);} async seedBundled(){await this.seedLegacy();await this.seedGenvrSdk();const directory=fileURLToPath(new URL('../../skills/',import.meta.url));for(const entry of await fs.readdir(directory,{withFileTypes:true}).catch(()=>[])){if(!entry.isDirectory())continue;try{const b=await bundle(path.join(directory,entry.name));if(!(await q.one('SELECT id FROM central_skills WHERE name=?',b.name)))await this.store(b,'bundled');}catch{/* Invalid optional bundled skills never block startup. */}}} /** Skills shipped inside the installed GenVR SDK (core-ui/vendor/genvr): added when new and updated diff --git a/packages/backend/test/personality-library.test.mjs b/packages/backend/test/personality-library.test.mjs index 0c767fcd..7e2a640b 100644 --- a/packages/backend/test/personality-library.test.mjs +++ b/packages/backend/test/personality-library.test.mjs @@ -20,37 +20,38 @@ const store=new PersonalityLibrary();await store.initialize(); const headers=id=>({authorization:'Bearer '+generateToken(id,1)}); const request=(method,url,payload,who='library-owner')=>app.inject({method,url,headers:headers(who),...(payload?{payload}:{})}); const base='/api/personality-library'; -async function agent(id){await sql().tx(function*(){yield* run("INSERT INTO agents_config(id,name) VALUES(?,?)",id,id);yield* seedLibraryPersonality(id,'library-owner',{id:'balanced-assistant',revision:1});},{immediate:true});} +async function agent(id){await sql().tx(function*(){yield* run("INSERT INTO agents_config(id,name) VALUES(?,?)",id,id);yield* seedLibraryPersonality(id,'library-owner',{id:'temperament-balanced',revision:1});},{immediate:true});} test('owner gates precede validation, including reads and writes',async()=>{ for(const who of ['library-admin','library-user'])for(const [method,url,payload]of [['GET',base],['PUT',base+'/invalid',{bad:true}],['POST',base+'/blend',{bad:true}],['POST',base+'/assigned/test',{bad:true}]])assert.equal((await request(method,url,payload,who)).statusCode,403); assert.equal((await app.inject({method:'GET',url:base})).statusCode,401); }); -test('15 starter profiles, exact legacy values and idempotent initialize',async()=>{ - const first=await store.list();assert.equal(first.length,15);const legacy=first.find(p=>p.id==='careful-auditor');assert.equal(legacy.profile.traits.doubt,85);assert.equal(first.find(p=>p.id==='main-agent').profile.dialect,'ar-IQ'); - const p=await store.read('friendly-support');p.profile.description='Saved edit';await store.save('library-owner',p.id,{revision:p.revision,profile:p.profile});await store.initialize();assert.equal((await store.read(p.id)).profile.description,'Saved edit'); +test('12 temperament starters are independent of occupations, valid and idempotent',async()=>{ + const first=await store.list();assert.equal(first.length,12);assert.equal(new Set(first.map(e=>e.id)).size,12);for(const e of first){assert.match(e.id,/^temperament-/);assert.equal(e.profile.archetype,'Temperament');validateProfile(e.profile);assert.equal(Object.keys(e.profile.traits).length,9);} + assert.equal(first.find(p=>p.id==='temperament-calm').profile.temperament.emotionalStability,95); + const p=await store.read('temperament-warm');p.profile.description='Saved edit';await store.save('library-owner',p.id,{revision:p.revision,profile:p.profile});await store.initialize();assert.equal((await store.read(p.id)).profile.description,'Saved edit'); }); test('linked changes bind the exact reviewed snapshot, preserve overrides, skip locked/copies',async()=>{ for(const id of ['linked-one','locked-one','copy-one'])await agent(id); await savePersonalityVersion('linked-one',{traits:{warmth:91},lockedTraits:['caution']},'library-owner'); await setPersonalityLocked('locked-one',true,'library-owner'); - await store.assign('library-owner','copy-one',{id:'balanced-assistant',revision:1,expectedVersion:1,linked:false}); - const e=await store.read('balanced-assistant');const draft=structuredClone(e.profile);draft.traits.creativity=95;draft.traits.caution=5;draft.traits.warmth=20; + await store.assign('library-owner','copy-one',{id:'temperament-balanced',revision:1,expectedVersion:1,linked:false}); + const e=await store.read('temperament-balanced');const draft=structuredClone(e.profile);draft.traits.creativity=95;draft.traits.caution=5;draft.traits.warmth=20; await assert.rejects(store.save('library-owner',e.id,{revision:e.revision,profile:draft}),/review_required/); const review=await store.preview(e.id,{revision:e.revision,profile:draft}); await savePersonalityVersion('linked-one',{traits:{persistence:80}},'library-owner'); await assert.rejects(store.save('library-owner',e.id,{revision:e.revision,profile:draft,confirmation:review.confirmation}),/review_required/); assert.equal((await store.read(e.id)).revision,1); const fresh=await store.preview(e.id,{revision:1,profile:draft});await store.save('library-owner',e.id,{revision:1,profile:draft,confirmation:fresh.confirmation}); - const p=await getCurrentPersonality('linked-one');assert.equal(p.traits.creativity,95);assert.equal(p.traits.warmth,91);assert.equal(p.traits.caution,55);assert.equal(p.traits.persistence,80); - assert.equal((await getCurrentPersonality('locked-one')).traits.creativity,50);assert.equal((await getCurrentPersonality('copy-one')).traits.creativity,50); + const p=await getCurrentPersonality('linked-one');assert.equal(p.traits.creativity,95);assert.equal(p.traits.warmth,91);assert.equal(p.traits.caution,e.profile.traits.caution);assert.equal(p.traits.persistence,80); + assert.equal((await getCurrentPersonality('locked-one')).traits.creativity,e.profile.traits.creativity);assert.equal((await getCurrentPersonality('copy-one')).traits.creativity,e.profile.traits.creativity); assert.equal((await store.assignment('locked-one')).revision,1);assert.equal((await store.assignment('linked-one')).revision,2); await assert.rejects(store.save('library-owner',e.id,{revision:1,profile:draft}),/version_conflict/); }); test('assignment is version fenced and never changes tool grants',async()=>{ const before=await getCurrentPersonality('copy-one');const grants=db.prepare('SELECT * FROM agent_tool_grants WHERE agent_id=?').all('copy-one'); - await assert.rejects(store.assign('library-owner','copy-one',{id:'guardian',revision:1,expectedVersion:999,linked:true}),/version_conflict/); + await assert.rejects(store.assign('library-owner','copy-one',{id:'temperament-cautious',revision:1,expectedVersion:999,linked:true}),/version_conflict/); assert.equal((await getCurrentPersonality('copy-one')).version,before.version); - await store.assign('library-owner','copy-one',{id:'guardian',revision:1,expectedVersion:before.version,linked:true}); + await store.assign('library-owner','copy-one',{id:'temperament-cautious',revision:1,expectedVersion:before.version,linked:true}); assert.deepEqual(db.prepare('SELECT * FROM agent_tool_grants WHERE agent_id=?').all('copy-one'),grants); assert.match((await libraryBehavior('copy-one')).promptBlock,/Additional working preferences/); }); @@ -60,15 +61,15 @@ test('blend is deterministic, bounded, preserves both principles and rejects per assert.throws(()=>blend(a,b,NaN));assert.throws(()=>validateProfile({...a,tools:['exec']})); }); test('archive is reversible and old links still read their snapshot',async()=>{ - const e=await store.read('guardian');await store.archive(e.id,{revision:e.revision,archived:true});await assert.rejects(store.read(e.id),/not_found/); - assert.equal((await store.assignment('copy-one')).id,'guardian');assert.match((await libraryBehavior('copy-one')).promptBlock,/Additional working preferences/); + const e=await store.read('temperament-cautious');await store.archive(e.id,{revision:e.revision,archived:true});await assert.rejects(store.read(e.id),/not_found/); + assert.equal((await store.assignment('copy-one')).id,'temperament-cautious');assert.match((await libraryBehavior('copy-one')).promptBlock,/Additional working preferences/); await store.archive(e.id,{revision:e.revision,archived:false});assert.equal((await store.read(e.id)).id,e.id); }); test('manual exams store exact profile revision and reject stale or invalid assessments',async()=>{ const b={revision:1,answers:['I will ask.','I will try another way.','I need approval.'],ratings:[4,3,4]}; - const saved=await request('POST',base+'/guardian/exams',b);assert.equal(saved.statusCode,200,saved.body);assert.equal(saved.json().exams[0].score,92); - assert.equal((await request('POST',base+'/guardian/exams',{...b,revision:999})).statusCode,409); - assert.equal((await request('POST',base+'/guardian/exams',{...b,ratings:[5,3,4]})).statusCode,400); + const saved=await request('POST',base+'/temperament-cautious/exams',b);assert.equal(saved.statusCode,200,saved.body);assert.equal(saved.json().exams[0].score,92); + assert.equal((await request('POST',base+'/temperament-cautious/exams',{...b,revision:999})).statusCode,409); + assert.equal((await request('POST',base+'/temperament-cautious/exams',{...b,ratings:[5,3,4]})).statusCode,400); }); test.after(async()=>{await app.close();closeDatabase();}); @@ -76,7 +77,7 @@ test('individual style overrides survive linked edits and rollback restores styl const initial=await getCurrentPersonality('linked-one');const assigned=await store.assignment('linked-one'); await store.tune('library-owner','linked-one',{expectedVersion:initial.version,profile:{...assigned.profile,formality:'formal',humour:'light'}}); assert.match((await libraryBehavior('linked-one')).promptBlock,/Communication: formal; humour light/); - const e=await store.read('balanced-assistant');const changed={...e.profile,pace:'fast'};const review=await store.preview(e.id,{revision:e.revision,profile:changed});await store.save('library-owner',e.id,{revision:e.revision,profile:changed,confirmation:review.confirmation}); + const e=await store.read('temperament-balanced');const changed={...e.profile,pace:'fast'};const review=await store.preview(e.id,{revision:e.revision,profile:changed});await store.save('library-owner',e.id,{revision:e.revision,profile:changed,confirmation:review.confirmation}); assert.match((await libraryBehavior('linked-one')).promptBlock,/Communication: formal; humour light/);assert.match((await libraryBehavior('linked-one')).promptBlock,/Pace: fast/); await rollbackPersonality('linked-one',initial.version,'library-owner');assert.doesNotMatch((await libraryBehavior('linked-one')).promptBlock,/Communication: formal; humour light/);assert.equal((await store.assignment('linked-one')).profile.traits.warmth,91); }); @@ -91,14 +92,14 @@ test('link mode preserves local overrides and remains version fenced',async()=>{ }); test('new library hires are atomic, reject stale selection and never grant tools',async()=>{ const {repositories}=await import('../src/repositories/index.ts');const {userActor}=await import('../src/repositories/actor.ts');const owner=userActor({id:'library-owner',role:'owner'}),admin=userActor({id:'library-admin',role:'admin'}); - const agent=await repositories.agents.createConfig(owner,{name:'Fictional Library Hire',role:'specialist',libraryPersonality:{id:'guardian',revision:1}}); - assert.equal((await store.assignment(agent.id)).id,'guardian');assert.equal((await getCurrentPersonality(agent.id)).traits.caution,95);assert.equal(db.prepare('SELECT COUNT(*) n FROM agent_tool_grants WHERE agent_id=?').get(agent.id).n,0); + const agent=await repositories.agents.createConfig(owner,{name:'Fictional Library Hire',role:'specialist',libraryPersonality:{id:'temperament-cautious',revision:1}}); + assert.equal((await store.assignment(agent.id)).id,'temperament-cautious');assert.equal((await getCurrentPersonality(agent.id)).traits.caution,95);assert.equal(db.prepare('SELECT COUNT(*) n FROM agent_tool_grants WHERE agent_id=?').get(agent.id).n,0); const before=db.prepare('SELECT COUNT(*) n FROM agents_config').get().n; - await assert.rejects(repositories.agents.createConfig(owner,{name:'Fictional Stale Hire',role:'specialist',libraryPersonality:{id:'guardian',revision:999}}),/version_conflict/);assert.equal(db.prepare('SELECT COUNT(*) n FROM agents_config').get().n,before); - await assert.rejects(repositories.agents.createConfig(admin,{name:'Fictional Admin Hire',role:'specialist',libraryPersonality:{id:'guardian',revision:1}}),/Only the owner/); + await assert.rejects(repositories.agents.createConfig(owner,{name:'Fictional Stale Hire',role:'specialist',libraryPersonality:{id:'temperament-cautious',revision:999}}),/version_conflict/);assert.equal(db.prepare('SELECT COUNT(*) n FROM agents_config').get().n,before); + await assert.rejects(repositories.agents.createConfig(admin,{name:'Fictional Admin Hire',role:'specialist',libraryPersonality:{id:'temperament-cautious',revision:1}}),/Only the owner/); }); test('batch hires persist the reviewed library selection across runs',async()=>{ - const {createBatch,runBatch}=await import('../src/services/team-batch.ts');const input={id:'library-test-batch',departments:[{department:'security',count:1,personalities:{manager:{id:'guardian',revision:1},specialist:{id:'guardian',revision:1}}}]}; - await createBatch('library-owner',input);const first=await runBatch('library-owner',input.id);assert.equal(first.progress.created,1);assert.equal((await store.assignment(first.results[0].agentId)).id,'guardian');assert.equal((await runBatch('library-owner',input.id)).results.length,0); - await assert.rejects(createBatch('library-owner',{...input,departments:[{...input.departments[0],personalities:{specialist:{id:'friendly-support',revision:2}}}]}),/different batch/); + const {createBatch,runBatch}=await import('../src/services/team-batch.ts');const input={id:'library-test-batch',departments:[{department:'security',count:1,personalities:{manager:{id:'temperament-cautious',revision:1},specialist:{id:'temperament-cautious',revision:1}}}]}; + await createBatch('library-owner',input);const first=await runBatch('library-owner',input.id);assert.equal(first.progress.created,1);assert.equal((await store.assignment(first.results[0].agentId)).id,'temperament-cautious');assert.equal((await runBatch('library-owner',input.id)).results.length,0); + await assert.rejects(createBatch('library-owner',{...input,departments:[{...input.departments[0],personalities:{specialist:{id:'temperament-warm',revision:2}}}]}),/different batch/); }); diff --git a/packages/backend/test/workspace-apps.test.mjs b/packages/backend/test/workspace-apps.test.mjs new file mode 100644 index 00000000..ea9c0b3f --- /dev/null +++ b/packages/backend/test/workspace-apps.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import {randomBytes} from 'node:crypto'; +const root=fs.mkdtempSync(path.join(os.tmpdir(),'workspace-apps-'));process.env.AGENT_PLAYER_DATA_DIR=root;process.env.DATABASE_PATH=path.join(root,'test.db');process.env.JWT_SECRET=randomBytes(64).toString('hex');process.env.ENCRYPTION_KEY=randomBytes(32).toString('hex');delete process.env.AGENT_EXEC_TOOLS; +const {getDatabase,closeDatabase}=await import('../src/db/database.ts');const db=getDatabase();await db.initialize(); +for(const [id,role]of [['apps-owner','owner'],['apps-admin','admin'],['apps-member','user']])db.prepare("INSERT INTO users(id,email,username,password_hash,role,status) VALUES(?,?,?,'fixture',?,'active')").run(id,id+'@example.invalid',id,role); +const {default:Fastify}=await import('fastify'),{registerAuthGate}=await import('../src/auth/auth-gate.ts'),{generateToken}=await import('../src/auth/jwt.ts'),{workspaceAppsRoutes}=await import('../src/api/routes/workspace-apps.ts'),{mcpServersRoutes}=await import('../src/api/routes/mcp-servers.ts'),{listWorkspaceApps}=await import('../src/services/workspace-apps.ts'); +const app=Fastify();registerAuthGate(app);await app.register(workspaceAppsRoutes);await app.register(mcpServersRoutes);await app.ready(); +const h=id=>({authorization:'Bearer '+generateToken(id,1)}),owner=h('apps-owner'); +const get=()=>app.inject({url:'/api/apps',headers:owner}); +test.after(async()=>{await app.close();closeDatabase();fs.rmSync(root,{recursive:true,force:true});}); +test('app inventory is owner-only and rejects query/mutation escape hatches',async()=>{ + assert.equal((await app.inject({url:'/api/apps'})).statusCode,401); + for(const id of ['apps-admin','apps-member'])assert.equal((await app.inject({url:'/api/apps',headers:h(id)})).statusCode,403); + assert.equal((await app.inject({url:'/api/apps?url=https://example.invalid',headers:owner})).statusCode,400); + assert.equal((await app.inject({method:'POST',url:'/api/apps',headers:owner,payload:{install:'anything'}})).statusCode,404); +}); +test('one inventory joins existing registries without duplicate GenVR or a second installation table',async()=>{ + const tables=()=>db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();await (await import('../src/services/genvr-account.ts')).genvrConnection('apps-owner');const before=tables(); + const result=await get();assert.equal(result.statusCode,200,result.body);assert.equal(result.headers['cache-control'],'no-store'); + assert.deepEqual(result.json().items.map(x=>[x.id,x.kind]),[['terminal','builtin'],['trading','builtin'],['genvr','api']]);assert.equal(result.json().items.find(x=>x.id==='genvr').state,'not_connected');assert.deepEqual(tables(),before); +}); +test('adding, duplicate refusal, disabling and removal reuse the actual MCP registry',async()=>{ + const created=await app.inject({method:'POST',url:'/api/mcp/servers',headers:owner,payload:{name:'sample-app',description:'A synthetic tool connection',transport:'http',url:'http://127.0.0.1:9/mcp'}}); + assert.equal(created.statusCode,201,created.body);const server=created.json();assert.equal(server.enabled,false); + const row=(await get()).json().items.find(x=>x.id==='mcp:'+server.id);assert.equal(row.kind,'mcp');assert.equal(row.state,'disabled');assert.equal(row.removable,true);assert.equal(row.sourceId,server.id); + const duplicate=await app.inject({method:'POST',url:'/api/mcp/servers',headers:owner,payload:{name:'sample-app',transport:'http',url:'http://127.0.0.1:9/mcp'}});assert.ok([400,409].includes(duplicate.statusCode));assert.equal((await get()).json().items.filter(x=>x.sourceId===server.id).length,1); + assert.equal((await app.inject({method:'POST',url:'/api/mcp/servers/'+server.id+'/disable',headers:owner})).statusCode,200); + assert.equal((await app.inject({method:'DELETE',url:'/api/mcp/servers/'+server.id,headers:owner})).statusCode,200);assert.ok(!(await get()).json().items.some(x=>x.sourceId===server.id)); +}); +test('inventory strips executable, URL, secret and account payloads and derives honest states',async()=>{ + const id='00000000-0000-0000-0000-000000000001';let live='connected',enabled=true,active=true,lastError=null; + const account={id:'genvr',status:'connected',account:{name:'PRIVATE-ACCOUNT'},scopes:[],pending:{userCode:'PRIVATE-CODE'},accessToken:'PRIVATE-TOKEN'}; + const source={servers:async()=>[{id,name:'fixture',description:'Tools',get enabled(){return enabled;},get active(){return active;},get lastError(){return lastError;},transport:'stdio',tools:[{}],command:'PRIVATE-COMMAND',env:{key:'PRIVATE-SECRET'},headers:{Authorization:'PRIVATE-HEADER'},url:'PRIVATE-URL'}],account:async user=>{assert.equal(user,'apps-owner');return account;},state:()=>live}; + let result=await listWorkspaceApps('apps-owner',source);assert.ok(!JSON.stringify(result).includes('PRIVATE-'));assert.equal(result.items.at(-1).state,'connected');assert.equal(result.items.at(-1).tools,1); + for(const [on,allowed,error,state]of [[false,true,null,'disabled'],[true,false,null,'attention'],[true,true,'failed','attention']]){enabled=on;active=allowed;lastError=error;assert.equal((await listWorkspaceApps('apps-owner',source)).items.at(-1).state,state);} + enabled=true;active=true;lastError=null;live='disconnected';assert.equal((await listWorkspaceApps('apps-owner',source)).items.at(-1).state,'disconnected'); +}); +test('a failed source is unavailable instead of fabricating a disconnected app list',async()=>{ + const unavailable=Fastify();unavailable.decorateRequest('authUser',null);unavailable.addHook('onRequest',async request=>{request.authUser={id:'apps-owner',role:'owner'};});await unavailable.register(workspaceAppsRoutes,{sources:{servers:async()=>{throw Error('PRIVATE-FAILURE');},account:async()=>({status:'not_connected'}),state:()=>''}}); + const r=await unavailable.inject({url:'/api/apps'});assert.equal(r.statusCode,503);assert.deepEqual(r.json(),{code:'apps_unavailable'});await unavailable.close(); +}); diff --git a/packages/backend/test/workspace-pack.test.mjs b/packages/backend/test/workspace-pack.test.mjs new file mode 100644 index 00000000..eb735d7a --- /dev/null +++ b/packages/backend/test/workspace-pack.test.mjs @@ -0,0 +1,14 @@ +import test,{after} from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import {randomUUID,randomBytes} from 'node:crypto'; +const root=fs.mkdtempSync(path.join(os.tmpdir(),'workspace-pack-'));process.env.AGENT_PLAYER_DATA_DIR=root;process.env.DATABASE_PATH=path.join(root,'test.db');process.env.ENCRYPTION_KEY=randomBytes(32).toString('hex');process.env.JWT_SECRET=randomBytes(64).toString('hex'); +const {getDatabase,closeDatabase}=await import('../src/db/database.ts');const db=getDatabase();await db.initialize();for(const [id,role]of [['pack-owner','owner'],['pack-user','user']])db.prepare("INSERT INTO users(id,email,username,password_hash,role,status) VALUES(?,?,?,'fixture',?,'active')").run(id,id+'@example.invalid',id,role); +const {WorkspacePacks,validateWorkspacePack}=await import('../src/services/workspace-pack.ts'),{CentralSkills}=await import('../src/skills/central-store.ts'),{SpecialtyTemplates}=await import('../src/services/specialty-templates.ts'),{PersonalityLibrary}=await import('../src/services/personality-library.ts');const {profile}=await import('../src/services/personality-library-profile.ts'); +const skills=new CentralSkills(root,path.join(root,'skill-library')),specialties=new SpecialtyTemplates(),library=new PersonalityLibrary();await skills.initialize();await library.initialize();const store=new WorkspacePacks(skills,specialties,library); +const pack={format:'dpro-workspace-pack',version:1,personalities:[{id:'profile-1',profile:profile('Fictional calm')}],skills:[{id:'skill-1',name:'pack-example',files:[{path:'SKILL.md',content:'---\nname: pack-example\ndescription: A fictional portable skill\n---\nUse docs/guide.md for guidance.\n'},{path:'docs/guide.md',content:'Only explain fictional examples.\n'}]}],specialties:[{id:'specialty-1',name:'Fictional examples',department:'education',description:'Explain examples',instructions:'Explain fictional examples clearly.',skills:['skill-1']}],agents:[{id:'agent-1',name:'Example Person',description:'Example configuration',role:'specialist',department:'education',model:'local-model',provider:'ollama',personality:'profile-1',skills:['skill-1'],specialties:['specialty-1']}]}; +const {default:Fastify}=await import('fastify'),{workspacePackRoutes}=await import('../src/api/routes/workspace-pack.ts'),{registerAuthGate}=await import('../src/auth/auth-gate.ts'),{generateToken}=await import('../src/auth/jwt.ts');const app=Fastify();registerAuthGate(app);await app.register(workspacePackRoutes,{store});await app.ready();const headers=id=>({authorization:'Bearer '+generateToken(id,1)}); +after(async()=>{await app.close();closeDatabase();fs.rmSync(root,{recursive:true,force:true});}); +test('workspace pack rejects unknown fields, secrets, executable paths and broken references',()=>{assert.deepEqual(validateWorkspacePack(pack),pack);for(const bad of [{...pack,apiKey:'secret'},{...pack,memories:[]},{...pack,agents:[{...pack.agents[0],personality:'missing'}]},{...pack,skills:[{...pack.skills[0],files:[{path:'../SKILL.md',content:'bad'}]}]},{...pack,skills:[{...pack.skills[0],files:[...pack.skills[0].files,{path:'run.js',content:'bad'}]}]}])assert.throws(()=>validateWorkspacePack(bad));const secret=structuredClone(pack);secret.agents[0].description='-----BEGIN PRIVATE KEY-----\nfixture\n-----END PRIVATE KEY-----';assert.throws(()=>validateWorkspacePack(secret));}); +test('owner gates precede export, validation and import',async()=>{for(const action of ['export','preview','import']){assert.equal((await app.inject({method:'POST',url:'/api/workspace-pack/'+action,payload:{bad:true}})).statusCode,401);assert.equal((await app.inject({method:'POST',url:'/api/workspace-pack/'+action,headers:headers('pack-user'),payload:{bad:true}})).statusCode,403);}}); +test('preview has no writes; confirmed import copies complete graph and never grants tools',async()=>{const before=db.prepare('select count(*) n from agents_config').get().n;const preview=await store.preview(pack);assert.equal(db.prepare('select count(*) n from agents_config').get().n,before);const result=await store.import('pack-owner',pack,preview.fingerprint);assert.equal(result.agentIds.length,1);const agent=result.agentIds[0];assert.equal((await library.assignment(agent)).profile.name,'Fictional calm');const read=await skills.read(db.prepare('select skill_id from central_agent_skills where agent_id=?').get(agent).skill_id);assert.equal(read.files.length,2);assert.equal(db.prepare('select count(*) n from agent_specialties where agent_id=?').get(agent).n,1);assert.equal(db.prepare('select count(*) n from agent_tool_grants where agent_id=?').get(agent).n,0);assert.equal(db.prepare('select is_primary from agents_config where id=?').get(agent).is_primary,0);const config=JSON.parse(db.prepare('select config_json from specialty_templates where id=(select template_id from agent_specialties where agent_id=?)').get(agent).config_json);assert.deepEqual(config.tools,[]);assert.deepEqual(config.memoryRead,[]);assert.ok(Object.values(config.modes).every(v=>v==='ask'));}); +test('reimport after store restart creates no duplicates and keeps local edits',async()=>{const a=db.prepare('select * from agents_config').all();db.prepare("update agents_config set description='Local edit' where id=?").run(a.find(x=>x.id.startsWith('shared-')).id);const restart=new WorkspacePacks(skills,specialties,library),preview=await restart.preview(pack);const result=await restart.import('pack-owner',pack,preview.fingerprint);assert.equal(db.prepare('select count(*) n from agents_config').get().n,a.length);assert.equal(db.prepare('select description from agents_config where id=?').get(result.agentIds[0]).description,'Local edit');assert.equal(db.prepare('select count(*) n from specialty_templates').get().n,1);}); +test('export roundtrip omits credentials, grants, conversations and memory, and retains markdown attachments',async()=>{const exported=await store.export({agents:true,personalities:true,specialties:true,skills:true});validateWorkspacePack(exported);assert.equal(exported.agents.length,2);assert.equal(exported.skills[0].files.length,2);for(const field of ['api_key','password_hash','credentials','chat_messages','memories','permissions'])assert.ok(!JSON.stringify(exported).includes('"'+field+'"'));const result=await app.inject({method:'POST',url:'/api/workspace-pack/export',headers:headers('pack-owner'),payload:{agents:true,personalities:true,specialties:true,skills:true}});assert.equal(result.statusCode,200,result.body);}); +test('tampered confirmation and conflicting skill refuse before new agents',async()=>{const preview=await store.preview(pack),before=db.prepare('select count(*) n from agents_config').get().n;await assert.rejects(store.import('pack-owner',{...pack,agents:[]},preview.fingerprint),/preview_changed/);const conflict=structuredClone(pack);conflict.skills[0].files[0].content+='Different version\n';await assert.rejects(store.preview(conflict),/skill_name_conflict/);assert.equal(db.prepare('select count(*) n from agents_config').get().n,before);}); diff --git a/packages/core-ui/scripts/application-server.cjs b/packages/core-ui/scripts/application-server.cjs index 2b3384e2..e5bf6f34 100644 --- a/packages/core-ui/scripts/application-server.cjs +++ b/packages/core-ui/scripts/application-server.cjs @@ -1,3 +1,4 @@ +const {packRoute,allowedPack,packResponse}=require('../src/workspace-pack-contract.js'); const {allowedLibrary,libraryPath,libraryResponse}=require('../src/personality-library-contract.js'); const {tradingRuntimeRoute,allowedTradingRuntime,tradingRuntimeResponse}=require('../src/trading-runtime-contract.js'); const {tradingAccountSetupRoute,allowedTradingAccountSetup,tradingAccountSetupResponse}=require('../src/trading-account-setup-contract.js'); @@ -11,7 +12,7 @@ const {allowedVerification,verificationRoute,verificationResponse}=require('../s const {allowedReviewer,reviewerRoute,reviewerResponse}=require('../src/reviewers-contract.js'); const {coverageRoute,allowedCoverage,coverageResponse}=require('../src/security-coverage-contract.js'); const {schedulerRoute,allowedScheduler,schedulerResponse}=require('../src/scheduler-contract.js'); -const {sourceCatalogueRoute,allowedSourceCatalogue,sourceCatalogueResponse,appsCatalogueRoute,allowedAppsCatalogue,appsCatalogueResponse}=require('../src/apps-contract.js'); +const {workspaceAppsRoute,allowedWorkspaceApps,workspaceAppsResponse,sourceCatalogueRoute,allowedSourceCatalogue,sourceCatalogueResponse,appsCatalogueRoute,allowedAppsCatalogue,appsCatalogueResponse}=require('../src/apps-contract.js'); const {chatAddonsRoute,allowedChatAddons,chatAddonsResponse}=require('../src/chat-addons-contract.js'); const {approvalRoute,allowedApproval,approvalResponse}=require('../src/approvals-contract.js'); const { allowedConnections, connectionsRoute, connectionsResponse } = require('../src/connections-contract.js'); @@ -79,6 +80,7 @@ const agent = x => { : { id: x.id, version: x.version, nameUnavailable: true, ...profile(x) }; }; function allowed(method, target, body) { + if(packRoute(target))return allowedPack(method,target,body); if(tradingRuntimeRoute(target))return allowedTradingRuntime(method,target,body); if(tradingAccountSetupRoute(target))return allowedTradingAccountSetup(method,target,body); if(tradingInstallRoute(target))return allowedTradingInstall(method,target,body); @@ -92,6 +94,7 @@ function allowed(method, target, body) { if(tradingPaperRoute(target))return allowedTradingPaper(method,target,body); if(schedulerRoute(target))return allowedScheduler(method,target,body); if(coverageRoute(target))return allowedCoverage(method,target,body); + if(workspaceAppsRoute(target))return allowedWorkspaceApps(method,target,body); if(appsCatalogueRoute(target))return allowedAppsCatalogue(method,target,body); if(chatAddonsRoute(target))return allowedChatAddons(method,target,body); if (approvalRoute(target)) return allowedApproval(method,target,body); @@ -144,6 +147,7 @@ function stage1Route(method, target) { return read && allowed('GET', target, undefined); } function project(target, status, body) { + if(packRoute(target))return packResponse(target,status,body); if(tradingRuntimeRoute(target))return tradingRuntimeResponse(target,status,body); if(tradingAccountSetupRoute(target))return tradingAccountSetupResponse(target,status,body); if(tradingInstallRoute(target))return tradingInstallResponse(target,status,body); @@ -157,6 +161,7 @@ function project(target, status, body) { if(tradingPaperRoute(target))return tradingPaperResponse(target,status,body); if(schedulerRoute(target))return schedulerResponse(target,status,body); if(coverageRoute(target))return coverageResponse(status,body); + if(workspaceAppsRoute(target))return workspaceAppsResponse(status,body); if(appsCatalogueRoute(target))return appsCatalogueResponse(status,body); if(chatAddonsRoute(target))return chatAddonsResponse(status,body); if (target === '/api/setup/status') return setupStatus(status, body); @@ -313,7 +318,7 @@ function createApplicationServer({ packageRoot = root, channel, stage1ReadOnly = if ((login || discovery) ? auth !== undefined : typeof auth !== 'string' || auth.length > 8200 || !/^Bearer [A-Za-z0-9._~-]+$/.test(auth)) return send(401); const length = Number(req.headers['content-length'] || 0); const audio = req.method === 'POST' && raw === '/api/chat/transcribe'; - const limit = genvrRoute(raw) ? GENVR_BYTES + 65536 : audio ? AUDIO_LIMIT : LIMIT; + const limit = packRoute(raw) ? 2_100_000 : genvrRoute(raw) ? GENVR_BYTES + 65536 : audio ? AUDIO_LIMIT : LIMIT; if (!Number.isSafeInteger(length) || length < 0 || length > limit) return send(413); if (req.headers['content-type'] && !/^application\/json(?:;\s*charset=utf-8)?$/i.test(req.headers['content-type'])) return send(415); try { diff --git a/packages/core-ui/src/AgentCard.tsx b/packages/core-ui/src/AgentCard.tsx index 3c50e3ab..623ffe21 100644 --- a/packages/core-ui/src/AgentCard.tsx +++ b/packages/core-ui/src/AgentCard.tsx @@ -2,7 +2,7 @@ import {AgentPersonalityLibrary} from './AgentPersonalityLibrary'; import {createPortal} from 'react-dom'; import {Avatar} from './desktop-ui'; import {DocumentEditor} from './DocumentEditor'; -import {AgentSpecialties} from './AgentSpecialties'; +import {AgentConfigurationSummary} from './AgentConfigurationSummary'; import { AgentMcp } from './McpLibrary'; import { AgentSkills } from './SkillLibrary'; import { useEffect, useState, useSyncExternalStore } from 'react'; @@ -12,7 +12,6 @@ import type { AgentsController } from './agents'; import type { PersonalityController } from './personality'; import { PersonalityReview } from './PersonalityReview'; import { permissionCategories, drawerKinds } from './hire-contract.js'; -import type { TraitName } from './agent-card-contract.js'; import { drawerLabel } from './MemoryViews'; import { AgentMemoryController, handoffLine } from './agent-memory'; import { ago, oneLine } from './memory-store'; @@ -25,11 +24,6 @@ const TABS: { key: CardTab; label: string }[] = [ ]; const VIEWS: { label: string; tabs: CardTab[] }[] = [{ label: 'Profile', tabs: ['profile', 'persona'] }, { label: 'Memory', tabs: ['memory'] }, { label: 'Skills and tools', tabs: ['skills', 'mcp', 'permissions'] }]; const ROLE_LABEL: Record = { '': 'No role', main: 'Main agent', manager: 'Manager', specialist: 'Specialist', evaluator: 'Evaluator' }; -const TRAIT: Record = { - doubt: ['Confidence', 'Checks everything', 'Trusts quickly'], caution: ['Caution', 'Bold', 'Careful'], creativity: ['Creativity', 'Conventional', 'Inventive'], - thoroughness: ['Thoroughness', 'Quick', 'Thorough'], independence: ['Independence', 'Asks often', 'Decides alone'], verbosity: ['Length', 'Short', 'Detailed'], - warmth: ['Warmth', 'Neutral', 'Warm'], curiosity: ['Curiosity', 'Focused', 'Explores'], persistence: ['Persistence', 'Gives up early', 'Keeps trying'], -}; export const CATEGORY: Record = { browser: 'Use the browser', network: 'Reach the internet', message: 'Send messages', delete: 'Delete things', install: 'Install software', money: 'Spend money', settings: 'Change settings' }; const DIALECT: Record = { 'ar-IQ': 'Arabic (Iraqi)', ar: 'Arabic', en: 'English', de: 'German', fr: 'French', es: 'Spanish', zh: 'Chinese', ja: 'Japanese', ko: 'Korean', 'pt-BR': 'Portuguese (Brazil)', ru: 'Russian', tr: 'Turkish', hi: 'Hindi' }; @@ -60,7 +54,7 @@ export function CardView({ card, agents, personality, onBack, onOpenMemory, side {s.notice &&

{s.notice}

} {s.saved &&

{s.saved}

} {s.phase === 'error' ? null : <> - {s.tab === 'profile' && <>} + {s.tab === 'profile' && <>void card.openTab('persona')} onSkills={()=>void card.openTab('skills')}/>} {s.tab === 'persona' && } {s.tab === 'permissions' && } {s.tab === 'mcp' && <>} @@ -105,36 +99,13 @@ function ProfileTab({ card, agents, busy }: { card: AgentCardController; agents? function PersonaTab({ card, personality, busy }: { card: AgentCardController; personality?: PersonalityController; busy: boolean }) { const s = useSyncExternalStore(card.subscribe, card.getSnapshot, card.getSnapshot); - const [note, setNote] = useState(''); - const p = s.persona, d = s.personaDraft; - if (!p || !d) return

The persona could not be loaded.

; + const p = s.persona; + if (!p) return

The persona could not be loaded.

; const locked = p.locked; return
void card.load()}/> -

Fine-tune this agent below. The library personality stays unchanged.

-
{p.traitNames.map(t => { - const [label, low, high] = TRAIT[t]; - return ; - })}
-
- - -
-

The persona shapes how the agent answers and when it asks first. It never gives tools or permissions.

-
- - -
+

Choose a library personality above. Use Fine-tune work style to adjust this agent's weights in one place. Personality never grants tools or permissions.

+
{s.versions && s.versions.length > 0 &&

History

    {s.versions.map(v =>
  1. diff --git a/packages/core-ui/src/AgentConfigurationSummary.tsx b/packages/core-ui/src/AgentConfigurationSummary.tsx new file mode 100644 index 00000000..f259eafa --- /dev/null +++ b/packages/core-ui/src/AgentConfigurationSummary.tsx @@ -0,0 +1,11 @@ +import {useEffect,useState,useSyncExternalStore} from 'react'; +import type {SessionClient} from './session'; +import {TEMPLATES_PATH} from './specialty-contract.js'; +import type {Profile} from './personality-library'; +type Facts={personality:{name:string;linked:boolean;traits:Profile['traits'];temperament:Profile['temperament']}|null;specialties:string[];skills:string[]}; +export function AgentConfigurationSummary({client,agentId,onPersonality,onSkills}:{client:SessionClient;agentId:string;onPersonality?:()=>void;onSkills?:()=>void}){ + const session=useSyncExternalStore(client.subscribe,client.getSnapshot,client.getSnapshot),owner=session.phase==='authenticated'&&session.user?.role==='owner';const [state,setState]=useState<{phase:'loading'|'ready'|'error';facts?:Facts}>({phase:'loading'}),[refresh,setRefresh]=useState(0); + useEffect(()=>{let stopped=false;if(!owner)return;setState({phase:'loading'});const epoch=session.epoch,userId=session.user?.id;const get=async(path:string)=>{const r=await client.request({method:'GET',path} as never);if(r.status!==200)throw Error('unavailable');return r.body as any;};void Promise.all([get('/api/personality-library/assigned/'+agentId),get(TEMPLATES_PATH+'/assigned/'+agentId),get('/api/skill-library/agents/'+agentId),get('/api/skill-library')]).then(([p,t,a,c])=>{const current=client.getSnapshot();if(stopped||current.epoch!==epoch||current.user?.id!==userId)return;setState({phase:'ready',facts:{personality:p.assignment?{name:p.assignment.profile.name,linked:p.assignment.linked,traits:p.assignment.profile.traits,temperament:p.assignment.profile.temperament}:null,specialties:t.templates.map((v:{name:string})=>v.name),skills:c.skills.filter((v:{id:string})=>a.selected.includes(v.id)).map((v:{name:string})=>v.name)}});}).catch(()=>{if(!stopped)setState({phase:'error'});});return()=>{stopped=true;};},[client,agentId,owner,session.epoch,session.user?.id,refresh]); + if(!owner)return null;if(state.phase!=='ready')return
    {state.phase==='loading'?

    Reading saved configuration…

    :<>

    Saved configuration could not be read.

    }
    ; + const f=state.facts!;return

    Personality

    {f.personality?<>

    {f.personality.name} ¡ {f.personality.linked?'Linked':'Individual copy'}

    Social energy {f.personality.temperament.extraversion}% ¡ Friendliness {f.personality.traits.warmth}% ¡ Detail {f.personality.traits.thoroughness}%

    :

    Independent settings ¡ no library personality linked.

    }{onPersonality&&}

    Specialties

    {f.specialties.length?
      {f.specialties.map(n=>
    • {n}
    • )}
    :

    No specialty linked. Choose one in Specialties; personality does not assign a profession.

    }

    Skills

    {f.skills.length?
      {f.skills.map(n=>
    • {n}
    • )}
    :

    No skills assigned.

    }{onSkills&&}
    ; +} diff --git a/packages/core-ui/src/AppSettings.tsx b/packages/core-ui/src/AppSettings.tsx index 64fec569..e5b7aafc 100644 --- a/packages/core-ui/src/AppSettings.tsx +++ b/packages/core-ui/src/AppSettings.tsx @@ -1,3 +1,4 @@ +import {WorkspacePackSettings} from './WorkspacePackSettings'; import {PersonalityLibrary} from './PersonalityLibrary'; import {VerificationSettings as AccountVerificationSettings} from './VerificationSettings'; import {ReviewerPoliciesSettings} from './ReviewerPoliciesSettings'; @@ -19,7 +20,7 @@ import type { ThemePreference } from './app-navigation'; import { MemorySettings, MEMORY_PANES } from './MemorySettings'; import type { MemorySettingsPane } from './MemorySettings'; import { UiIcon } from './ui-icons'; -export type SettingsTab='approval-rules'|'schedules'|'general'|'account'|'models'|'connections'|'channels'|'skills'|'mcp'|'personalities'|'specialties'|'memory'|'about'|'permissions'|'verification'|'apps'; +export type SettingsTab='sharing'|'approval-rules'|'schedules'|'general'|'account'|'models'|'connections'|'channels'|'skills'|'mcp'|'personalities'|'specialties'|'memory'|'about'|'permissions'|'verification'|'apps'; /** * Settings. In the signed-in window the sections are the sidebar (the shell * gives a slot; Memory has its own sub-places); without a slot (signed out, @@ -32,10 +33,10 @@ export function AppSettings({ signedIn, preference, onTheme, onAccount, onAgents useEffect(()=>{setTab(initialTab);},[initialTab]);useEffect(()=>{setPane(initialMemoryPane);},[initialMemoryPane]); const owner=signedIn&&client?.getSnapshot().user?.role==='owner'; const tabs:{value:SettingsTab;label:string}[]=[{value:'general',label:'General'},{value:'account',label:'Account'},{value:'models',label:'Models & tools'},...(signedIn?[{value:'apps' as const,label:'Apps'},{value:'verification' as const,label:'Verification'}]:[]),...(owner?[{value:'schedules' as const,label:'Scheduled tasks'},{value:'permissions' as const,label:'Permissions'},{value:'connections' as const,label:'Connections'},{value:'channels' as const,label:'Channels'},{value:'skills' as const,label:'Skills'},{value:'personalities' as const,label:'Personalities'},{value:'specialties' as const,label:'Specialties'},{value:'mcp' as const,label:'MCP'},{value:'memory' as const,label:'Memory'}]:[]),{value:'about',label:'Application'}]; - if(owner)tabs.splice(tabs.length-1,0,{value:'approval-rules',label:'Approval rules'}); - const icons={'approval-rules':'shield',schedules:'clock',general:'sun',account:'contact',models:'cpu',connections:'link',channels:'message',skills:'file',personalities:'contact',specialties:'layers',mcp:'tool',memory:'brain',about:'info',apps:'layers',permissions:'shield',verification:'check'} as const; + if(owner)tabs.splice(tabs.length-1,0,{value:'sharing',label:'Share settings'},{value:'approval-rules',label:'Approval rules'}); + const icons={sharing:'file','approval-rules':'shield',schedules:'clock',general:'sun',account:'contact',models:'cpu',connections:'link',channels:'message',skills:'file',personalities:'contact',specialties:'layers',mcp:'tool',memory:'brain',about:'info',apps:'layers',permissions:'shield',verification:'check'} as const; const choose=(value:SettingsTab)=>{if(value==='apps'&&onOpenApps){onOpenApps();return;}if(onOpenTool&&(value==='skills'||value==='mcp'||value==='specialties'||value==='personalities'))onOpenTool(value);else setTab(value);}; - const groups=[{name:'Application',keys:['general','account','models']},{name:'Security',keys:['permissions','verification','approval-rules']},{name:'Connections',keys:['connections','channels','memory']},{name:'Workspace',keys:['schedules','apps','skills','specialties','personalities','mcp']},{name:'',keys:['about']}]; + const groups=[{name:'Application',keys:['general','account','models','sharing']},{name:'Security',keys:['permissions','verification','approval-rules']},{name:'Connections',keys:['connections','channels','memory']},{name:'Workspace',keys:['schedules','apps','skills','specialties','personalities','mcp']},{name:'',keys:['about']}]; const sidebar=; @@ -49,6 +50,7 @@ export function AppSettings({ signedIn, preference, onTheme, onAccount, onAgents {active_==='permissions'&&owner&&client&&{})} onAccount={onAccount} onAgent={onAgent}/>} {active_==='verification'&&signedIn&&client&&{})} onBoard={onBoard??(()=>{})} onAgent={onAgent??(()=>onAgents?.())} onAccount={onAccount}/>} {active_==='apps'&&signedIn&&onOpenApps&&

    Applications and local add-ons for this workspace.

    } + {active_==='sharing'&&owner&&client&&} {active_==='general'&&} {active_==='approval-rules'&&owner&&client&&} {active_==='account'&&(signedIn&&client?<>:)} diff --git a/packages/core-ui/src/AppsWorkspace.tsx b/packages/core-ui/src/AppsWorkspace.tsx index 6b077fd1..ea1b7b1a 100644 --- a/packages/core-ui/src/AppsWorkspace.tsx +++ b/packages/core-ui/src/AppsWorkspace.tsx @@ -1,4 +1,5 @@ -import {TradingInstallView} from './TradingInstallView'; +import {WorkspaceApplications,type AppKind} from './WorkspaceApplications'; +export {builtinApps} from './WorkspaceApplications'; import {SourceCatalogue} from './SourceCatalogue'; import {useEffect,useRef,useState} from 'react'; import {createPortal} from 'react-dom'; @@ -9,8 +10,9 @@ import {ReplyAppsSettings} from './ReplyAppsSettings'; import {APPS_CATALOGUE_PATH} from './apps-contract.js'; type Place='applications'|'addons'|'catalogue'; type CatalogueItem={id:string;name:string;description:string;author:string;version:string;type:string;permissions:string[];installed:boolean;verified:boolean}; -export const builtinApps=[{id:'trading',name:'Trading preview',icon:'globe',summary:'Market terminal with clearly labelled sample data.',access:'Owner ¡ demonstration',detail:'Preview the market terminal. No broker, live prices or autonomous trading is connected.'},{id:'terminal',name:'Terminal',icon:'terminal',summary:'Command sessions on this computer.',access:'Owner',detail:'Open the terminal workspace to inspect sessions. Starting a shell gives it access to the host account; agent action permissions are not a terminal sandbox.'},{id:'genvr',name:'GenVR',icon:'layers',summary:'Virtual worlds and connected experiences.',access:'Signed-in workspace',detail:'Open the existing GenVR workspace. The world service has its own connection and availability checks.'}] as const; -export function AppsWorkspace({client,active,sidebarSlot,onOpen,onConnections,onChat,onPermissions}:{client:SessionClient;active:boolean;sidebarSlot?:HTMLElement|null;onOpen:(id:'terminal'|'genvr'|'trading')=>void;onConnections:()=>void;onChat:()=>void;onPermissions:()=>void}){ + +export function AppsWorkspace({client,active,sidebarSlot,onOpen,onConnections,onChat,onPermissions,onAccountConnections}:{client:SessionClient;active:boolean;sidebarSlot?:HTMLElement|null;onOpen:(id:'terminal'|'genvr'|'trading')=>void;onConnections:()=>void;onChat:()=>void;onPermissions:()=>void;onAccountConnections?:()=>void}){ + const [kind,setKind]=useState('all'),[addRequest,setAddRequest]=useState(0); const searchInput=useRef(null); const owner=client.getSnapshot().user?.role==='owner'; const [catalogueSource,setCatalogueSource]=useState<'dpro'|'claude-code'|'codex'>('claude-code'),[place,setPlace]=useState('applications'),[query,setQuery]=useState(''),[selected,setSelected]=useState('terminal'),[items,setItems]=useState(null),[total,setTotal]=useState(0),[error,setError]=useState(false),[refresh,setRefresh]=useState(0); @@ -18,12 +20,11 @@ export function AppsWorkspace({client,active,sidebarSlot,onOpen,onConnections,on useEffect(()=>{if(!active||place!=='catalogue'||catalogueSource!=='dpro'||!owner)return;let stopped=false;setError(false);setItems(null);void client.request({method:'GET',path:APPS_CATALOGUE_PATH} as never).then(r=>{if(stopped)return;if(r.status!==200){setError(true);return;}const b=r.body as {items:CatalogueItem[];total:number};setItems(b.items);setTotal(b.total);}).catch(()=>{if(!stopped)setError(true);});return()=>{stopped=true;};},[active,place,catalogueSource,owner,client,refresh]); const change=(next:Place)=>{setPlace(next);setQuery('');setSelected(next==='applications'?'terminal':'');}; const nav=; - const builtins=builtinApps.filter(x=>(owner||!['terminal','trading'].includes(x.id))&&`${x.name} ${x.summary}`.toLowerCase().includes(query.toLowerCase())); const catalogue=(items??[]).filter(x=>`${x.name} ${x.author} ${x.type}`.toLowerCase().includes(query.toLowerCase())); - const app=builtins.find(x=>x.id===selected),item=catalogue.find(x=>x.id===selected); - return
    {if(e.defaultPrevented||e.currentTarget.querySelector('dialog[open]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='f'){e.preventDefault();searchInput.current?.focus();}if(e.shiftKey&&e.key==='F10'&&selected==='trading'){e.preventDefault();e.currentTarget.querySelector('summary[aria-label="Paper setup actions"]')?.click();}}}>{active&&sidebarSlot&&createPortal(nav,sidebarSlot)}

    {places.find(x=>x.id===place)?.label??'Applications'}

    {place!=='addons'&&}{!sidebarSlot&&}{place==='catalogue'&&}{place==='catalogue'&&}
    - {place==='addons'&&owner?
    :place==='catalogue'&&catalogueSource!=='dpro'&&owner?:

    {place==='applications'?'Included with this build. Select an app to open it.':'Listings saved in this Dpro installation. This is separate from the Claude and Codex directories.'}

    {if(!['ArrowDown','ArrowUp','Home','End'].includes(e.key))return;e.preventDefault();const ids=place==='applications'?builtins.map(x=>x.id):catalogue.map(x=>x.id);const at=ids.indexOf(selected as never),index=e.key==='Home'?0:e.key==='End'?ids.length-1:Math.max(0,Math.min(ids.length-1,at+(e.key==='ArrowDown'?1:-1)));if(ids[index]){setSelected(ids[index]);e.currentTarget.querySelectorAll('[role=option]')[index]?.focus();}}}> - {place==='applications'?builtins.map(x=>):catalogue.map(x=>)} + const item=catalogue.find(x=>x.id===selected); + return
    {if(e.defaultPrevented||e.currentTarget.querySelector('dialog[open]'))return;if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='f'){e.preventDefault();searchInput.current?.focus();}}}>{active&&sidebarSlot&&createPortal(nav,sidebarSlot)}

    {places.find(x=>x.id===place)?.label??'Applications'}

    {place!=='addons'&&}{!sidebarSlot&&}{place==='applications'&&owner&&<>}{place==='applications'&&owner&&}{place==='catalogue'&&}{place==='catalogue'&&}
    + {place==='applications'?:place==='addons'&&owner?
    :place==='catalogue'&&catalogueSource!=='dpro'&&owner?:

    Listings saved in this Dpro installation. This is separate from the Claude and Codex directories.

    {if(!['ArrowDown','ArrowUp','Home','End'].includes(e.key))return;e.preventDefault();const ids=catalogue.map(x=>x.id);const at=ids.indexOf(selected as never),index=e.key==='Home'?0:e.key==='End'?ids.length-1:Math.max(0,Math.min(ids.length-1,at+(e.key==='ArrowDown'?1:-1)));if(ids[index]){setSelected(ids[index]);e.currentTarget.querySelectorAll('[role=option]')[index]?.focus();}}}> + {catalogue.map(x=>)}
    {place==='catalogue'&&(error?

    The catalogue could not be loaded.

    :items===null?

    Reading catalogue…

    :!catalogue.length?

    {query?'No apps match your search.':'No extensions are listed in this installation yet.'}

    :total>100?

    Showing the first 100 listings.

    :null)}
    -
    }
    ; +
    }
    ; } diff --git a/packages/core-ui/src/CompanyWizard.tsx b/packages/core-ui/src/CompanyWizard.tsx index fe39af5b..d8522ec7 100644 --- a/packages/core-ui/src/CompanyWizard.tsx +++ b/packages/core-ui/src/CompanyWizard.tsx @@ -151,7 +151,7 @@ function ShareReview({ shares, busy, controller }: { shares: readonly Share[]; b {rolesSummary(roles)}
{teamRoleOrder.filter(r => roles[r] > 0).map(r => -
{teamRoleLabel[r]}controller.editShare(x.department,{role:r,personality:{id:entry.id,revision:entry.revision}})}/>
)} +
{teamRoleLabel[r]}controller.editShare(x.department,{role:r,personality:{id:entry.id,revision:entry.revision}})}/>
)}
Show the {x.count} {x.count === 1 ? 'agent' : 'agents'} diff --git a/packages/core-ui/src/McpWorkspace.tsx b/packages/core-ui/src/McpWorkspace.tsx index 75ae7cf1..3bf126ce 100644 --- a/packages/core-ui/src/McpWorkspace.tsx +++ b/packages/core-ui/src/McpWorkspace.tsx @@ -73,9 +73,7 @@ export function McpWorkspace({client,active=true,filter='all',view='connections'
Agent access

Adding or enabling a connection grants no tools to agents. Choose reviewed tools on each agent’s card.

{onOpenAgents&&}
Tool approval settings

Connection preferences never override an agent’s own approval policy.

{selectedServer.tools.map(t=>)}
:null} - {setup&&{select(id);setNotice('Connection saved. Select Start connection to connect and discover tools.');}} key={setup==='new'?'new':setup.id} server={setup==='new'?undefined:setup} c={c} onClose={()=>setSetup(null)}/>} - {s.approval&&c.cancelApproval()} footer={<>}>

Allow {s.approval.name} to start this executable as your user?

Executable
{s.approval.launch.command}
Arguments
    {s.approval.launch.args.map((a,i)=>
  1. {a}
  2. )}
Secret names
{s.approval.launch.envNames.join(', ')||'None'}

If the launch changes, approval is required again. No tools are granted to agents.

} - {remove&&{if(!busy)setRemove(null);}} footer={<>}>

This permanently removes the connection, its stored secrets and its tool grants from every agent.

To keep its setup, turn it off instead.

} + {select(id);setNotice('Connection saved. Select Start connection to connect and discover tools.');}} onRemoved={()=>{setSelected('');setShowDetail(false);}}/> ; } @@ -93,3 +91,12 @@ function ConnectionSetup({server,c,onClose,onSaved}:{server?:Connector;c:Connect
{program?'Environment secrets':'Secret headers'}{server&&Saved fields: {[...server.envNames,...server.headerNames].join(', ')||'None'}. Values are hidden. Leave them unchanged or enter a replacement.}{secrets.map((secret,i)=>
)}

Saved disabled when added. Secret values are encrypted and never returned to this page. Adding never starts a program or grants agent access.

}; } + +/** Shared lifecycle sheets for Apps and connection settings; all writes use one controller. */ +export function McpConnectionDialogs({c,setup,onSetup,remove,onRemove,onSaved,onRemoved}:{c:ConnectorsController;setup:Connector|'new'|null;onSetup:(v:Connector|'new'|null)=>void;remove:Connector|null;onRemove:(v:Connector|null)=>void;onSaved:(id:string)=>void;onRemoved:()=>void}){ + const s=useSyncExternalStore(c.subscribe,c.getSnapshot,c.getSnapshot),busy=!!s.busy; + return <> {setup&&onSetup(null)}/>} + {s.approval&&c.cancelApproval()} footer={<>}>

Allow {s.approval.name} to start this executable as your user?

Executable
{s.approval.launch.command}
Arguments
    {s.approval.launch.args.map((a,i)=>
  1. {a}
  2. )}
Secret names
{s.approval.launch.envNames.join(', ')||'None'}

If the launch changes, approval is required again. No tools are granted to agents.

} + {remove&&{if(!busy)onRemove(null);}} footer={<>}>

This permanently removes the connection, its stored secrets and its tool grants from every agent.

To keep its setup, turn it off instead.

} + ; +} diff --git a/packages/core-ui/src/PersonalityLibrary.tsx b/packages/core-ui/src/PersonalityLibrary.tsx index 50967c15..261d81b7 100644 --- a/packages/core-ui/src/PersonalityLibrary.tsx +++ b/packages/core-ui/src/PersonalityLibrary.tsx @@ -15,17 +15,15 @@ export function PersonalitySheet({title,children,footer,onClose}:{title:string;c /** Word for a 0-100 setting, shown next to its percentage. */ const level=(v:number)=>v<34?'Low':v>66?'High':'Balanced'; function Trait({name,p,onChange}:{name:TraitName;p:Profile;onChange:(p:Profile)=>void}){const v=inverse(name)?100-p.traits[name]:p.traits[name];return ;} -export function ProfileEditor({value,onChange}:{value:Profile;onChange:(p:Profile)=>void}){ - const [layer,setLayer]=useState('temperament');const p=value; - const select=(key:'pace'|'formality'|'humour'|'seniority',label:string,options:string[])=>; - return
- {layer==='temperament'&&<>

Temperament

Big Five inspired preferences for a software agent; these are not human test scores.

Openness{(['creativity','curiosity'] as TraitName[]).map(n=>)}
Conscientiousness{(['thoroughness','persistence'] as TraitName[]).map(n=>)}
{(['extraversion','emotionalStability'] as const).map(k=>)}
Agreeableness
Confidence describes verification habits, not emotional stability.} - {layer==='work'&&<>

Work style

{(['independence','caution','thoroughness'] as TraitName[]).map(n=>)}{select('pace','Pace',['deliberate','balanced','fast'])}{select('seniority','Seniority',['junior','senior','lead'])}

Speed and seniority never change required approvals or access.

} - {layer==='communication'&&<>

Communication

{select('formality','Formality',['casual','neutral','formal'])}{select('humour','Humour',['off','light','playful'])}} - {layer==='principles'&&<>

Principles

{(['do','never','ask'] as const).map(k=>