From 2d72721a7f5c56d2c0017d64cf6ffe94773b62a8 Mon Sep 17 00:00:00 2001 From: Mohamed Alarade Date: Sun, 25 Oct 2026 09:14:16 +0100 Subject: [PATCH 1/3] Unify Apps with existing connection registries and lifecycle controls --- packages/backend/src/api/app.ts | 2 + .../backend/src/api/routes/workspace-apps.ts | 13 ++++ .../backend/src/services/workspace-apps.ts | 34 ++++++++++ packages/backend/test/workspace-apps.test.mjs | 40 ++++++++++++ .../core-ui/scripts/application-server.cjs | 4 +- packages/core-ui/src/AppsWorkspace.tsx | 19 +++--- packages/core-ui/src/McpWorkspace.tsx | 13 +++- packages/core-ui/src/SignedInShell.tsx | 2 +- .../core-ui/src/WorkspaceApplications.tsx | 62 +++++++++++++++++++ packages/core-ui/src/apps-contract.d.ts | 6 ++ packages/core-ui/src/apps-contract.js | 17 +++++ packages/core-ui/src/session.ts | 6 +- packages/core-ui/src/styles.css | 4 ++ packages/core-ui/tests/apps-security.test.cjs | 2 +- .../tests/trading-install-view.test.cjs | 4 +- .../core-ui/tests/trading-workspace.test.cjs | 2 +- .../core-ui/tests/workspace-apps.test.cjs | 12 ++++ packages/mcp-server/src/tools.ts | 34 ++++++++++ packages/mcp-server/test/mcp-server.test.mjs | 6 +- .../mcp-server/test/workspace-apps.test.mjs | 9 +++ 20 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 packages/backend/src/api/routes/workspace-apps.ts create mode 100644 packages/backend/src/services/workspace-apps.ts create mode 100644 packages/backend/test/workspace-apps.test.mjs create mode 100644 packages/core-ui/src/WorkspaceApplications.tsx create mode 100644 packages/core-ui/tests/workspace-apps.test.cjs create mode 100644 packages/mcp-server/test/workspace-apps.test.mjs diff --git a/packages/backend/src/api/app.ts b/packages/backend/src/api/app.ts index 4b5101a1..2cf9c2ca 100644 --- a/packages/backend/src/api/app.ts +++ b/packages/backend/src/api/app.ts @@ -1,3 +1,4 @@ +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 +363,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 diff --git a/packages/backend/src/api/routes/workspace-apps.ts b/packages/backend/src/api/routes/workspace-apps.ts new file mode 100644 index 00000000..74d23619 --- /dev/null +++ b/packages/backend/src/api/routes/workspace-apps.ts @@ -0,0 +1,13 @@ +import type {FastifyInstance} from 'fastify'; +import {listWorkspaceApps,type WorkspaceAppsSources} from '../../services/workspace-apps.js'; + +export async function workspaceAppsRoutes(app:FastifyInstance,options:{sources?:WorkspaceAppsSources}={}){ + app.get('/api/apps',{config:{access:'owner'}},async(request,reply)=>{ + if(!request.authUser)return reply.code(401).send({code:'unauthenticated'}); + if(request.authUser.role!=='owner')return reply.code(403).send({code:'forbidden'}); + if(Object.keys(request.query as object).length)return reply.code(400).send({code:'invalid_input'}); + reply.header('Cache-Control','no-store'); + try{return await listWorkspaceApps(request.authUser.id,options.sources);} + catch{return reply.code(503).send({code:'apps_unavailable'});} + }); +} 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/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/core-ui/scripts/application-server.cjs b/packages/core-ui/scripts/application-server.cjs index 2b3384e2..72c9d879 100644 --- a/packages/core-ui/scripts/application-server.cjs +++ b/packages/core-ui/scripts/application-server.cjs @@ -11,7 +11,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'); @@ -92,6 +92,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); @@ -157,6 +158,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); 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/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/SignedInShell.tsx b/packages/core-ui/src/SignedInShell.tsx index ad0181fd..a2f40058 100644 --- a/packages/core-ui/src/SignedInShell.tsx +++ b/packages/core-ui/src/SignedInShell.tsx @@ -173,7 +173,7 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic } - + {user.role==='owner'&&} {(page === 'settings' || page === 'about') &&
setPage('apps')} onApprovals={()=>setPage('approvals')} onBoard={()=>setPage('board')} onAgent={id=>{setAgentFocus(f=>({id,n:(f?.n??0)+1}));setPage('agents');}} onOpenChat={()=>setPage('chat')} onOpenTool={tool=>setPage(tool)} key={settingsVisit} initialTab={settingsTab} initialMemoryPane={memoryPane} sidebarSlot={page === 'settings' ? slot : null} active={page === 'settings'} signedIn client={client} preference={appearance.preference} onTheme={appearance.setPreference} onAccount={() => setPage('account')} onAgents={() => setPage('agents')} onOpenMemory={() => setPage('memory')} aboutOnly={page === 'about'}/>
} diff --git a/packages/core-ui/src/WorkspaceApplications.tsx b/packages/core-ui/src/WorkspaceApplications.tsx new file mode 100644 index 00000000..bc48c3b7 --- /dev/null +++ b/packages/core-ui/src/WorkspaceApplications.tsx @@ -0,0 +1,62 @@ +import {useCallback,useEffect,useRef,useState,useSyncExternalStore} from 'react'; +import type {SessionClient} from './session'; +import type {Connector} from './connectors-contract.js'; +import {ConnectorsController} from './connectors'; +import {McpConnectionDialogs} from './McpWorkspace'; +import {UiIcon} from './ui-icons'; +import {TradingInstallView} from './TradingInstallView'; +import {WORKSPACE_APPS_PATH,type WorkspaceApp} from './apps-contract.js'; + +export const builtinApps=[{id:'trading',name:'Trading preview',icon:'globe',summary:'Market terminal with clearly labelled sample data.'},{id:'terminal',name:'Terminal',icon:'terminal',summary:'Command sessions on this computer.'},{id:'genvr',name:'GenVR',icon:'layers',summary:'Virtual worlds and connected experiences.'}] as const; +export type AppKind='all'|'builtin'|'mcp'|'api'; +const kinds={builtin:'Built-in',mcp:'MCP connection',api:'Account connection'}; +const states:Record={included:'Included',disabled:'Off',connected:'Connected',connecting:'Connecting…',disconnected:'Disconnected',attention:'Needs attention',not_connected:'Account not connected',expired:'Connect again'}; +const icon=(app:WorkspaceApp)=>app.id==='terminal'?'terminal':app.id==='trading'?'globe':app.id==='genvr'?'layers':'link'; +const publicApp:WorkspaceApp={id:'genvr',name:'GenVR',kind:'api',version:null,summary:'Virtual worlds and connected experiences.',state:'included',sourceId:'genvr',permissions:[],tools:0,removable:false}; + +/** Uses the existing MCP lifecycle and account Settings. There is no second installation store. */ +export function WorkspaceApplications({client,active,query,kind,refresh,addRequest,onOpen,onAccountConnections,onPermissions,onManageConnections}:{client:SessionClient;active:boolean;query:string;kind:AppKind;refresh:number;addRequest:number;onOpen:(id:'terminal'|'genvr'|'trading')=>void;onAccountConnections:()=>void;onPermissions:()=>void;onManageConnections:()=>void}){ + const owner=useSyncExternalStore(client.subscribe,client.getSnapshot,client.getSnapshot).user?.role==='owner'; + const [c]=useState(()=>new ConnectorsController(client)); + const connection=useSyncExternalStore(c.subscribe,c.getSnapshot,c.getSnapshot); + const [items,setItems]=useState([]),[phase,setPhase]=useState<'loading'|'ready'|'error'>('loading'),[selected,setSelected]=useState(''),[setup,onSetup]=useState(null),[remove,onRemove]=useState(null),[notice,setNotice]=useState(''); + const sequence=useRef(0),seenAdd=useRef(addRequest),list=useRef(null); + const load=useCallback(async()=>{ + const seq=++sequence.current;setPhase('loading'); + try{const result=await client.request({method:'GET',path:WORKSPACE_APPS_PATH} as never);if(seq!==sequence.current)return;if(result.status!==200)throw Error('unavailable');setItems((result.body as {items:WorkspaceApp[]}).items);setPhase('ready');} + catch{if(seq===sequence.current){setItems([]);setPhase('error');}} + },[client]); + useEffect(()=>{if(active&&owner){void load();void c.load();}return()=>{sequence.current++;};},[active,owner,load,c,refresh]); + useEffect(()=>{if(!owner||!active||connection.busy||seenAdd.current===addRequest)return;seenAdd.current=addRequest;onSetup('new');},[addRequest,owner,active,connection.busy]); + useEffect(()=>{if(!active||!owner){onSetup(null);onRemove(null);c.cancelApproval();}},[active,owner,c]); + useEffect(()=>{if(active&&owner&&connection.phase==='ready')void load();},[active,owner,connection.servers,connection.phase,load]); + const rows=(owner?items:[publicApp]).filter(x=>(kind==='all'||x.kind===kind)&&`${x.name} ${x.summary}`.toLowerCase().includes(query.trim().toLowerCase())); + const app=(!owner||phase==='ready')?rows.find(x=>x.id===selected):undefined,server=app?.kind==='mcp'?connection.servers.find(x=>x.id===app.sourceId):undefined; + const busy=!!connection.busy||connection.phase==='loading'||!!setup||!!remove; + const act=async(operation:()=>Promise)=>{setNotice('');await operation();await load();}; + const open=(item:WorkspaceApp)=>{if(item.id==='terminal'||item.id==='trading'||item.id==='genvr')onOpen(item.id);}; + const move=(e:React.KeyboardEvent)=>{if(!['ArrowDown','ArrowUp','Home','End'].includes(e.key)||!rows.length)return;e.preventDefault();const index=rows.findIndex(x=>x.id===selected),next=e.key==='Home'?0:e.key==='End'?rows.length-1:Math.max(0,Math.min(rows.length-1,index+(e.key==='ArrowDown'?1:-1)));setSelected(rows[next].id);list.current?.querySelectorAll('[role=option]')[next]?.focus();}; + return <>
{if(e.defaultPrevented)return;if(e.shiftKey&&e.key==='F10'&&selected==='trading'){e.preventDefault();e.currentTarget.querySelector('summary[aria-label="Paper setup actions"]')?.click();}}}> +
+ {owner&&phase==='loading'&&

Reading applications…

} + {owner&&phase==='error'?

Applications could not be loaded. Your connections have not changed.

:
{rows.map(x=>)}
} + {(!owner||phase==='ready')&&!rows.length&&

{query?'No applications match your search.':kind==='mcp'?'Add a connection to give your agents access to an app.':'No applications of this kind yet.'}

{kind==='mcp'&&owner&&}
} +
+
+ {(notice||connection.notice)&&

{notice||connection.notice}

} + {owner&&active&&{setSelected('mcp:'+id);setNotice(setup==='new'?'Connection added off. Review access before turning it on.':'Connection saved. Review access if its target changed.');void load();}} onRemoved={()=>{setSelected('');void load();}}/>} + ; +} diff --git a/packages/core-ui/src/apps-contract.d.ts b/packages/core-ui/src/apps-contract.d.ts index b195db9e..ad7efdff 100644 --- a/packages/core-ui/src/apps-contract.d.ts +++ b/packages/core-ui/src/apps-contract.d.ts @@ -6,3 +6,9 @@ export const SOURCE_CATALOGUE_PATH:string; export function sourceCatalogueRoute(path:string):boolean; export function allowedSourceCatalogue(method:string,path:string,body?:unknown):boolean; export function sourceCatalogueResponse(status:number,body:unknown):unknown; + +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 WORKSPACE_APPS_PATH:'/api/apps'; +export function workspaceAppsRoute(path:string):boolean; +export function allowedWorkspaceApps(method:string,path:string,body?:unknown):boolean; +export function workspaceAppsResponse(status:number,body:unknown):{items:WorkspaceApp[]}|{code:string}; diff --git a/packages/core-ui/src/apps-contract.js b/packages/core-ui/src/apps-contract.js index 27540221..f9466877 100644 --- a/packages/core-ui/src/apps-contract.js +++ b/packages/core-ui/src/apps-contract.js @@ -16,3 +16,20 @@ export function sourceCatalogueResponse(status,body){ const source=x=>['claude-code','codex'].includes(x); return {items:body.items.map(x=>{if(!x||typeof x.id!=='string'||x.id.length>320||!source(x.source)||typeof x.present!=='boolean')throw Error('protocol');return {id:text(x.id,320),name:text(x.name,150),description:text(x.description,1000),publisher:text(x.publisher,150),source:x.source,present:x.present};}),sources:body.sources.map(x=>{if(!x||!source(x.source)||!['limited','local_snapshot'].includes(x.status))throw Error('protocol');return {source:x.source,name:text(x.name,150),status:x.status};})}; } + +/** Unified local app inventory; lifecycle changes use the existing reviewed services. */ +export const WORKSPACE_APPS_PATH='/api/apps'; +export const workspaceAppsRoute=path=>path===WORKSPACE_APPS_PATH; +export const allowedWorkspaceApps=(method,path,body)=>workspaceAppsRoute(path)&&method==='GET'&&body===undefined; +export function workspaceAppsResponse(status,body){ + if(status!==200)return {code:'apps_unavailable'}; + if(!body||!Array.isArray(body.items)||body.items.length>1000)throw Error('protocol'); + const seen=new Set(); + return {items:body.items.map(x=>{ + if(!x||typeof x.id!=='string'||!(/^(terminal|trading|genvr|mcp:[a-f0-9-]{36})$/).test(x.id)||seen.has(x.id)||!['builtin','mcp','api'].includes(x.kind)||!['included','disabled','connected','connecting','disconnected','attention','not_connected','expired'].includes(x.state)||!Number.isSafeInteger(x.tools)||x.tools<0||x.tools>10000||!Array.isArray(x.permissions)||x.permissions.length>60)throw Error('protocol'); + if(x.kind==='mcp'?(x.id!=='mcp:'+x.sourceId||x.removable!==true):x.sourceId!==(x.id==='genvr'?'genvr':null)||x.removable!==false)throw Error('protocol'); + if(x.kind!==(x.id==='genvr'?'api':x.id.startsWith('mcp:')?'mcp':'builtin'))throw Error('protocol'); + seen.add(x.id); + return {id:x.id,name:text(x.name,180),kind:x.kind,version:x.version===null?null:text(x.version,80),summary:text(x.summary,1500),state:x.state,sourceId:x.sourceId,permissions:x.permissions.map(p=>{if(typeof p!=='string')throw Error('protocol');return text(p,180);}),tools:x.tools,removable:x.removable}; + })}; +} diff --git a/packages/core-ui/src/session.ts b/packages/core-ui/src/session.ts index 38ce3118..0c986399 100644 --- a/packages/core-ui/src/session.ts +++ b/packages/core-ui/src/session.ts @@ -11,7 +11,7 @@ import {allowedVerification,verificationRoute,verificationResponse} from './veri import {allowedReviewer,reviewerRoute,reviewerOwnerRoute,reviewerResponse} from './reviewers-contract.js'; import {coverageRoute,allowedCoverage,coverageResponse} from './security-coverage-contract.js'; import {schedulerRoute,allowedScheduler,schedulerResponse} from './scheduler-contract.js'; -import {sourceCatalogueRoute,allowedSourceCatalogue,sourceCatalogueResponse,appsCatalogueRoute,allowedAppsCatalogue,appsCatalogueResponse} from './apps-contract.js'; +import {workspaceAppsRoute,allowedWorkspaceApps,workspaceAppsResponse,sourceCatalogueRoute,allowedSourceCatalogue,sourceCatalogueResponse,appsCatalogueRoute,allowedAppsCatalogue,appsCatalogueResponse} from './apps-contract.js'; import {chatAddonsRoute,allowedChatAddons,chatAddonsResponse} from './chat-addons-contract.js'; import {approvalRoute,allowedApproval,approvalResponse} from './approvals-contract.js'; import { allowedConnections, connectionsRoute, connectionsResponse } from './connections-contract.js'; @@ -97,6 +97,7 @@ export function allowedRequest(method: string, path: string, body?: unknown): bo if(tradingPaperRoute(path))return allowedTradingPaper(method,path,body); if(schedulerRoute(path))return allowedScheduler(method,path,body); if(sourceCatalogueRoute(path))return allowedSourceCatalogue(method,path,body); + if(workspaceAppsRoute(path))return allowedWorkspaceApps(method,path,body); if(appsCatalogueRoute(path))return allowedAppsCatalogue(method,path,body); if(chatAddonsRoute(path))return allowedChatAddons(method,path,body); if (approvalRoute(path)) return allowedApproval(method,path,body); @@ -167,6 +168,7 @@ function projectResponse(path: string, response: WireResponse): WireResponse { if(tradingPaperRoute(path))return {status:response.status,body:tradingPaperResponse(path,response.status,response.body)}; if(schedulerRoute(path))return {status:response.status,body:schedulerResponse(path,response.status,response.body)}; if(sourceCatalogueRoute(path))return {status:response.status,body:sourceCatalogueResponse(response.status,response.body)}; + if(workspaceAppsRoute(path))return {status:response.status,body:workspaceAppsResponse(response.status,response.body)}; if(appsCatalogueRoute(path))return {status:response.status,body:appsCatalogueResponse(response.status,response.body)}; if(chatAddonsRoute(path))return {status:response.status,body:chatAddonsResponse(response.status,response.body)}; if (path === '/api/setup/status') return { status: response.status, body: setupStatus(response.status, response.body) }; @@ -487,7 +489,7 @@ export class SessionClient { if (conversationPath(request.path)?.kind === 'approval' && this.#state.user?.role !== 'owner') throw new SessionFailure('blocked'); if ((conversationRoute(request.path) || chatSearchRoute(request.path) || chatExportRoute(request.path) || chatSecretsRoute(request.path)) && this.#state.user?.role === 'guest') throw new SessionFailure('blocked'); if (request.path === CLEANUP_PATH && this.#state.user?.role !== 'owner') throw new SessionFailure('blocked'); - if ((sourceCatalogueRoute(request.path) || appsCatalogueRoute(request.path) || chatAddonsRoute(request.path) || request.path === '/api/chat/project-folders' || approvalRoute(request.path) || connectionsRoute(request.path) || libraryPath(request.path) || templatePath(request.path) || mcpImportPath(request.path) || channelRoute(request.path) || connectorRoute(request.path) || localImportRoute(request.path) || chatBoardRoute(request.path) || chatMemoryRoute(request.path)) && this.#state.user?.role !== 'owner') throw new SessionFailure('blocked'); + if ((workspaceAppsRoute(request.path) || sourceCatalogueRoute(request.path) || appsCatalogueRoute(request.path) || chatAddonsRoute(request.path) || request.path === '/api/chat/project-folders' || approvalRoute(request.path) || connectionsRoute(request.path) || libraryPath(request.path) || templatePath(request.path) || mcpImportPath(request.path) || channelRoute(request.path) || connectorRoute(request.path) || localImportRoute(request.path) || chatBoardRoute(request.path) || chatMemoryRoute(request.path)) && this.#state.user?.role !== 'owner') throw new SessionFailure('blocked'); if ((memoryDesktopRoute(request.path) || memoryPagesRoute(request.path) || memoryRecallRoute(request.path) || memoryBridgeRoute(request.path) || discoveryRoute(request.path)) && this.#state.user?.role !== 'owner') throw new SessionFailure('blocked'); if (terminalRoute(request.path) && (this.#state.user?.role !== 'owner' || terminalPath(request.path)?.kind === 'stream')) throw new SessionFailure('blocked'); if (request.method === 'PUT' && !conversationRoute(request.path) && this.#state.user?.role !== 'owner') throw new SessionFailure('blocked'); diff --git a/packages/core-ui/src/styles.css b/packages/core-ui/src/styles.css index 28ae41e0..a41926db 100644 --- a/packages/core-ui/src/styles.css +++ b/packages/core-ui/src/styles.css @@ -2663,3 +2663,7 @@ button.danger-solid { background:var(--danger); color:#fff; } .skill-install-sheet .actions button { margin-block:0; } /* Selected Trading setup stays within the existing Apps inspector and native decision sheet. */ .trading-install{min-width:0}.install-status-row{display:flex;align-items:center;gap:8px}.install-status-row>p{flex:1;margin:0}.apps-inspector .install-status-row button{margin:0}.apps-inspector .install-icon{padding:6px;flex:none}.install-menu{position:relative;flex:none}.install-menu summary{display:flex;align-items:center;justify-content:center;cursor:pointer;list-style:none;width:30px;height:30px}.install-menu summary::-webkit-details-marker{display:none}.install-menu summary:focus-visible{outline:2px solid var(--accent)}.install-menu>div{position:absolute;inset-inline-end:0;top:100%;z-index:15;background:var(--surface);border:1px solid var(--border);box-shadow:0 6px 24px #0003;padding:4px;min-width:220px;max-width:calc(100vw - 48px)}.apps-inspector .install-menu button{display:block;width:100%;margin:0;text-align:start;background:transparent;padding:8px}.trading-install-sheet{width:min(680px,calc(100vw - 32px));max-width:calc(100vw - 32px);max-height:calc(100dvh - 96px);box-sizing:border-box;padding:0;overflow:hidden;color:var(--text);background:var(--surface)}.trading-install-sheet[open]{display:flex;flex-direction:column}.trading-install-sheet>header{display:flex;align-items:center;gap:10px;padding:16px;margin:0;border-block-end:1px solid var(--border);flex:none}.trading-install-sheet>header h3{font-size:16px;margin:0;flex:1;outline:none}.trading-install-sheet>header button{margin:0;padding:6px;display:flex;background:transparent}.install-sheet-body{overflow:auto;min-height:0;padding:12px 20px;font-size:13px;line-height:1.7}.install-sheet-body fieldset{margin:0;padding:0;border:0;min-width:0}.install-sheet-body label{display:flex;flex-direction:column;gap:6px}.install-sheet-body .install-check{flex-direction:row;align-items:flex-start;margin:12px 0}.install-sheet-body input[type=checkbox]{width:16px;min-width:16px;height:16px;min-height:16px;flex:none;margin:4px 0}.install-sheet-body input:not([type=checkbox]){width:100%;box-sizing:border-box}.install-consequences{padding-inline-start:20px}.install-technical summary{cursor:pointer;color:var(--muted)}.install-technical dl{font-size:11px}.install-technical dd{margin:4px 0 12px;overflow-wrap:anywhere;user-select:text}.trading-install-sheet>.actions{display:flex;justify-content:flex-end;gap:10px;flex:none;padding:14px 20px;border-block-start:1px solid var(--border);margin:0}.apps-inspector .trading-install-sheet>.actions button{margin:0;display:inline-flex}.trading-install .fine-print{margin-block-start:16px} + +/* Apps uses the existing section sidebar and a single selection inspector. */ +.apps-connection-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.apps-connection-actions button{margin:0;padding:7px}.apps-connection-actions .primary{margin-inline-end:auto}.apps-access{margin-block-start:22px;border-block-start:1px solid var(--border);padding-block-start:12px}.apps-access summary{cursor:pointer}.apps-access p{color:var(--muted)}.apps-notice{flex:none;margin:0;padding:8px 16px;border-block-start:1px solid var(--border);font-size:12px;color:var(--muted)}.apps-row strong,.apps-row small{overflow-wrap:anywhere}.apps-toolbar>*{min-width:0}.apps-panes{grid-template-columns:minmax(0,1fr) minmax(280px,360px)} +@media(max-width:1000px){.apps-panes{grid-template-columns:minmax(0,1fr)}} diff --git a/packages/core-ui/tests/apps-security.test.cjs b/packages/core-ui/tests/apps-security.test.cjs index d3bdcf38..a001a152 100644 --- a/packages/core-ui/tests/apps-security.test.cjs +++ b/packages/core-ui/tests/apps-security.test.cjs @@ -1,7 +1,7 @@ require('./load.cjs');const {test}=require('node:test');const assert=require('node:assert/strict');const React=require('../node_modules/react');const {renderToStaticMarkup}=require('../node_modules/react-dom/server'); const {allowedRequest,SessionClient}=require('../src/session.ts');const {allowed}=require('../scripts/application-server.cjs');const {APPS_CATALOGUE_PATH,SOURCE_CATALOGUE_PATH,appsCatalogueResponse,sourceCatalogueResponse}=require('../src/apps-contract.js');const {AppsWorkspace}=require('../src/AppsWorkspace.tsx');const {PermissionsSettings,VerificationSettings}=require('../src/SecuritySettings.tsx'); const client=role=>({getSnapshot:()=>({user:{id:'test',username:'Test person',email:'person@example.test',role}})}); -test('apps keep built-in access boundaries and never fabricate install actions',()=>{for(const role of ['owner','user']){const html=renderToStaticMarkup(React.createElement(AppsWorkspace,{client:client(role),active:true,onOpen(){},onConnections(){},onChat(){},onPermissions(){}}));assert.match(html,/GenVR/);if(role==='owner')assert.match(html,/Terminal/);else assert.doesNotMatch(html,/Terminal|Local add-ons|Available catalogue/);assert.doesNotMatch(html,/>Install{for(const role of ['owner','user']){const html=renderToStaticMarkup(React.createElement(AppsWorkspace,{client:client(role),active:true,onOpen(){},onConnections(){},onChat(){},onPermissions(){}}));if(role==='owner'){assert.match(html,/Reading applications/);assert.match(html,/Add connection/);}else{assert.match(html,/GenVR/);assert.doesNotMatch(html,/Terminal|Local add-ons|Available catalogue|Add connection/);}assert.doesNotMatch(html,/>Install{const html=renderToStaticMarkup(React.createElement(VerificationSettings,{client:client('owner'),onApprovals(){},onBoard(){},onAgent(){},onAccount(){}}));assert.match(html,/Not supported in this build/);const reviewers=renderToStaticMarkup(React.createElement(VerificationSettings,{client:client('owner'),onApprovals(){},onBoard(){},onAgent(){},onAccount(){},initialView:'reviewers'}));assert.match(reviewers,/does not grant permission/);assert.doesNotMatch(html,/type="checkbox"|>Enable 2FA{for(const path of [APPS_CATALOGUE_PATH,SOURCE_CATALOGUE_PATH])for(const gate of [allowedRequest,allowed]){assert.equal(gate('GET',path,undefined),true);assert.equal(gate('POST',path,{}),false);assert.equal(gate('GET',path+'&path=/private',undefined),false);}const dpro=appsCatalogueResponse(200,{success:true,total:1,extensions:[{id:'sample',name:'Sample',type:'app',permissions:['files'],download_url:'PRIVATE-URL',author_email:'PRIVATE-EMAIL',config:{token:'PRIVATE-TOKEN'}}]});assert.ok(!JSON.stringify(dpro).includes('PRIVATE-'));assert.deepEqual(appsCatalogueResponse(200,dpro),dpro);const source=sourceCatalogueResponse(200,{items:[{id:'sample@publisher',name:'sample',source:'codex',present:false,command:'PRIVATE-COMMAND'}],sources:[]});assert.ok(!JSON.stringify(source).includes('PRIVATE-'));assert.throws(()=>sourceCatalogueResponse(200,{items:Array(801).fill({}),sources:[]}));}); test('members cannot forward either owner catalogue route',async()=>{let forwarded=0;const user={id:'catalogue-member',email:'member@example.test',username:'Member',role:'user'};const c=new SessionClient({mock:async r=>{if(r.path==='/api/auth/login')return {status:200,body:{token:'fixture',user}};if(r.path==='/api/auth/me')return {status:200,body:{user}};forwarded++;return {status:200,body:{}};}});c.selectMode('mock');await c.login('member@example.test','fixture');for(const path of [APPS_CATALOGUE_PATH,SOURCE_CATALOGUE_PATH])await assert.rejects(c.request({method:'GET',path}));assert.equal(forwarded,0);}); diff --git a/packages/core-ui/tests/trading-install-view.test.cjs b/packages/core-ui/tests/trading-install-view.test.cjs index a05bd6f5..9e7f0ba0 100644 --- a/packages/core-ui/tests/trading-install-view.test.cjs +++ b/packages/core-ui/tests/trading-install-view.test.cjs @@ -17,6 +17,8 @@ async function fixture(t,options={}){ const transport=async request=>{ if(request.path==='/api/auth/login')return {status:200,body:{token:'fixture',deviceToken:'d'.repeat(43),user}};if(request.path==='/api/auth/me')return {status:200,body:{user}}; f.calls.push({method:request.method,path:request.path,body:request.body}); + if(request.path==='/api/apps')return {status:200,body:{items:[{id:'terminal',name:'Terminal',kind:'builtin',version:null,summary:'Command sessions.',state:'included',sourceId:null,permissions:[],tools:0,removable:false},{id:'trading',name:'Trading preview',kind:'builtin',version:null,summary:'Sample data only.',state:'included',sourceId:null,permissions:[],tools:0,removable:false},{id:'genvr',name:'GenVR',kind:'api',version:null,summary:'Virtual worlds.',state:'not_connected',sourceId:'genvr',permissions:[],tools:0,removable:false}]}}; + if(request.path==='/api/mcp/servers')return {status:200,body:{servers:[]}}; if(request.path===base)return f.unavailable?{status:503,body:{code:'paper_installation_unavailable'}}:{status:200,body:status()}; if(request.path===base+'/reviews'){f.reviewCount=(f.reviewCount??0)+1;f.review={ok:true,id:reviewId.slice(0,-1)+String(f.reviewCount),expiresAt:Date.now()+300000,appId:'trading',version:'1.0.0',mode:'paper',...request.body,currentState:f.state,schemaDigest:'b'.repeat(64),schemaVersion:1,implementationId:'bundled.trading.paper.v1',dataEffects:{verifiesSchemaTables:15,createsAccounts:false,seedsCash:false,retainsData:true},runtimeConnected:false,createsAccounts:false,retainsData:true};return {status:200,body:f.review};} if(request.path.endsWith('/challenge'))return {status:200,body:{challenge:'c'.repeat(43),expiresAt:Date.now()+300000}}; @@ -54,7 +56,7 @@ test('local demo never grants setup; unavailable still preserves preview and ina const demo=await fixture(t,{mode:'mock'});assert.equal(demo.f.calls.length,0);assert.equal(button(demo.container,'Review paper setup'),undefined);await click(button(demo.container,'Open preview'));assert.deepEqual(demo.f.opens,['trading']);const x=await fixture(t,{unavailable:true});assert.match(text(x.container),/unavailable/);assert.ok(button(x.container,'Try again'));await click(button(x.container,'Open preview'));assert.equal(x.f.calls.filter(r=>r.method==='POST').length,0);await x.render(false);assert.equal(x.container.textContent,''); }); test('Apps integration keeps Terminal GenVR and Trading preview in the existing selected inspector',async t=>{ - const x=await fixture(t,{apps:true});await click(button(x.container,'Open Terminal'));assert.deepEqual(x.f.opens,['terminal']);const trading=[...x.container.querySelectorAll('[role=option]')].find(node=>text(node).startsWith('Trading preview'));await click(trading);assert.match(text(trading),/Included preview/);assert.equal(x.container.querySelectorAll('.apps-inspector').length,1);assert.ok(button(x.container,'Review paper setup'));await click(button(x.container,'Open preview'));const genvr=[...x.container.querySelectorAll('[role=option]')].find(node=>text(node).startsWith('GenVR'));await click(genvr);await click(button(x.container,'Open GenVR'));assert.deepEqual(x.f.opens,['terminal','trading','genvr']);assert.equal(x.container.querySelectorAll('dialog').length,0); + const x=await fixture(t,{apps:true});await click([...x.container.querySelectorAll('[role=option]')].find(node=>text(node).startsWith('Terminal')));await click(button(x.container,'Open Terminal'));assert.deepEqual(x.f.opens,['terminal']);const trading=[...x.container.querySelectorAll('[role=option]')].find(node=>text(node).startsWith('Trading preview'));await click(trading);assert.match(text(trading),/Included preview/);assert.equal(x.container.querySelectorAll('.apps-inspector').length,1);assert.ok(button(x.container,'Review paper setup'));await click(button(x.container,'Open preview'));const genvr=[...x.container.querySelectorAll('[role=option]')].find(node=>text(node).startsWith('GenVR'));await click(genvr);await click(button(x.container,'Open GenVR'));assert.deepEqual(x.f.opens,['terminal','trading','genvr']);assert.equal(x.container.querySelectorAll('dialog').length,0); }); test('Apps keyboard keeps selected More open and prevents search focus escaping a modal sheet',async t=>{ const x=await fixture(t,{apps:true});await click([...x.container.querySelectorAll('[role=option]')].find(node=>text(node).startsWith('Trading preview'))); diff --git a/packages/core-ui/tests/trading-workspace.test.cjs b/packages/core-ui/tests/trading-workspace.test.cjs index 85999039..e9308a22 100644 --- a/packages/core-ui/tests/trading-workspace.test.cjs +++ b/packages/core-ui/tests/trading-workspace.test.cjs @@ -3,7 +3,7 @@ const {test}=require('node:test'),assert=require('node:assert/strict'); const React=require('../node_modules/react'),{renderToStaticMarkup}=require('../node_modules/react-dom/server'); const {TradingWorkspace}=require('../src/TradingWorkspace.tsx'); test('trading preview labels fictional prices/history and exposes no execution or provider controls',()=>{const html=renderToStaticMarkup(React.createElement(TradingWorkspace,{active:true,onBack(){}}));assert.match(html,/Paper · Demo data · Not live/);assert.match(html,/fictional price chart/);assert.match(html,/Search sample instruments/);assert.match(html,/Sector heatmap · illustrative/);assert.match(html,/Trading place/);assert.doesNotMatch(html,/>Buy<|>Sell<|>Start bot<|>Connect broker<|src="https?:/);}); -test('trading catalogue entry stays owner-only and is clearly a preview',()=>{const {AppsWorkspace,builtinApps}=require('../src/AppsWorkspace.tsx');assert.ok(builtinApps.some(x=>x.id==='trading'));for(const role of ['owner','user']){const client={getSnapshot:()=>({user:{role}})};const html=renderToStaticMarkup(React.createElement(AppsWorkspace,{client,active:true,onOpen(){},onConnections(){},onChat(){},onPermissions(){}}));if(role==='owner')assert.match(html,/Trading preview/);else assert.doesNotMatch(html,/Trading preview/);assert.doesNotMatch(html,/>Install{const {AppsWorkspace,builtinApps}=require('../src/AppsWorkspace.tsx');assert.ok(builtinApps.some(x=>x.id==='trading'));for(const role of ['owner','user']){const client={getSnapshot:()=>({user:{role}})};const html=renderToStaticMarkup(React.createElement(AppsWorkspace,{client,active:true,onOpen(){},onConnections(){},onChat(){},onPermissions(){}}));if(role==='owner'){assert.match(html,/Reading applications/);assert.equal(builtinApps.find(x=>x.id==='trading').name,'Trading preview');}else assert.doesNotMatch(html,/Trading preview/);assert.doesNotMatch(html,/>Install{ const {TradingPaperReadView}=require('../src/TradingPaperViews.tsx'); const account={accountId:'one',currency:'USD',cash:100,availableCash:90,pendingExposure:10,exposure:0,stopped:true,reservationCount:201,reservations:[{id:'r-one',policyId:'p',notional:10,status:'pending',createdAt:1,cancelledAt:null}]}; diff --git a/packages/core-ui/tests/workspace-apps.test.cjs b/packages/core-ui/tests/workspace-apps.test.cjs new file mode 100644 index 00000000..decd6b80 --- /dev/null +++ b/packages/core-ui/tests/workspace-apps.test.cjs @@ -0,0 +1,12 @@ +require('./load.cjs');const {test}=require('node:test'),assert=require('node:assert/strict'); +const {allowedRequest,SessionClient}=require('../src/session.ts'),{allowed,project}=require('../scripts/application-server.cjs'); +const {WORKSPACE_APPS_PATH,workspaceAppsResponse}=require('../src/apps-contract.js'); +const sample={id:'mcp:00000000-0000-0000-0000-000000000001',name:'A connection',kind:'mcp',summary:'Tools',version:null,state:'disabled',sourceId:'00000000-0000-0000-0000-000000000001',permissions:['Choose agent access'],tools:0,removable:true}; +test('unified app transport is exact, read-only and projects no secret or executable fields',()=>{ + for(const gate of [allowedRequest,allowed]){assert.equal(gate('GET',WORKSPACE_APPS_PATH),true);for(const path of ['/api/apps?url=/private','/api/apps/../secrets','/api/apps/','/api/apps%2f'])assert.equal(gate('GET',path),false);assert.equal(gate('POST',WORKSPACE_APPS_PATH,{}),false);assert.equal(gate('GET',WORKSPACE_APPS_PATH,{}),false);} + const out=workspaceAppsResponse(200,{items:[{...sample,env:{secret:'PRIVATE-SECRET'},command:'PRIVATE-COMMAND',url:'PRIVATE-URL'}]});assert.ok(!JSON.stringify(out).includes('PRIVATE-'));assert.deepEqual(out,workspaceAppsResponse(200,out)); + assert.throws(()=>workspaceAppsResponse(200,{items:[sample,sample]}));assert.throws(()=>workspaceAppsResponse(200,{items:[{...sample,sourceId:'other'}]}));assert.throws(()=>workspaceAppsResponse(200,{items:[{...sample,kind:'builtin'}]}));assert.throws(()=>workspaceAppsResponse(200,{items:[{...sample,state:'installed-and-running'}]}));assert.throws(()=>workspaceAppsResponse(200,{items:Array(1001).fill(sample)}));assert.deepEqual(workspaceAppsResponse(503,{secret:'private'}),{code:'apps_unavailable'}); +}); +test('member session cannot forward the unified inventory',async()=>{ + let forwarded=0;const user={id:'apps-member',email:'member@example.invalid',username:'Member',role:'user'};const c=new SessionClient({mock:async r=>{if(r.path==='/api/auth/login')return {status:200,body:{token:'fixture',user}};if(r.path==='/api/auth/me')return {status:200,body:{user}};forwarded++;return {status:200,body:{items:[]}};}});c.selectMode('mock');await c.login(user.email,'fixture');await assert.rejects(c.request({method:'GET',path:WORKSPACE_APPS_PATH}));assert.equal(forwarded,0); +}); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 643f1af8..39089000 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -28,6 +28,12 @@ const FINAL = new Set(['completed', 'failed', 'cancelled', 'waiting_approval']); const RUN_ID = /^[0-9a-f-]{36}$/i; export const TOOL_DEFINITIONS = [ + { + name:'workspace_apps',title:'Workspace applications', + description:'Owner-session only. List Apps, add an MCP connection disabled, or turn one off. Writes require the owner’s explicit instruction. Adding does not execute a program or grant tools. Program startup, account linking, secrets and permanent removal are reviewed by the owner in the app.', + inputSchema:{type:'object',properties:{operation:{type:'string',enum:['list','add','disable']},id:{type:'string',maxLength:36},connection:{type:'object',properties:{name:{type:'string',maxLength:24},description:{type:'string',maxLength:500},transport:{type:'string',enum:['http','sse','stdio']},url:{type:'string',maxLength:2000},command:{type:'string',maxLength:500},args:{type:'array',maxItems:50,items:{type:'string',maxLength:1000}},allowRemote:{type:'boolean'}},additionalProperties:false}},required:['operation'],additionalProperties:false}, + }, + { name:'personality_library', title:'Personality library', description:'Owner-session only. Read profiles, team balance, versions and manual exams; create or edit, preview linked changes, assign, archive or mix personalities. A write requires the owner’s explicit instruction. A linked change (one that updates other agents) can only be previewed here; the owner confirms it in the app. Approving drift and unlocking are owner-only in the app. Personality grants no tools or permissions. Use operation read/list/team/versions/exams/save/preview/assign/archive/blend/assess. For save supply revision and profile in body.', @@ -242,6 +248,7 @@ export async function callTool(name: string, rawArgs: unknown, deps: HandlerDeps try { const args = obj(rawArgs); switch (name) { + case 'workspace_apps': return await workspaceAppsTool(args,deps); case 'personality_library': return await personalityLibraryTool(args,deps); case 'spawn_small_agent': return await spawn(args, deps); case 'get_result': return await getResult(args, deps); @@ -417,3 +424,30 @@ async function personalityLibraryTool(args:Record,{backend}:Hand const data=await backend.request(route.method,route.id?url:base+route.suffix,route.method==='GET'||['lock','unlock'].includes(operation)?undefined:obj(args.body)); return ok('Personality library result. Preferences never grant access.',data); } + +async function workspaceAppsTool(args:Record,{backend}:HandlerDeps):Promise{ + only(args,['operation','id','connection']); + if(args.operation==='list'){ + only(args,['operation']); + return ok('Applications. Reading this list does not connect or grant access.',await backend.request('GET','/api/apps')); + } + if(args.operation==='disable'){ + only(args,['operation','id']); + if(typeof args.id!=='string'||!(/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/).test(args.id))throw new InputError('A valid connection id is required'); + await backend.request('POST','/api/mcp/servers/'+args.id+'/disable'); + return ok('Connection turned off.',await backend.request('GET','/api/apps')); + } + if(args.operation==='add'){ + only(args,['operation','connection']); + const input=obj(args.connection);only(input,['name','description','transport','url','command','args','allowRemote']); + if(typeof input.name!=='string'||!(/^[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?$/).test(input.name)||!['http','sse','stdio'].includes(String(input.transport)))throw new InputError('A valid name and transport are required'); + if(input.description!==undefined&&(typeof input.description!=='string'||input.description.length>500)||input.allowRemote!==undefined&&typeof input.allowRemote!=='boolean')throw new InputError('Invalid connection'); + if(input.transport==='stdio'){ + if(typeof input.command!=='string'||!input.command.trim()||input.command.length>500||input.url!==undefined||input.allowRemote!==undefined)throw new InputError('Invalid program connection'); + if(input.args!==undefined&&(!Array.isArray(input.args)||input.args.length>50||!input.args.every(v=>typeof v==='string'&&v.length<=1000)))throw new InputError('Invalid arguments'); + }else if(typeof input.url!=='string'||!input.url||input.url.length>2000||input.command!==undefined||input.args!==undefined)throw new InputError('Invalid web connection'); + const saved=await backend.request('POST','/api/mcp/servers',input); + return ok('Connection added disabled. The owner reviews startup and agent access in the app.',{id:saved.id}); + } + throw new InputError('Unknown application operation'); +} diff --git a/packages/mcp-server/test/mcp-server.test.mjs b/packages/mcp-server/test/mcp-server.test.mjs index 2f33ba40..dbf6d789 100644 --- a/packages/mcp-server/test/mcp-server.test.mjs +++ b/packages/mcp-server/test/mcp-server.test.mjs @@ -117,11 +117,11 @@ test('initialize and tools/list over stdio', async () => { assert.match(init.result.instructions, /spawn_small_agent/); const list = await srv.request('tools/list', {}); const tools = list.result.tools; - assert.deepEqual(tools.map((t) => t.name), ['spawn_small_agent', 'get_result', 'list_runs', 'list_handoffs', 'read_handoff', 'edit_handoff']); - const spawnTool = tools[0]; + assert.deepEqual(tools.map((t) => t.name), ['workspace_apps', 'personality_library', 'spawn_small_agent', 'get_result', 'list_runs', 'list_handoffs', 'read_handoff', 'edit_handoff']); + const spawnTool = tools.find(t=>t.name==='spawn_small_agent'); assert.deepEqual(spawnTool.inputSchema.required, ['task']); assert.deepEqual(Object.keys(spawnTool.inputSchema.properties).sort(), ['drawers', 'kind', 'max_steps', 'read_dir', 'task', 'tools']); - assert.deepEqual(tools[1].inputSchema.required, ['run_id']); + assert.deepEqual(tools.find(t=>t.name==='get_result').inputSchema.required, ['run_id']); const unknown = await srv.request('no/such/method', {}); assert.ok(unknown.error, 'unknown method is a JSON-RPC error'); assert.equal(allLog(ctx).length, 0, 'listing tools does not call the backend'); diff --git a/packages/mcp-server/test/workspace-apps.test.mjs b/packages/mcp-server/test/workspace-apps.test.mjs new file mode 100644 index 00000000..266f0143 --- /dev/null +++ b/packages/mcp-server/test/workspace-apps.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {callTool,TOOL_DEFINITIONS} from '../src/tools.ts'; +test('apps MCP tool reuses bounded owner routes and never starts or grants a connection',async()=>{ + const calls=[],id='00000000-0000-0000-0000-000000000001',backend={async request(method,path,body){calls.push({method,path,body});return {id,items:[]};}}; + assert.notEqual((await callTool('workspace_apps',{operation:'list'},{backend})).isError,true);assert.deepEqual(calls.at(-1),{method:'GET',path:'/api/apps',body:undefined}); + const connection={name:'sample',transport:'http',url:'http://127.0.0.1:9/mcp'};assert.notEqual((await callTool('workspace_apps',{operation:'add',connection},{backend})).isError,true);assert.deepEqual(calls.at(-1),{method:'POST',path:'/api/mcp/servers',body:connection}); + assert.notEqual((await callTool('workspace_apps',{operation:'disable',id},{backend})).isError,true);assert.equal(calls.at(-2).path,'/api/mcp/servers/'+id+'/disable'); + for(const input of [{operation:'enable',id},{operation:'remove',id},{operation:'disable',id:'../secrets'},{operation:'list',connection:{}},{operation:'add',connection:{...connection,enabled:true}},{operation:'add',connection:{...connection,headers:{Authorization:'refused'}}},{operation:'add',connection:{...connection,args:['wrong transport']}},{operation:'add',connection:{name:'sample',transport:'stdio',command:'node',args:[{}]}}]){const n=calls.length;assert.equal((await callTool('workspace_apps',input,{backend})).isError,true);assert.equal(calls.length,n);} + assert.ok(TOOL_DEFINITIONS.some(t=>t.name==='workspace_apps')); +}); From 8aef9eb6b847662102a082773d4ebfbc723d90a9 Mon Sep 17 00:00:00 2001 From: Mohamed Alarade Date: Sun, 25 Oct 2026 11:27:48 +0100 Subject: [PATCH 2/3] Separate temperament profiles from roles and simplify personality editing --- .../services/personality-library-profile.ts | 38 ++++++-------- .../backend/test/personality-library.test.mjs | 47 ++++++++--------- packages/core-ui/src/AgentCard.tsx | 37 ++------------ packages/core-ui/src/CompanyWizard.tsx | 2 +- packages/core-ui/src/PersonalityLibrary.tsx | 51 ++++++++++--------- packages/core-ui/src/personality-library.css | 5 ++ packages/core-ui/src/personality-library.ts | 6 +-- .../tests/personality-library.test.cjs | 7 ++- 8 files changed, 86 insertions(+), 107 deletions(-) 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/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/core-ui/src/AgentCard.tsx b/packages/core-ui/src/AgentCard.tsx index 3c50e3ab..13e79bb3 100644 --- a/packages/core-ui/src/AgentCard.tsx +++ b/packages/core-ui/src/AgentCard.tsx @@ -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' }; @@ -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/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/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=>