@tomflow/chatloop-tools 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -2
- package/bin/cli.mjs +105 -0
- package/fixtures/worker.mjs +24 -0
- package/lib/adapters/fixture.mjs +134 -0
- package/lib/adapters/index.mjs +13 -0
- package/lib/adapters/mcp-child.mjs +136 -0
- package/lib/control.mjs +98 -0
- package/lib/desktop-commander-tools.json +879 -0
- package/lib/gateway.mjs +71 -0
- package/lib/supervisor.mjs +163 -0
- package/package.json +15 -4
package/lib/control.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {connect} from 'node:net';
|
|
2
|
+
import {existsSync, lstatSync, mkdirSync, readFileSync, statSync, unlinkSync} from 'node:fs';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const CONTRACT = 'chat-loop.local-control.v1';
|
|
7
|
+
export const VERSION = '0.4.0';
|
|
8
|
+
export const ACTIONS = new Set(['start','stop','restart','status']);
|
|
9
|
+
export const directory = () => process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR || join(homedir(), '.chat-loop', 'local-tools');
|
|
10
|
+
export const paths = () => ({dir: directory(), socket: join(directory(), 'control.sock'), lock: join(directory(), 'manager.lock'), log: join(directory(), 'events.jsonl')});
|
|
11
|
+
export function registry() {
|
|
12
|
+
const tools = [{id: 'desktop-commander', enabled: process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES !== '1', kind: 'mcp-child'}];
|
|
13
|
+
if (['1','both'].includes(process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES)) {
|
|
14
|
+
tools.push({id: 'mock-alpha', enabled: true, kind: 'fixture'});
|
|
15
|
+
tools.push({id: 'mock-beta', enabled: true, kind: 'fixture'});
|
|
16
|
+
}
|
|
17
|
+
return tools;
|
|
18
|
+
}
|
|
19
|
+
export function ensurePrivateDir() {
|
|
20
|
+
const p = paths().dir;
|
|
21
|
+
mkdirSync(p, {recursive:true, mode:0o700});
|
|
22
|
+
const s = lstatSync(p);
|
|
23
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (process.getuid && s.uid !== process.getuid()) || (s.mode & 0o077))
|
|
24
|
+
throw Object.assign(new Error('Insecure local-tools state directory'), {code:'INSECURE_STATE_DIR'});
|
|
25
|
+
}
|
|
26
|
+
export function lockPresent() {return existsSync(paths().lock);}
|
|
27
|
+
export function verifyOwner(hello) {
|
|
28
|
+
const p=paths();
|
|
29
|
+
const d=lstatSync(p.dir), l=lstatSync(p.lock), s=lstatSync(p.socket);
|
|
30
|
+
if (!d.isDirectory() || d.isSymbolicLink() || (d.mode&0o077) ||
|
|
31
|
+
!l.isFile() || l.isSymbolicLink() || (l.mode&0o077) ||
|
|
32
|
+
!s.isSocket() || (s.mode&0o077) ||
|
|
33
|
+
(process.getuid && [d,l,s].some(f=>f.uid!==process.getuid())))
|
|
34
|
+
throw Object.assign(new Error('Untrusted local IPC ownership'),{code:'INSECURE_STATE_DIR'});
|
|
35
|
+
const owner=JSON.parse(readFileSync(p.lock,'utf8'));
|
|
36
|
+
if(owner.pid!==hello.managerPid || owner.nonce!==hello.ownerNonce ||
|
|
37
|
+
owner.version!==VERSION)
|
|
38
|
+
throw Object.assign(new Error('Manager lock and IPC mismatch'),{code:'MANAGER_UNAVAILABLE'});
|
|
39
|
+
}
|
|
40
|
+
export function safeFallback(action, target, requestId) {
|
|
41
|
+
const found = registry().filter(t => target ? t.id === target : action === 'status' || t.enabled);
|
|
42
|
+
const unsure = lockPresent();
|
|
43
|
+
const results = found.map(t => ({toolId:t.id, enabled:t.enabled, before:t.enabled ? (unsure ? 'UNKNOWN' : 'STOPPED') : 'DISABLED',
|
|
44
|
+
after:t.enabled ? (unsure ? 'UNKNOWN' : 'STOPPED') : 'DISABLED',
|
|
45
|
+
outcome:unsure && t.enabled ? 'MANAGER_UNAVAILABLE' : action === 'stop' ? 'ALREADY_STOPPED' : 'OBSERVED',
|
|
46
|
+
pid:null, evidence:unsure ? 'STALE_MANAGER_LOCK' : 'NO_MANAGER_LOCK', error:unsure && t.enabled ? {code:'UNKNOWN_RESULT'} : null}));
|
|
47
|
+
return {contract:CONTRACT,requestId,action,scope:target?'tool':'all',target:target || null,
|
|
48
|
+
status:unsure ? 'UNKNOWN':'OK',results};
|
|
49
|
+
}
|
|
50
|
+
export function send(request, timeout=3000) {
|
|
51
|
+
return new Promise((resolve,reject)=>{
|
|
52
|
+
let buf = '', done = false;
|
|
53
|
+
const socket = connect(paths().socket);
|
|
54
|
+
const timer = setTimeout(()=>finish(Object.assign(new Error('IPC deadline'),{code:'TIMEOUT'})),timeout);
|
|
55
|
+
function finish(err,value) {if(done)return; done=true; clearTimeout(timer); socket.destroy(); err?reject(err):resolve(value);}
|
|
56
|
+
socket.on('connect',()=>socket.write(JSON.stringify(request)+'\n'));
|
|
57
|
+
socket.on('error',err=>finish(err));
|
|
58
|
+
socket.on('end',()=>{if(!done)finish(Object.assign(new Error('Empty IPC reply'),{code:'UNKNOWN_RESULT'}));});
|
|
59
|
+
socket.on('data',chunk=>{
|
|
60
|
+
buf+=chunk.toString();
|
|
61
|
+
if(buf.length>8*1024*1024)return finish(Object.assign(new Error('IPC reply too large'),{code:'INVALID_REPLY'}));
|
|
62
|
+
const n=buf.indexOf('\n'); if(n===-1)return;
|
|
63
|
+
try{finish(null,JSON.parse(buf.slice(0,n)));}catch{finish(Object.assign(new Error('Malformed IPC reply'),{code:'INVALID_REPLY'}));}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function talk(request,timeout=12000){
|
|
68
|
+
const hello=await send({action:'__ping'},1200);
|
|
69
|
+
if(hello.contract!==CONTRACT||hello.version!==VERSION||
|
|
70
|
+
hello.registrySignature!==registry().map(t=>t.id+':'+t.enabled).join('|'))
|
|
71
|
+
throw Object.assign(new Error('Version or registry mismatch'),{code:'INCOMPATIBLE_VERSION'});
|
|
72
|
+
verifyOwner(hello);
|
|
73
|
+
return send(request,timeout);
|
|
74
|
+
}
|
|
75
|
+
export async function recoverStaleLock() {
|
|
76
|
+
const p=paths();
|
|
77
|
+
if (!existsSync(p.lock)) return;
|
|
78
|
+
const contents=readFileSync(p.lock,'utf8');
|
|
79
|
+
let data; try{data=JSON.parse(contents);}catch{throw Object.assign(new Error('Unrecognized owner lock'),{code:'MANAGER_UNAVAILABLE'});}
|
|
80
|
+
if (!Number.isSafeInteger(data.pid) || data.pid<2 || !data.nonce) throw Object.assign(new Error('Unverified owner lock'),{code:'MANAGER_UNAVAILABLE'});
|
|
81
|
+
try { process.kill(data.pid,0); throw Object.assign(new Error('Owner may still be alive'),{code:'MANAGER_UNAVAILABLE'}); }
|
|
82
|
+
catch(err){ if(err.code!=='ESRCH')throw err; }
|
|
83
|
+
// Fail closed on a changed lock; never send signals to a PID read from disk.
|
|
84
|
+
if(readFileSync(p.lock,'utf8')!==contents)throw Object.assign(new Error('Owner changed'),{code:'MANAGER_UNAVAILABLE'});
|
|
85
|
+
if(existsSync(p.socket)) {
|
|
86
|
+
const s=lstatSync(p.socket);
|
|
87
|
+
if(!s.isSocket() || (process.getuid && s.uid!==process.getuid()))throw Object.assign(new Error('Unowned socket'),{code:'MANAGER_UNAVAILABLE'});
|
|
88
|
+
unlinkSync(p.socket);
|
|
89
|
+
}
|
|
90
|
+
unlinkSync(p.lock);
|
|
91
|
+
}
|
|
92
|
+
export function exitCode(reply) {
|
|
93
|
+
if(reply?.error?.code==='UNKNOWN_TOOL' || reply?.error?.code==='DISABLED_TOOL' || reply?.error?.code==='INVALID_ARGUMENT')return 2;
|
|
94
|
+
if(reply?.error?.code==='INCOMPATIBLE_VERSION'||reply?.error?.code==='MANAGER_UNAVAILABLE')return 3;
|
|
95
|
+
if(reply?.status==='OK')return 0;
|
|
96
|
+
if(reply?.status==='PARTIAL')return 4;
|
|
97
|
+
return 5;
|
|
98
|
+
}
|