@tomflow/chatloop-tools 0.4.6 → 0.4.7
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 +16 -10
- package/bin/cli.mjs +21 -18
- package/lib/adapters/index.mjs +3 -1
- package/lib/adapters/tunnel.mjs +160 -0
- package/lib/control.mjs +20 -9
- package/lib/gateway.mjs +1 -1
- package/lib/supervisor.mjs +10 -4
- package/lib/tunnel.mjs +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
# @tomflow/chatloop-tools — Chat Loop 工具包
|
|
2
2
|
|
|
3
|
-
**v0.4.
|
|
3
|
+
**v0.4.7 · Node.js >=20 · 当前验证平台 macOS;实际发布版本以 npm Registry 为准**
|
|
4
4
|
|
|
5
|
-
本包是**本地 CLI + 统一 stdio MCP Gateway
|
|
5
|
+
本包是**本地 CLI + 统一 stdio MCP Gateway**(以下 API 合同适用于 v0.4.7,低版本以各自安装版说明为准):一套命令管理多个工具模块的启动、停止、重启、状态;一个 MCP 入口统一对外暴露各模块的工具 API。目前代理 **Desktop Commander 0.2.44** 原有 26 个工具,新增 **Chat Loop Extension** 四个专用 Action CLI 工具(总计 30 个 MCP 工具),Repomix 尚未接入。它**不是** ChatGPT Plugin、Secure MCP Tunnel 或 Chrome 扩展;但 v0.4.6 **随 npm 自动安装一份 Chat Loop 私有的对应平台 tunnel-client 二进制依赖**,不会使用 Local Dev/Homebrew 上的程序,也不包含任何用户凭据。
|
|
6
6
|
|
|
7
7
|
> **给人类和 AI 的 API 合同**:此 README 随 npm 包分发,可独立阅读;对外的稳定使用入口是 `chatloop-tools` CLI(兼容别名 `chat-loop-local`)和 `chatloop-tools mcp` 提供的 MCP 协议。**无承诺的 JavaScript import/exports API**,不要依赖 `lib/` 内部模块。研发架构文档在仓库 `docs/17-local-tools-multi-tool-plan.md`,不随 npm 包发行。
|
|
8
8
|
|
|
9
9
|
## Chat Loop 专属 tunnel-client:打包内置,不依赖 Local Dev
|
|
10
10
|
|
|
11
|
-
v0.4.6
|
|
11
|
+
v0.4.7 延续 v0.4.6 的独立 npm 平台二进制依赖分发 OpenAI 官方 [tunnel-client v0.0.19](https://github.com/openai/tunnel-client/releases/tag/v0.0.19) 的 macOS Intel(x64)/Apple Silicon(arm64) **完整发行二进制与 cloudflared 同伴程序**分别封装在同一 `@tomflow` scope 的 `@tomflow/chatloop-tunnel-darwin-{x64,arm64}@0.0.19` 平台子包,主包用 `optionalDependencies` 由 npm **自动只安装匹配当前 OS/CPU 的包**。这仍属于 Chat Loop Tools 的内部依赖,不安装到 PATH、Homebrew 或 Local Dev。两份官方 ZIP 均按上游 SHA256SUMS 验证;将许可证、NOTICE、第三方许可证、SPDX 和二进制 SHA256 溯源一并保留。启动只调用本包自身的绝对路径,不搜索 PATH、不依赖本机 Homebrew 或 Local Dev。
|
|
12
|
+
|
|
13
|
+
**版本注意**:`@latest` 始终取 npm Registry 当前最新版本;如 Registry 尚未提供 v0.4.7,下列生命周期命令只能在本仓源码或独立 v0.4.7 tarball 环境中使用。
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
16
|
npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools tunnel doctor
|
|
15
17
|
npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools tunnel --version
|
|
16
|
-
# 用户完成专属 Tunnel
|
|
18
|
+
# 用户完成专属 Tunnel 的账号授权、配置后,由统一 Supervisor 启动:
|
|
19
|
+
npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools start tunnel
|
|
20
|
+
# 兼容入口(等价于 start tunnel,不再另开未托管前台进程):
|
|
17
21
|
npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools tunnel run
|
|
18
22
|
```
|
|
19
23
|
|
|
20
|
-
`tunnel doctor` 校验当前架构的 tunnel-client
|
|
24
|
+
`tunnel doctor` 校验当前架构的 tunnel-client/cloudflared 哈希,报告 `BUNDLED_READY` 不代表连接已认证。**v0.4.7** 注册唯一组件 ID `tunnel`,由同一个 Supervisor 执行 `start|stop|restart|status tunnel`;无参命令统一涵盖已启用的 Desktop Commander 与 Tunnel。启动时只读产品自己的 `~/.chat-loop/tunnel/profiles/chat-loop.yaml`,缺少配置以 `PROFILE_NOT_FOUND` 失败而不启动。启动的真实二进制路径固定为本包对应平台依赖,唯一管理器持有子进程句柄;重复启动不复制进程,失去所有权时保留 `owner.json` 证据并安全拒绝接管、发信号。仅能停止本管理器直接持有的子进程,绝不按进程名查杀其他 Tunnel。
|
|
25
|
+
|
|
26
|
+
Supervisor 自己创建的独立 Unix 健康 Socket `<state-dir>/tools/tunnel/health.sock` 通过 `/readyz` 判断**本地启动就绪**;结果 `after=RUNNING / evidence=LOCAL_READYZ / connection=UNVERIFIED` **不代表** OpenAI 云端请求成功。最后仍必须经 ChatGPT 真实 tools/call 验证。统一 Tunnel 子进程不继承外部的 `OPENAI_ADMIN_KEY` 或 `CONTROL_PLANE_API_KEY`:若自己的 profile 使用 `env:CONTROL_PLANE_API_KEY`,用户须明确在启动该 CLI 时提供产品独立的 `CHAT_LOOP_TUNNEL_CONTROL_PLANE_API_KEY`,由适配器专门映射;也可让产品自己的 profile 指向用户自有受保护的 `file:` 密钥引用。不要把凭据放在 CLI 参数、聊天或仓库里。
|
|
21
27
|
|
|
22
28
|
**限制**:当前内置二进制仅适用 macOS Intel/Apple Silicon;其他操作系统明确失败而不回退系统 PATH。官方 macOS 发布二进制未签名;当前 Mac 可运行,Gatekeeper 单独检查拒绝,**其他用户机器的安装/公证/首次执行尚未验收**,不能宣称所有 macOS 用户零配置成功。
|
|
23
29
|
|
|
@@ -60,7 +66,7 @@ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools mcp
|
|
|
60
66
|
cd packages/local-tools
|
|
61
67
|
npm pack
|
|
62
68
|
# 在仓库外一个全新的目录:
|
|
63
|
-
npm install /absolute/path/to/tomflow-chatloop-tools-0.4.
|
|
69
|
+
npm install /absolute/path/to/tomflow-chatloop-tools-0.4.7.tgz
|
|
64
70
|
./node_modules/.bin/chat-loop-local doctor
|
|
65
71
|
./node_modules/.bin/chat-loop-local status
|
|
66
72
|
```
|
|
@@ -69,7 +75,7 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.6.tgz
|
|
|
69
75
|
|
|
70
76
|
## 3. CLI API
|
|
71
77
|
|
|
72
|
-
语法:`chatloop-tools <command> [tool-id]
|
|
78
|
+
语法:`chatloop-tools <command> [tool-id]`。正式可管理组件 ID 是 **`desktop-commander`** 和 **`tunnel`**(都不是 MCP 工具名);可选参数最多一个。**不带工具 ID:对所有 enabled=true 模块操作**,但 `status` 无参数显示全部登记模块(包括 disabled);**带 ID:只作用于该模块**。绝不把无效 ID 回退为全体操作。
|
|
73
79
|
|
|
74
80
|
| 命令 | 参数 | 语义与副作用 |
|
|
75
81
|
| --- | --- | --- |
|
|
@@ -77,7 +83,7 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.6.tgz
|
|
|
77
83
|
| `capabilities` | 无 | 返回当前命令能力与边界 |
|
|
78
84
|
| `status` | 可选工具 ID | **只读**;不因查询而启动 Supervisor |
|
|
79
85
|
| `start` | 可选工具 ID | 启动指定或全部启用工具,运行中返回 `ALREADY_RUNNING` |
|
|
80
|
-
| `stop` |
|
|
86
|
+
| `stop` | 可选组件 ID | 只停止本 Supervisor 实际拥有的组件;无参全体先停止 Tunnel,后停止 Desktop Commander;绝不影响 Local Dev |
|
|
81
87
|
| `restart` | 可选工具 ID | 对指定或全部启用工具执行 stop → start;原本停止的也会启动 |
|
|
82
88
|
| `mcp` | 无 | 在前台运行 **stdio MCP Server**,由 MCP 客户端持续连接;**不会自行 start 工具** |
|
|
83
89
|
|
|
@@ -186,9 +192,9 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.6.tgz
|
|
|
186
192
|
| `CHAT_LOOP_LOCAL_TOOLS_STATE_DIR` | 覆盖本包状态目录,默认 `~/.chat-loop/local-tools`;测试建议使用新建私有临时目录 |
|
|
187
193
|
| `CHAT_LOOP_LOCAL_TOOLS_FIXTURES=1` | **仅测试**:禁用 DC、启用 `mock-alpha`、`mock-beta` |
|
|
188
194
|
| `CHAT_LOOP_LOCAL_TOOLS_FIXTURES=both` | **仅测试**:真实 DC + 两个模拟工具共同启用 |
|
|
189
|
-
| 未设置测试开关 | Supervisor
|
|
195
|
+
| 未设置测试开关 | Supervisor 管理 `desktop-commander` 与 `tunnel`;MCP 另外公布四个无需 Supervisor 启动的 Extension Action |
|
|
190
196
|
|
|
191
|
-
`<state-dir>/manager.lock`、`control.sock`、`events.jsonl` 是本包进程所有权/IPC/唯一事件日志;`tools/desktop-commander/home`
|
|
197
|
+
`<state-dir>/manager.lock`、`control.sock`、`events.jsonl` 是本包进程所有权/IPC/唯一事件日志;`tools/desktop-commander/home` 是隔离的 DC 配置目录。Tunnel 另用 `tools/tunnel/owner.json` 保存崩溃后需要核实的所有权证据,以及私有 `health.sock` 只读健康探针;它们不是第二套权威日志。若孤儿标记仍在,即使旧管理器已死,也不得凭 PID 猜测、强制杀或自动启动第二实例。**Supervisor** 按需存在,持有 Registry、进程与串行队列;**Gateway** 只转发 MCP,不自己管理子进程;**Adapter** 处理具体工具的生命周期/协议。
|
|
192
198
|
|
|
193
199
|
- CLI、Gateway 和 Supervisor 必须使用**相同的状态目录/测试开关**;不同版本或 Registry 签名会拒绝管理。不要随意删除未知 `manager.lock`,也不要使用 PID 或进程名批量杀掉其他项目的工具。
|
|
194
200
|
- `stop` 仅管理本包拥有的子进程。关闭 Gateway 不会自动停止正在运行的 Desktop Commander;工具停止后仍可列出 API,但调用返回 `TOOL_UNAVAILABLE`。
|
package/bin/cli.mjs
CHANGED
|
@@ -59,34 +59,34 @@ async function connectOrLaunch(request){
|
|
|
59
59
|
}
|
|
60
60
|
throw Object.assign(new Error('Supervisor did not become ready'),{code:last?.code||'MANAGER_UNAVAILABLE'});
|
|
61
61
|
}
|
|
62
|
-
async function runControl(){
|
|
63
|
-
const requestId=randomUUID(), target=
|
|
64
|
-
if(
|
|
65
|
-
const reply=fail('INVALID_ARGUMENT',
|
|
62
|
+
async function runControl(action=command, params=extra){
|
|
63
|
+
const requestId=randomUUID(), target=params[0]||null;
|
|
64
|
+
if(params.length>1 || (params.length===1 && !/^[a-z][a-z0-9-]*$/.test(target))){
|
|
65
|
+
const reply=fail('INVALID_ARGUMENT',action,target,requestId);emit(reply);process.exitCode=2;return;
|
|
66
66
|
}
|
|
67
67
|
const tool=target?registry().find(t=>t.id===target):null;
|
|
68
68
|
if(target && !tool){const reply=fail('UNKNOWN_TOOL',command,target,requestId);emit(reply);process.exitCode=2;return;}
|
|
69
|
-
if(
|
|
70
|
-
if(
|
|
71
|
-
const reply=fail('NO_ENABLED_TOOLS',
|
|
69
|
+
if(action!=='status' && tool && !tool.enabled){const reply=fail('DISABLED_TOOL',action,target,requestId);emit(reply);process.exitCode=2;return;}
|
|
70
|
+
if(action!=='status' && !target && !registry().some(t=>t.enabled)){
|
|
71
|
+
const reply=fail('NO_ENABLED_TOOLS',action,target,requestId);emit(reply);process.exitCode=5;return;
|
|
72
72
|
}
|
|
73
|
-
const req={contract:CONTRACT,requestId,action
|
|
73
|
+
const req={contract:CONTRACT,requestId,action,scope:target?'tool':'all',deadlineMs:12000};
|
|
74
74
|
if(target)req.toolId=target;
|
|
75
75
|
let reply;
|
|
76
76
|
try{
|
|
77
|
-
if(
|
|
77
|
+
if(action==='start'||action==='restart')reply=await connectOrLaunch(req);
|
|
78
78
|
else{
|
|
79
79
|
try{reply=await talk(req);}
|
|
80
80
|
catch(err){
|
|
81
81
|
if(!['ENOENT','ECONNREFUSED','TIMEOUT','UNKNOWN_RESULT'].includes(err.code))throw err;
|
|
82
|
-
reply=safeFallback(
|
|
82
|
+
reply=safeFallback(action,target,requestId);
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
}catch(err){
|
|
86
86
|
const known=['INCOMPATIBLE_VERSION','MANAGER_UNAVAILABLE',
|
|
87
87
|
'INSECURE_STATE_DIR','TIMEOUT','UNKNOWN_RESULT'];
|
|
88
88
|
reply=fail(known.includes(err.code)?err.code:'MANAGER_UNAVAILABLE',
|
|
89
|
-
|
|
89
|
+
action,target,requestId);
|
|
90
90
|
}
|
|
91
91
|
emit(reply);process.exitCode=exitCode(reply);
|
|
92
92
|
}
|
|
@@ -102,19 +102,22 @@ else if(command==='doctor'){
|
|
|
102
102
|
const result=tunnelDoctor();
|
|
103
103
|
emit({contract:'chat-loop.local-tools.v1',command,...result});
|
|
104
104
|
if(result.status!=='BUNDLED_READY')process.exitCode=1;
|
|
105
|
-
}else if(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
try{await runTunnel([...extra]);}
|
|
105
|
+
}else if(extra.length===1&&extra[0]==='run'){
|
|
106
|
+
// Compatibility alias: all tunnel execution now belongs to the Supervisor.
|
|
107
|
+
await runControl('start',['tunnel']);
|
|
108
|
+
}else if(extra.length===1&&extra[0]==='--version'){
|
|
109
|
+
try{await runTunnel(['--version']);}
|
|
111
110
|
catch(e){emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',
|
|
112
111
|
code:e.code||'TUNNEL_EXEC_FAILED'});process.exitCode=1;}
|
|
112
|
+
}else{
|
|
113
|
+
emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',
|
|
114
|
+
code:'INVALID_ARGUMENT',usage:'tunnel doctor | tunnel --version | tunnel run (managed)'});
|
|
115
|
+
process.exitCode=2;
|
|
113
116
|
}
|
|
114
117
|
}else if(command==='capabilities'){
|
|
115
118
|
if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
|
|
116
119
|
else emit({contract:'chat-loop.local-tools.v1',command,status:'OK',
|
|
117
120
|
implemented:['capabilities','doctor','mcp','start','stop','restart','status','tunnel'],
|
|
118
121
|
lifecycle:'SUPERVISOR_MCP_GATEWAY',mcpTools:'Namespaced tool catalog',
|
|
119
|
-
tunnel:'
|
|
122
|
+
tunnel:'BUNDLED_SUPERVISOR_MANAGED'});
|
|
120
123
|
}else{emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',code:'UNKNOWN_COMMAND'});process.exitCode=1;}
|
package/lib/adapters/index.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {createFixtureAdapter} from './fixture.mjs';
|
|
2
2
|
import {createMcpChildAdapter} from './mcp-child.mjs';
|
|
3
|
+
import {createTunnelAdapter} from './tunnel.mjs';
|
|
3
4
|
|
|
4
5
|
const factories=new Map([
|
|
5
6
|
['fixture',createFixtureAdapter],
|
|
6
|
-
['mcp-child',createMcpChildAdapter]
|
|
7
|
+
['mcp-child',createMcpChildAdapter],
|
|
8
|
+
['tunnel',createTunnelAdapter]
|
|
7
9
|
]);
|
|
8
10
|
// Register future tool kinds here; Supervisor does not know concrete tools.
|
|
9
11
|
export function createAdapter(kind,ctx){
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// One Chat Loop-owned tunnel-client child, never Local Dev or a system executable.
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {existsSync,lstatSync,mkdirSync,readFileSync,unlinkSync,writeFileSync} from 'node:fs';
|
|
5
|
+
import {request as httpRequest} from 'node:http';
|
|
6
|
+
import {homedir} from 'node:os';
|
|
7
|
+
import {join} from 'node:path';
|
|
8
|
+
import {paths} from '../control.mjs';
|
|
9
|
+
import {bundledTunnel} from '../tunnel.mjs';
|
|
10
|
+
|
|
11
|
+
const err=code=>({code});
|
|
12
|
+
const result=(t,before,after,outcome,error=null,pid=null,evidence=null,connection='UNVERIFIED')=>
|
|
13
|
+
({toolId:t.id,enabled:t.enabled,before,after,outcome,pid,evidence,error,connection});
|
|
14
|
+
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
15
|
+
function probe(socketPath,ms=350){
|
|
16
|
+
return new Promise(resolve=>{
|
|
17
|
+
const req=httpRequest({socketPath,path:'/readyz',method:'GET',timeout:ms},res=>{
|
|
18
|
+
res.resume();res.once('end',()=>resolve(res.statusCode===200));
|
|
19
|
+
});
|
|
20
|
+
req.once('error',()=>resolve(false));
|
|
21
|
+
req.once('timeout',()=>{req.destroy();resolve(false);});
|
|
22
|
+
req.end();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function safeUnlink(file){
|
|
26
|
+
try{unlinkSync(file);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
27
|
+
}
|
|
28
|
+
export function createTunnelAdapter({records,log},options={}){
|
|
29
|
+
const locate=options.locate||bundledTunnel;
|
|
30
|
+
const spawnChild=options.spawnChild||spawn;
|
|
31
|
+
const profileDir=options.profileDir||join(homedir(),'.chat-loop','tunnel','profiles');
|
|
32
|
+
const profile=join(profileDir,'chat-loop.yaml');
|
|
33
|
+
const dir=join(paths().dir,'tools','tunnel');
|
|
34
|
+
const marker=join(dir,'owner.json');
|
|
35
|
+
const socket=join(dir,'health.sock');
|
|
36
|
+
function isMarked(){
|
|
37
|
+
try{lstatSync(marker);return true;}
|
|
38
|
+
catch(e){if(e.code==='ENOENT')return false;throw e;}
|
|
39
|
+
}
|
|
40
|
+
function forget(rec){
|
|
41
|
+
let owned=false;
|
|
42
|
+
try{owned=JSON.parse(readFileSync(marker,'utf8')).nonce===rec.nonce;}
|
|
43
|
+
catch{}
|
|
44
|
+
// If owner evidence changed, never touch a socket potentially owned by another manager.
|
|
45
|
+
if(!owned)return;
|
|
46
|
+
try{
|
|
47
|
+
if(existsSync(socket)){
|
|
48
|
+
const stat=lstatSync(socket);
|
|
49
|
+
if(stat.isSocket()&&(!process.getuid||stat.uid===process.getuid()))safeUnlink(socket);
|
|
50
|
+
}
|
|
51
|
+
}catch{}
|
|
52
|
+
try{safeUnlink(marker);}catch{}
|
|
53
|
+
}
|
|
54
|
+
function validProfile(){
|
|
55
|
+
try{
|
|
56
|
+
const s=lstatSync(profile);
|
|
57
|
+
return s.isFile()&&!s.isSymbolicLink()&&(!process.getuid||s.uid===process.getuid());
|
|
58
|
+
}catch{return false;}
|
|
59
|
+
}
|
|
60
|
+
async function inspect(t){
|
|
61
|
+
const rec=records.get(t.id);
|
|
62
|
+
if(!rec){
|
|
63
|
+
if(isMarked())return result(t,'UNKNOWN','UNKNOWN','OBSERVED',err('OWNERSHIP_UNCERTAIN'),null,'ORPHAN_MARKER');
|
|
64
|
+
return result(t,'STOPPED','STOPPED','OBSERVED',null,null,'NO_OWNED_PROCESS');
|
|
65
|
+
}
|
|
66
|
+
if(rec.exited)return result(t,rec.state,rec.state,'OBSERVED',
|
|
67
|
+
rec.state==='FAILED'?err(rec.authRequired?'AUTH_REQUIRED':'CHILD_EXIT'):null,null,'CHILD_EXIT');
|
|
68
|
+
if(rec.state==='STOPPING'||rec.stopSignaled)
|
|
69
|
+
return result(t,rec.state,'UNKNOWN','OBSERVED',err('UNKNOWN_RESULT'),rec.pid,'STOP_UNCONFIRMED');
|
|
70
|
+
const before=rec.state,ready=await probe(socket);
|
|
71
|
+
if(!ready){rec.state='UNKNOWN';return result(t,before,'UNKNOWN','OBSERVED',err('READINESS_LOST'),rec.pid,'READYZ_NOT_200');}
|
|
72
|
+
rec.state='RUNNING';
|
|
73
|
+
return result(t,before,'RUNNING','OBSERVED',null,rec.pid,'LOCAL_READYZ');
|
|
74
|
+
}
|
|
75
|
+
async function launch(t,ctx){
|
|
76
|
+
const old=records.get(t.id),before=old?.state||'STOPPED';
|
|
77
|
+
if(old&&!old.exited){
|
|
78
|
+
const state=await inspect(t);
|
|
79
|
+
return state.after==='RUNNING'?result(t,before,'RUNNING','ALREADY_RUNNING',null,old.pid,'LOCAL_READYZ'):
|
|
80
|
+
result(t,before,'UNKNOWN','IDENTITY_UNCERTAIN',state.error||err('UNKNOWN_RESULT'),old.pid,state.evidence);
|
|
81
|
+
}
|
|
82
|
+
if(isMarked())return result(t,before,'UNKNOWN','OWNERSHIP_UNCERTAIN',err('OWNERSHIP_UNCERTAIN'),null,'ORPHAN_MARKER');
|
|
83
|
+
if(!validProfile())return result(t,before,'FAILED','START_FAILED',err('PROFILE_NOT_FOUND'),null,'NO_PROCESS_STARTED');
|
|
84
|
+
if(ctx.remaining()<500)return result(t,before,before,'DEADLINE_EXPIRED',err('TIMEOUT'),null,'NO_PROCESS_STARTED');
|
|
85
|
+
let binary;
|
|
86
|
+
try{binary=locate().binary;}
|
|
87
|
+
catch(e){return result(t,before,'FAILED','START_FAILED',err(e.code||'INVALID_BUNDLE'));}
|
|
88
|
+
mkdirSync(dir,{recursive:true,mode:0o700});
|
|
89
|
+
const ds=lstatSync(dir);
|
|
90
|
+
if(!ds.isDirectory()||ds.isSymbolicLink()||(ds.mode&0o077)||
|
|
91
|
+
(process.getuid&&ds.uid!==process.getuid()))
|
|
92
|
+
return result(t,before,'UNKNOWN','START_FAILED',err('INSECURE_STATE_DIR'));
|
|
93
|
+
if(existsSync(socket))return result(t,before,'UNKNOWN','OWNERSHIP_UNCERTAIN',err('OWNERSHIP_UNCERTAIN'),null,'STALE_HEALTH_SOCKET');
|
|
94
|
+
const nonce=randomUUID();
|
|
95
|
+
try{writeFileSync(marker,JSON.stringify({nonce,created:Date.now(),binary}),{flag:'wx',mode:0o600});}
|
|
96
|
+
catch{return result(t,before,'UNKNOWN','OWNERSHIP_UNCERTAIN',err('OWNERSHIP_UNCERTAIN'));}
|
|
97
|
+
// Never inherit Local Dev's CONTROL_PLANE_API_KEY or OPENAI_ADMIN_KEY.
|
|
98
|
+
const env={HOME:homedir(),PATH:process.env.PATH||'/usr/bin:/bin',
|
|
99
|
+
USER:process.env.USER||'',LANG:process.env.LANG||'en_US.UTF-8',
|
|
100
|
+
TMPDIR:process.env.TMPDIR||'/tmp'};
|
|
101
|
+
if(process.env.CHAT_LOOP_TUNNEL_CONTROL_PLANE_API_KEY)
|
|
102
|
+
env.CONTROL_PLANE_API_KEY=process.env.CHAT_LOOP_TUNNEL_CONTROL_PLANE_API_KEY;
|
|
103
|
+
let child;
|
|
104
|
+
try{child=spawnChild(binary,['run','--profile-dir',profileDir,'--profile','chat-loop',
|
|
105
|
+
'--health.unix-socket',socket],{env,stdio:['ignore','pipe','pipe'],windowsHide:true});}
|
|
106
|
+
catch(e){safeUnlink(marker);return result(t,before,'FAILED','START_FAILED',err(e.code||'SPAWN_FAILED'));}
|
|
107
|
+
const rec={child,pid:child.pid||null,nonce,exited:false,state:'STARTING'};
|
|
108
|
+
records.set(t.id,rec);
|
|
109
|
+
child.stdout?.on('data',data=>log('tunnel-output',{toolId:t.id,bytes:data.length}));
|
|
110
|
+
child.stderr?.on('data',data=>{
|
|
111
|
+
// Recognize missing authorization without storing credentials or raw stderr.
|
|
112
|
+
if(/CONTROL_PLANE_API_KEY|OPENAI_ADMIN_KEY|missing (?:the )?(?:api|runtime) key/i.test(data.toString()))
|
|
113
|
+
rec.authRequired=true;
|
|
114
|
+
log('tunnel-stderr',{toolId:t.id,bytes:data.length});
|
|
115
|
+
});
|
|
116
|
+
child.once('error',e=>log('tunnel-error',{toolId:t.id,code:e.code||'ERROR'}));
|
|
117
|
+
function onExit(code,signal){
|
|
118
|
+
if(rec.exited)return;
|
|
119
|
+
rec.exited=true;rec.state=rec.state==='STOPPING'?'STOPPED':'FAILED';
|
|
120
|
+
forget(rec);log('tunnel-exit',{toolId:t.id,pid:rec.pid,code,signal});
|
|
121
|
+
}
|
|
122
|
+
child.once('exit',onExit);
|
|
123
|
+
// spawn ENOENT emits error then close, potentially without an exit event.
|
|
124
|
+
child.once('close',(code,signal)=>onExit(code,signal));
|
|
125
|
+
const until=Date.now()+Math.min(8000,Math.max(0,ctx.remaining()-250));
|
|
126
|
+
while(Date.now()<until&&!rec.exited){
|
|
127
|
+
if(await probe(socket,250)){
|
|
128
|
+
rec.state='RUNNING';log('tool-start',{requestId:ctx.requestId,toolId:t.id,pid:rec.pid});
|
|
129
|
+
return result(t,before,'RUNNING','STARTED',null,rec.pid,'LOCAL_READYZ');
|
|
130
|
+
}
|
|
131
|
+
await pause(100);
|
|
132
|
+
}
|
|
133
|
+
if(rec.exited)return result(t,before,'FAILED','START_FAILED',err(rec.authRequired?'AUTH_REQUIRED':'CHILD_EXIT'),null,'CHILD_EXIT');
|
|
134
|
+
rec.state='UNKNOWN';
|
|
135
|
+
return result(t,before,'UNKNOWN','START_UNVERIFIED',err('UNKNOWN_RESULT'),rec.pid,'READYZ_NOT_200');
|
|
136
|
+
}
|
|
137
|
+
async function stop(t,ctx){
|
|
138
|
+
const rec=records.get(t.id),before=rec?.state||'STOPPED';
|
|
139
|
+
if(!rec||rec.exited){
|
|
140
|
+
if(isMarked())return result(t,before,'UNKNOWN','OWNERSHIP_UNCERTAIN',err('OWNERSHIP_UNCERTAIN'),null,'ORPHAN_MARKER');
|
|
141
|
+
return result(t,before,'STOPPED','ALREADY_STOPPED',null,null,'NO_LIVE_OWNED_CHILD');
|
|
142
|
+
}
|
|
143
|
+
if(ctx.remaining()<100)return result(t,before,before,'DEADLINE_EXPIRED',err('TIMEOUT'),rec.pid,'NO_SIGNAL_SENT');
|
|
144
|
+
rec.state='STOPPING';rec.stopSignaled=true;
|
|
145
|
+
if(!rec.child.kill('SIGTERM')){
|
|
146
|
+
rec.state='UNKNOWN';return result(t,before,'UNKNOWN','STOP_FAILED',err('UNKNOWN_RESULT'),rec.pid,'SIGNAL_UNCONFIRMED');
|
|
147
|
+
}
|
|
148
|
+
const exited=await new Promise(resolve=>{
|
|
149
|
+
if(rec.exited)return resolve(true);
|
|
150
|
+
const timer=setTimeout(()=>resolve(false),Math.min(2200,Math.max(1,ctx.remaining())));
|
|
151
|
+
rec.child.once('exit',()=>{clearTimeout(timer);resolve(true);});
|
|
152
|
+
});
|
|
153
|
+
if(!exited){
|
|
154
|
+
rec.state='UNKNOWN';return result(t,before,'UNKNOWN','STOP_TIMEOUT',err('UNKNOWN_RESULT'),rec.pid,'NO_EXIT_CONFIRMATION');
|
|
155
|
+
}
|
|
156
|
+
log('tool-stop',{requestId:ctx.requestId,toolId:t.id,pid:rec.pid});
|
|
157
|
+
return result(t,before,'STOPPED','STOPPED',null,null,'EXIT_CONFIRMED');
|
|
158
|
+
}
|
|
159
|
+
return {launch,stop,inspect};
|
|
160
|
+
}
|
package/lib/control.mjs
CHANGED
|
@@ -4,12 +4,15 @@ import {homedir} from 'node:os';
|
|
|
4
4
|
import {join} from 'node:path';
|
|
5
5
|
|
|
6
6
|
export const CONTRACT = 'chat-loop.local-control.v1';
|
|
7
|
-
export const VERSION = '0.4.
|
|
7
|
+
export const VERSION = '0.4.7';
|
|
8
8
|
export const ACTIONS = new Set(['start','stop','restart','status']);
|
|
9
9
|
export const directory = () => process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR || join(homedir(), '.chat-loop', 'local-tools');
|
|
10
10
|
export const paths = () => ({dir: directory(), socket: join(directory(), 'control.sock'), lock: join(directory(), 'manager.lock'), log: join(directory(), 'events.jsonl')});
|
|
11
11
|
export function registry() {
|
|
12
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:'tunnel',enabled:true,kind:'tunnel'});
|
|
15
|
+
}
|
|
13
16
|
if (['1','both'].includes(process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES)) {
|
|
14
17
|
tools.push({id: 'mock-alpha', enabled: true, kind: 'fixture'});
|
|
15
18
|
tools.push({id: 'mock-beta', enabled: true, kind: 'fixture'});
|
|
@@ -38,14 +41,22 @@ export function verifyOwner(hello) {
|
|
|
38
41
|
throw Object.assign(new Error('Manager lock and IPC mismatch'),{code:'MANAGER_UNAVAILABLE'});
|
|
39
42
|
}
|
|
40
43
|
export function safeFallback(action, target, requestId) {
|
|
41
|
-
const found
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
const found=registry().filter(t=>target?t.id===target:action==='status'||t.enabled);
|
|
45
|
+
const locked=lockPresent();
|
|
46
|
+
const orphan=()=>{
|
|
47
|
+
try{lstatSync(join(paths().dir,'tools','tunnel','owner.json'));return true;}
|
|
48
|
+
catch(e){return e.code!=='ENOENT';}
|
|
49
|
+
};
|
|
50
|
+
const results=found.map(t=>{
|
|
51
|
+
const uncertain=locked||(t.id==='tunnel'&&orphan());
|
|
52
|
+
return {toolId:t.id,enabled:t.enabled,before:t.enabled?(uncertain?'UNKNOWN':'STOPPED'):'DISABLED',
|
|
53
|
+
after:t.enabled?(uncertain?'UNKNOWN':'STOPPED'):'DISABLED',
|
|
54
|
+
outcome:uncertain&&t.enabled?'MANAGER_UNAVAILABLE':action==='stop'?'ALREADY_STOPPED':'OBSERVED',
|
|
55
|
+
pid:null,evidence:locked?'STALE_MANAGER_LOCK':uncertain?'ORPHAN_MARKER':'NO_MANAGER_LOCK',
|
|
56
|
+
error:uncertain&&t.enabled?{code:'UNKNOWN_RESULT'}:null};
|
|
57
|
+
});
|
|
58
|
+
return {contract:CONTRACT,requestId,action,scope:target?'tool':'all',target:target||null,
|
|
59
|
+
status:results.some(r=>r.after==='UNKNOWN')?'UNKNOWN':'OK',results};
|
|
49
60
|
}
|
|
50
61
|
export function send(request, timeout=3000) {
|
|
51
62
|
return new Promise((resolve,reject)=>{
|
package/lib/gateway.mjs
CHANGED
|
@@ -46,7 +46,7 @@ export function runGateway(){
|
|
|
46
46
|
return reject(m.id,-32602,'UNSUPPORTED_MCP_VERSION');
|
|
47
47
|
connected=true;
|
|
48
48
|
return reply(m.id,{protocolVersion:m.params.protocolVersion,
|
|
49
|
-
capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:'0.4.
|
|
49
|
+
capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:'0.4.7'}});
|
|
50
50
|
}
|
|
51
51
|
// A modern MCP client probes discovery before falling back to legacy initialize.
|
|
52
52
|
// We only implement the legacy handshake era, so report unsupported method.
|
package/lib/supervisor.mjs
CHANGED
|
@@ -84,7 +84,9 @@ async function execute(req){
|
|
|
84
84
|
const promise=(async()=>{
|
|
85
85
|
const results=[];
|
|
86
86
|
// Batch targets run in Registry order; each tool is separately serialized.
|
|
87
|
-
|
|
87
|
+
// Stop incoming Tunnel requests before stopping the MCP child.
|
|
88
|
+
const ordered=req.action==='stop'&&req.scope==='all'?[...found].reverse():found;
|
|
89
|
+
for(const t of ordered){
|
|
88
90
|
const item=await serial(t.id,async()=>{
|
|
89
91
|
if(req.action==='status'){
|
|
90
92
|
if(!t.enabled)return {toolId:t.id,enabled:false,before:'DISABLED',after:'DISABLED',
|
|
@@ -97,7 +99,9 @@ async function execute(req){
|
|
|
97
99
|
if(ended.error)return {...ended,outcome:'RESTART_FAILED'};
|
|
98
100
|
const began=await adapterFor(t).launch(t,ctx);
|
|
99
101
|
return {...began,before:ended.before,outcome:began.error?'RESTART_FAILED':'RESTARTED'};
|
|
100
|
-
})
|
|
102
|
+
}).catch(e=>({toolId:t.id,enabled:t.enabled,before:'UNKNOWN',after:'UNKNOWN',
|
|
103
|
+
outcome:'ADAPTER_FAILED',pid:null,evidence:'UNHANDLED_ADAPTER_ERROR',
|
|
104
|
+
error:error(e.code||'UNKNOWN_RESULT')}));
|
|
101
105
|
results.push(item);
|
|
102
106
|
}
|
|
103
107
|
const failures=results.filter(r=>r.error);
|
|
@@ -155,8 +159,10 @@ async function main(){
|
|
|
155
159
|
chmodSync(p.socket,0o600);
|
|
156
160
|
log('manager-start',{pid:process.pid,version:VERSION});
|
|
157
161
|
process.on('SIGTERM',async()=>{
|
|
158
|
-
for(const t of catalog.filter(t=>t.enabled))
|
|
159
|
-
await serial(t.id,()=>adapterFor(t).stop(t,{requestId:'manager-shutdown',remaining:()=>5000}));
|
|
162
|
+
for(const t of catalog.filter(t=>t.enabled).reverse()){
|
|
163
|
+
try{await serial(t.id,()=>adapterFor(t).stop(t,{requestId:'manager-shutdown',remaining:()=>5000}));}
|
|
164
|
+
catch(e){log('shutdown-stop-error',{toolId:t.id,code:e.code||'UNKNOWN_RESULT'});}
|
|
165
|
+
}
|
|
160
166
|
await shutdown();
|
|
161
167
|
});
|
|
162
168
|
}
|
package/lib/tunnel.mjs
CHANGED
|
@@ -28,7 +28,12 @@ export function bundledTunnel(){
|
|
|
28
28
|
const checksum=createHash('sha256').update(readFileSync(binary)).digest('hex');
|
|
29
29
|
if(checksum!==info.tunnelClientSha256)
|
|
30
30
|
throw Object.assign(Error('Bundled executable checksum mismatch'),{code:'INVALID_BUNDLE'});
|
|
31
|
-
|
|
31
|
+
const cloudflared=join(base,'cloudflared');
|
|
32
|
+
const cs=statSync(cloudflared);
|
|
33
|
+
const cloudHash=createHash('sha256').update(readFileSync(cloudflared)).digest('hex');
|
|
34
|
+
if(!cs.isFile()||!(cs.mode&0o111)||cloudHash!==info.cloudflaredSha256)
|
|
35
|
+
throw Object.assign(Error('Bundled cloudflared checksum mismatch'),{code:'INVALID_BUNDLE'});
|
|
36
|
+
return {binary,cloudflared,release:info.release,platform,checksum,base};
|
|
32
37
|
}
|
|
33
38
|
export function tunnelDoctor(){
|
|
34
39
|
try{
|