@tomflow/chatloop-tools 0.4.5 → 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 CHANGED
@@ -1,16 +1,37 @@
1
1
  # @tomflow/chatloop-tools — Chat Loop 工具包
2
2
 
3
- **v0.4.5 · npm 安装包 · Node.js >=20 · 当前验证平台 macOS**
3
+ **v0.4.7 · Node.js >=20 · 当前验证平台 macOS;实际发布版本以 npm Registry 为准**
4
4
 
5
- 本包是**本地 CLI + 统一 stdio MCP Gateway**:一套命令管理多个工具模块的启动、停止、重启、状态;一个 MCP 入口统一对外暴露各模块的工具 API。目前代理 **Desktop Commander 0.2.44** 原有 26 个工具,新增 **Chat Loop Extension** 四个专用 Action CLI 工具(总计 30 个 MCP 工具),Repomix 尚未接入。它**不是** ChatGPT Plugin、Secure MCP Tunnel、tunnel-client 或 Chrome 扩展,也不会替用户安装这些组件。
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
+ ## Chat Loop 专属 tunnel-client:打包内置,不依赖 Local Dev
10
+
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 环境中使用。
14
+
15
+ ```bash
16
+ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools tunnel doctor
17
+ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools tunnel --version
18
+ # 用户完成专属 Tunnel 的账号授权、配置后,由统一 Supervisor 启动:
19
+ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools start tunnel
20
+ # 兼容入口(等价于 start tunnel,不再另开未托管前台进程):
21
+ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools tunnel run
22
+ ```
23
+
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 参数、聊天或仓库里。
27
+
28
+ **限制**:当前内置二进制仅适用 macOS Intel/Apple Silicon;其他操作系统明确失败而不回退系统 PATH。官方 macOS 发布二进制未签名;当前 Mac 可运行,Gatekeeper 单独检查拒绝,**其他用户机器的安装/公证/首次执行尚未验收**,不能宣称所有 macOS 用户零配置成功。
29
+
9
30
  ## 1. 可用范围与安全前提
10
31
 
11
32
  - **已经验证**:仓库内 CLI 生命周期、真实 Desktop Commander MCP 握手和只读调用、26 项工具发现、单工具故障隔离、Gateway 断线重连;全部在本机测试或隔离状态目录内完成。
12
33
  - **已验证(2026-11-05)**:v0.4.0 本地 tarball 在仓库外 macOS 临时目录用 npm 安装(`--ignore-scripts`),安装后的 `doctor/status/start/stop` 正常;MCP initialize、26 工具列表与 `read_file` 调用读回临时文件均通过。未改现有 Local Dev/Tunnel。
13
- - **未验证 / 不能宣称可正式上线**:用户自有 Tunnel → ChatGPT 完整联通、新版 2026 MCP 客户端、Windows/Linux、长任务取消、用户目录/命令授权边界、无 `--ignore-scripts` 的完整安装差异、npm/Plugin 公共发布。
34
+ - **已验收历史与当前缺口要区分**:旧 v0.4.3 通过用户独立 Tunnel → ChatGPT 的 26 工具发现和真实只读 tools/call;正式 npm v0.4.5 已发布、npx latest 本地 MCP 30 工具发现通过,但**当前专用 Tunnel 因安全工具阻断重连,处于 STOPPED**,v0.4.5 的远端 tools/call **仍未验收**;但真实 macOS Chrome 0.3.29 + Native Host + 本地 MCP tools/call 的 Text/DOM/PNG/Reload **已通过**,并在 Reload 后正式 Audit 复核版本/Owner 未变化。Windows/Linux、新版 MCP 2026 协议、长任务取消、最小权限及公开 Chat Loop 产品 Plugin 发布也未验收。
14
35
  - **高风险权限**:Desktop Commander 在独立 `HOME` 下运行,但仍继承**当前操作系统用户的文件、进程与命令权限**;这不是文件系统沙箱。只在获用户明确授权的机器上运行,勿以管理员权限启动,勿向不可信客户端暴露;生产级目录白名单/命令审批**尚未实现**。
15
36
 
16
37
  ## 2. 安装及入口
@@ -45,7 +66,7 @@ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools mcp
45
66
  cd packages/local-tools
46
67
  npm pack
47
68
  # 在仓库外一个全新的目录:
48
- npm install /absolute/path/to/tomflow-chatloop-tools-0.4.5.tgz
69
+ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.7.tgz
49
70
  ./node_modules/.bin/chat-loop-local doctor
50
71
  ./node_modules/.bin/chat-loop-local status
51
72
  ```
@@ -54,7 +75,7 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.5.tgz
54
75
 
55
76
  ## 3. CLI API
56
77
 
57
- 语法:`chatloop-tools <command> [tool-id]`。正式工具 ID 目前只有 **`desktop-commander`**(不是 MCP 工具名);可选参数最多一个。**不带工具 ID:对所有 enabled=true 模块操作**,但 `status` 无参数显示全部登记模块(包括 disabled);**带 ID:只作用于该模块**。绝不把无效 ID 回退为全体操作。
78
+ 语法:`chatloop-tools <command> [tool-id]`。正式可管理组件 ID 是 **`desktop-commander`** 和 **`tunnel`**(都不是 MCP 工具名);可选参数最多一个。**不带工具 ID:对所有 enabled=true 模块操作**,但 `status` 无参数显示全部登记模块(包括 disabled);**带 ID:只作用于该模块**。绝不把无效 ID 回退为全体操作。
58
79
 
59
80
  | 命令 | 参数 | 语义与副作用 |
60
81
  | --- | --- | --- |
@@ -62,7 +83,7 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.5.tgz
62
83
  | `capabilities` | 无 | 返回当前命令能力与边界 |
63
84
  | `status` | 可选工具 ID | **只读**;不因查询而启动 Supervisor |
64
85
  | `start` | 可选工具 ID | 启动指定或全部启用工具,运行中返回 `ALREADY_RUNNING` |
65
- | `stop` | 可选工具 ID | 停止指定或全部启用工具,仅限本包拥有的进程;不停止 Tunnel/其他项目 |
86
+ | `stop` | 可选组件 ID | 只停止本 Supervisor 实际拥有的组件;无参全体先停止 Tunnel,后停止 Desktop Commander;绝不影响 Local Dev |
66
87
  | `restart` | 可选工具 ID | 对指定或全部启用工具执行 stop → start;原本停止的也会启动 |
67
88
  | `mcp` | 无 | 在前台运行 **stdio MCP Server**,由 MCP 客户端持续连接;**不会自行 start 工具** |
68
89
 
@@ -171,9 +192,9 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.5.tgz
171
192
  | `CHAT_LOOP_LOCAL_TOOLS_STATE_DIR` | 覆盖本包状态目录,默认 `~/.chat-loop/local-tools`;测试建议使用新建私有临时目录 |
172
193
  | `CHAT_LOOP_LOCAL_TOOLS_FIXTURES=1` | **仅测试**:禁用 DC、启用 `mock-alpha`、`mock-beta` |
173
194
  | `CHAT_LOOP_LOCAL_TOOLS_FIXTURES=both` | **仅测试**:真实 DC + 两个模拟工具共同启用 |
174
- | 未设置测试开关 | Supervisor 默认只管理 `desktop-commander`;MCP 另外公布四个无需 Supervisor 启动的 Extension Action |
195
+ | 未设置测试开关 | Supervisor 管理 `desktop-commander` 与 `tunnel`;MCP 另外公布四个无需 Supervisor 启动的 Extension Action |
175
196
 
176
- `<state-dir>/manager.lock`、`control.sock`、`events.jsonl` 是本包进程所有权/IPC/唯一事件日志;`tools/desktop-commander/home` 是与其他项目隔离的 DC 配置目录。**Supervisor** 按需存在,持有 Registry、进程与串行队列;**Gateway** 只转发 MCP,不自己管理子进程;**Adapter** 处理具体工具的生命周期/协议。
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** 处理具体工具的生命周期/协议。
177
198
 
178
199
  - CLI、Gateway 和 Supervisor 必须使用**相同的状态目录/测试开关**;不同版本或 Registry 签名会拒绝管理。不要随意删除未知 `manager.lock`,也不要使用 PID 或进程名批量杀掉其他项目的工具。
179
200
  - `stop` 仅管理本包拥有的子进程。关闭 Gateway 不会自动停止正在运行的 Desktop Commander;工具停止后仍可列出 API,但调用返回 `TOOL_UNAVAILABLE`。
@@ -190,7 +211,7 @@ npm install /absolute/path/to/tomflow-chatloop-tools-0.4.5.tgz
190
211
 
191
212
  ## 7. 本机验证与发布闸门
192
213
 
193
- 在 Chat Loop 仓库根目录:
214
+ 在 Chat Loop 仓库根目录(根 `npm test` 限制测试文件并发为 2,避免共享资源负载使真实 Desktop Commander 生命周期超时;仍保留全部 184 个测试用例):
194
215
 
195
216
  ```bash
196
217
  npm run check
package/bin/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import {fileURLToPath} from 'node:url';
8
8
  import {CONTRACT,ACTIONS,registry,ensurePrivateDir,paths,
9
9
  safeFallback,recoverStaleLock,exitCode,talk} from '../lib/control.mjs';
10
10
  import {runGateway} from '../lib/gateway.mjs';
11
+ import {tunnelDoctor,runTunnel} from '../lib/tunnel.mjs';
11
12
 
12
13
  const require=createRequire(import.meta.url);
13
14
  const command=process.argv[2]??'doctor';
@@ -58,34 +59,34 @@ async function connectOrLaunch(request){
58
59
  }
59
60
  throw Object.assign(new Error('Supervisor did not become ready'),{code:last?.code||'MANAGER_UNAVAILABLE'});
60
61
  }
61
- async function runControl(){
62
- const requestId=randomUUID(), target=extra[0]||null;
63
- if(extra.length>1 || (extra.length===1 && !/^[a-z][a-z0-9-]*$/.test(target))){
64
- const reply=fail('INVALID_ARGUMENT',command,target,requestId);emit(reply);process.exitCode=2;return;
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;
65
66
  }
66
67
  const tool=target?registry().find(t=>t.id===target):null;
67
68
  if(target && !tool){const reply=fail('UNKNOWN_TOOL',command,target,requestId);emit(reply);process.exitCode=2;return;}
68
- if(command!=='status' && tool && !tool.enabled){const reply=fail('DISABLED_TOOL',command,target,requestId);emit(reply);process.exitCode=2;return;}
69
- if(command!=='status' && !target && !registry().some(t=>t.enabled)){
70
- const reply=fail('NO_ENABLED_TOOLS',command,target,requestId);emit(reply);process.exitCode=5;return;
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;
71
72
  }
72
- const req={contract:CONTRACT,requestId,action:command,scope:target?'tool':'all',deadlineMs:12000};
73
+ const req={contract:CONTRACT,requestId,action,scope:target?'tool':'all',deadlineMs:12000};
73
74
  if(target)req.toolId=target;
74
75
  let reply;
75
76
  try{
76
- if(command==='start'||command==='restart')reply=await connectOrLaunch(req);
77
+ if(action==='start'||action==='restart')reply=await connectOrLaunch(req);
77
78
  else{
78
79
  try{reply=await talk(req);}
79
80
  catch(err){
80
81
  if(!['ENOENT','ECONNREFUSED','TIMEOUT','UNKNOWN_RESULT'].includes(err.code))throw err;
81
- reply=safeFallback(command,target,requestId);
82
+ reply=safeFallback(action,target,requestId);
82
83
  }
83
84
  }
84
85
  }catch(err){
85
86
  const known=['INCOMPATIBLE_VERSION','MANAGER_UNAVAILABLE',
86
87
  'INSECURE_STATE_DIR','TIMEOUT','UNKNOWN_RESULT'];
87
88
  reply=fail(known.includes(err.code)?err.code:'MANAGER_UNAVAILABLE',
88
- command,target,requestId);
89
+ action,target,requestId);
89
90
  }
90
91
  emit(reply);process.exitCode=exitCode(reply);
91
92
  }
@@ -96,10 +97,27 @@ if(command==='mcp'){
96
97
  else if(command==='doctor'){
97
98
  if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
98
99
  else{const snapshot=inspect();emit({contract:'chat-loop.local-tools.v1',command,...snapshot});if(snapshot.status!=='LOCAL_READY')process.exitCode=1;}
100
+ }else if(command==='tunnel'){
101
+ if(extra.length===1&&extra[0]==='doctor'){
102
+ const result=tunnelDoctor();
103
+ emit({contract:'chat-loop.local-tools.v1',command,...result});
104
+ if(result.status!=='BUNDLED_READY')process.exitCode=1;
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']);}
110
+ catch(e){emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',
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;
116
+ }
99
117
  }else if(command==='capabilities'){
100
118
  if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
101
119
  else emit({contract:'chat-loop.local-tools.v1',command,status:'OK',
102
- implemented:['capabilities','doctor','mcp','start','stop','restart','status'],
120
+ implemented:['capabilities','doctor','mcp','start','stop','restart','status','tunnel'],
103
121
  lifecycle:'SUPERVISOR_MCP_GATEWAY',mcpTools:'Namespaced tool catalog',
104
- tunnel:'SEPARATE_USER_RESOURCE'});
122
+ tunnel:'BUNDLED_SUPERVISOR_MANAGED'});
105
123
  }else{emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',code:'UNKNOWN_COMMAND'});process.exitCode=1;}
@@ -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.5';
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 = 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};
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.5'}});
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.
@@ -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
- for(const t of found){
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 ADDED
@@ -0,0 +1,71 @@
1
+ // Bundled OpenAI tunnel-client: never resolve from PATH, Homebrew or Local Dev.
2
+ import {createHash} from 'node:crypto';
3
+ import {createRequire} from 'node:module';
4
+ import {spawn} from 'node:child_process';
5
+ import {readFileSync,statSync} from 'node:fs';
6
+ import {homedir} from 'node:os';
7
+ import {join,dirname} from 'node:path';
8
+ import {fileURLToPath} from 'node:url';
9
+ const require=createRequire(import.meta.url);
10
+
11
+ const supported=new Set(['darwin-x64','darwin-arm64']);
12
+ export function bundledTunnel(){
13
+ const platform=process.platform+'-'+process.arch;
14
+ if(!supported.has(platform))throw Object.assign(Error('Bundled tunnel-client is not available on '+platform),
15
+ {code:'UNSUPPORTED_PLATFORM'});
16
+ const platformPackage='@tomflow/chatloop-tunnel-'+platform;
17
+ let base;
18
+ try{base=join(dirname(require.resolve(platformPackage+'/package.json')),'bin');}
19
+ catch{
20
+ // Repo-only test fallback. No sibling workspace in a published npm consumer.
21
+ base=fileURLToPath(new URL('../../tunnel-client-'+platform+'/bin/',import.meta.url));
22
+ }
23
+ const info=JSON.parse(readFileSync(join(base,'provenance.json'),'utf8'));
24
+ const binary=join(base,'tunnel-client');
25
+ const st=statSync(binary);
26
+ if(!st.isFile()||!(st.mode&0o111))
27
+ throw Object.assign(Error('Bundled executable missing'),{code:'INVALID_BUNDLE'});
28
+ const checksum=createHash('sha256').update(readFileSync(binary)).digest('hex');
29
+ if(checksum!==info.tunnelClientSha256)
30
+ throw Object.assign(Error('Bundled executable checksum mismatch'),{code:'INVALID_BUNDLE'});
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};
37
+ }
38
+ export function tunnelDoctor(){
39
+ try{
40
+ const b=bundledTunnel();
41
+ const cloudflared=createHash('sha256').update(readFileSync(b.cloudflared)).digest('hex');
42
+ const info=JSON.parse(readFileSync(join(b.base,'provenance.json'),'utf8'));
43
+ if(cloudflared!==info.cloudflaredSha256)
44
+ throw Object.assign(Error('Bundled cloudflared checksum mismatch'),{code:'INVALID_BUNDLE'});
45
+ return {status:'BUNDLED_READY',release:b.release,platform:b.platform,
46
+ executable:b.binary,cloudflared:b.cloudflared,verified:true,connection:'UNVERIFIED'};
47
+ }catch(e){return {status:'BLOCKED',code:e.code||'INVALID_BUNDLE',reason:e.message};}
48
+ }
49
+ // Explicitly scoped to this product. Do not touch a sibling Local Dev profile.
50
+ export async function runTunnel(args){
51
+ const b=bundledTunnel();
52
+ if(args[0]==='run'){
53
+ const hasProfileDir=args.some(x=>x==='--profile-dir'||x.startsWith('--profile-dir='));
54
+ const hasProfile=args.some(x=>x==='--profile'||x.startsWith('--profile='));
55
+ if(!hasProfileDir)args=[...args,'--profile-dir',join(homedir(),'.chat-loop','tunnel','profiles')];
56
+ if(!hasProfile)args=[...args,'--profile','chat-loop'];
57
+ }
58
+ await new Promise((resolve,reject)=>{
59
+ const child=spawn(b.binary,args,{stdio:'inherit',env:process.env,windowsHide:true,
60
+ cwd:b.base}); // Only this package's verified binary. Never use the system PATH.
61
+ child.once('error',reject);
62
+ const forward=signal=>child.kill(signal);
63
+ process.on('SIGINT',forward);process.on('SIGTERM',forward);
64
+ child.once('exit',(code,signal)=>{
65
+ process.off('SIGINT',forward);process.off('SIGTERM',forward);
66
+ if(signal){process.exitCode=1;return resolve();}
67
+ process.exitCode=code===null?1:code;
68
+ resolve();
69
+ });
70
+ });
71
+ }
package/package.json CHANGED
@@ -3,9 +3,9 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.5",
6
+ "version": "0.4.7",
7
7
  "type": "module",
8
- "description": "Chat Loop local tools: MCP gateway and unified tool lifecycle CLI",
8
+ "description": "Chat Loop local tools: MCP gateway, isolated bundled tunnel-client and unified CLI",
9
9
  "bin": {
10
10
  "chatloop-tools": "bin/cli.mjs",
11
11
  "chat-loop-local": "bin/cli.mjs"
@@ -25,5 +25,9 @@
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=20"
28
+ },
29
+ "optionalDependencies": {
30
+ "@tomflow/chatloop-tunnel-darwin-x64": "0.0.19",
31
+ "@tomflow/chatloop-tunnel-darwin-arm64": "0.0.19"
28
32
  }
29
33
  }