@iamlbccc/tdxd 1.1.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 +49 -0
- package/config.example.json +29 -0
- package/lib/acp.mjs +230 -0
- package/lib/perm.mjs +132 -0
- package/lib/router.mjs +162 -0
- package/lib/tpl.mjs +77 -0
- package/lib/ui.mjs +102 -0
- package/package.json +29 -0
- package/tdxd-ctl.mjs +257 -0
- package/tdxd.mjs +368 -0
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# tdxd
|
|
2
|
+
|
|
3
|
+
TDX-Daemon — [Paperclip](https://paperclip.ing) TDX-Adapter 的远端守护进程:以「会话即进程」驱动 ACP Agent(opencode),提供会话续接、人在环权限审批、usage 计量回填;随包附带跨平台实例管理器 `tdxd-ctl`。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @iamlbccc/tdxd # Node.js >= 24
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 快速开始
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. 初始化一个 Agent 实例(交互式向导:config + token + workspace 骨架)
|
|
15
|
+
tdxd-ctl init my-agent
|
|
16
|
+
|
|
17
|
+
# 2. 启动并自检(health=200 即就绪)
|
|
18
|
+
tdxd-ctl start my-agent
|
|
19
|
+
tdxd-ctl status my-agent
|
|
20
|
+
|
|
21
|
+
# 3. 在 Paperclip 平台 UI hire Agent(选 TDX-Adapter),粘贴 init 打印的 endpoint/token
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
实例布局(agent-first,每 Agent 一套内聚目录):
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
$TDXD_HOME(默认 ~/.tdx)/agents/<name>/
|
|
28
|
+
├── config.json # 端口/workspace/权限白名单/模型价目
|
|
29
|
+
├── secret.env # 共享 token + 平台回调 key(600)
|
|
30
|
+
├── log/tdxd.log
|
|
31
|
+
└── run/pid
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 命令
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
tdxd-ctl init <name> 交互式脚手架(--yes 可脚本化)
|
|
38
|
+
tdxd-ctl list 实例一览
|
|
39
|
+
tdxd-ctl start|stop|restart|status [name|all]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 运行前提
|
|
43
|
+
|
|
44
|
+
- Node.js >= 24;目标 Agent 的 ACP 运行时(如 [opencode](https://opencode.ai))
|
|
45
|
+
- 一台可达的 Paperclip 平台(TDX-Adapter 热装)
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "TDX-问答助手",
|
|
3
|
+
"note": "TDXD_NAME / TDXD_PORT / TDXD_TOKEN / TDXD_WORKSPACE / TDXD_COMMAND / TDXD_PAPERCLIP_URL / TDXD_PAPERCLIP_KEY 环境变量优先",
|
|
4
|
+
"port": 18200,
|
|
5
|
+
"bind": "127.0.0.1",
|
|
6
|
+
"token": "CHANGE_ME_long_random",
|
|
7
|
+
"workspace": "/home/iamlbccc/wsp/agentX/workspaces/tdx-assistant",
|
|
8
|
+
"command": "/home/iamlbccc/.opencode/bin/opencode",
|
|
9
|
+
"commandArgs": [],
|
|
10
|
+
"idleTtlMin": 10,
|
|
11
|
+
"maxSessions": 5,
|
|
12
|
+
"promptTimeoutMs": 360000,
|
|
13
|
+
"paperclip": {
|
|
14
|
+
"url": "http://127.0.0.1:3100",
|
|
15
|
+
"agentKey": "sk_pap_xxx(POST /api/agents/{id}/keys 铸造后填入)"
|
|
16
|
+
},
|
|
17
|
+
"permissions": {
|
|
18
|
+
"default": "deny",
|
|
19
|
+
"allow": [
|
|
20
|
+
{
|
|
21
|
+
"command": "^(cat|head|tail|ls|grep|jq|echo)\\b"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"command": "^curl +.*(\\\\$PAPERCLIP_API_URL|\\\\$PAPERCLIP_API_BASE|127\\\\.0\\\\.0\\\\.1:3100)"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"_doc": "示例白名单:按段匹配(;|&&||||换行切分),全段命中才放行;deny 时向 issue 发审批评论(askComment:false 可关)"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/lib/acp.mjs
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// ACP 统一驱动:JSON-RPC(stdio) 传输层 + ACP 核心状态机 + 能力协商(resume/load 方言)
|
|
2
|
+
// 设计依据:OpenCode 1.18.29 实测(docs/opencode-acp-probe-evidence.log)+ ACP 规范
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
7
|
+
|
|
8
|
+
/** 桥内部凭据不得泄入 agent 子进程(BUG-06) */
|
|
9
|
+
function sanitizedEnv(extra) {
|
|
10
|
+
const e = { ...process.env, ...extra };
|
|
11
|
+
for (const k of Object.keys(e)) if (k.startsWith("TDXD_")) delete e[k];
|
|
12
|
+
return e;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// envKey 必须覆盖整个 run env(authToken + RUN_ID + TASK_ID…):
|
|
16
|
+
// 只看 token 会导致进程跨 run 复用时 env 停留在首 run——模型读到旧 PAPERCLIP_RUN_ID,
|
|
17
|
+
// checkout/PATCH 对平台而言是「别的 run」→ 409 → 收尾死循环(TDM-29~32 总根因)
|
|
18
|
+
export const envKeyOf = (env) => env ? createHash("md5").update(JSON.stringify(env)).digest("hex").slice(0, 12) : null;
|
|
19
|
+
|
|
20
|
+
export class AcpAgent {
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {string} opts.command 可执行文件(如 opencode)
|
|
24
|
+
* @param {string[]} [opts.args] 额外参数(默认 ["acp"])
|
|
25
|
+
* @param {string} opts.workspace 会话工作区(--cwd)
|
|
26
|
+
* @param {object} [opts.env] 注入子进程的环境变量(如 PAPERCLIP_*,随 run 变化)
|
|
27
|
+
* @param {Array} [opts.mcpServers] 平台下发的 run 作用域 MCP servers({name,url,token},随 session 建立)
|
|
28
|
+
*/
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.command = opts.command;
|
|
31
|
+
this.args = opts.args ?? ["acp"];
|
|
32
|
+
this.workspace = opts.workspace;
|
|
33
|
+
this.env = opts.env ?? null;
|
|
34
|
+
this.mcpServers = opts.mcpServers ?? [];
|
|
35
|
+
this.proc = null;
|
|
36
|
+
this.capabilities = null;
|
|
37
|
+
this.agentInfo = null;
|
|
38
|
+
this.resumeMethod = null; // 协商结果:"session/resume" | "session/load" | null
|
|
39
|
+
this.model = null; // 会话当前模型(取自 configOptions,随 done 事件上报供计费维度使用)
|
|
40
|
+
this.pending = new Map();
|
|
41
|
+
this.nextId = 0;
|
|
42
|
+
this.onUpdate = null; // (update) => void session/update 通知回调
|
|
43
|
+
this.permissionPolicy = opts.permissionPolicy ?? null; // (command, params) => "allow" | "deny"
|
|
44
|
+
this.onPermissionEvent = null; // ({decision, reason, command, requestId}) => void 权限决策回调
|
|
45
|
+
this.buf = "";
|
|
46
|
+
this.dead = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get alive() { return !!this.proc && !this.dead && this.proc.exitCode === null; }
|
|
50
|
+
|
|
51
|
+
async start() {
|
|
52
|
+
if (this.alive) return;
|
|
53
|
+
this.dead = false;
|
|
54
|
+
this.proc = spawn(this.command, [...this.args, "--cwd", this.workspace], {
|
|
55
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
56
|
+
env: sanitizedEnv(this.env ?? undefined),
|
|
57
|
+
});
|
|
58
|
+
this.proc.stdout.setEncoding("utf8");
|
|
59
|
+
this.proc.stdout.on("data", (d) => this._onStdout(d));
|
|
60
|
+
this.proc.stderr.setEncoding("utf8");
|
|
61
|
+
this.proc.stderr.on("data", (d) => {
|
|
62
|
+
for (const line of String(d).split("\n")) {
|
|
63
|
+
if (line.trim()) console.error(`[acp:${this.workspace.split("/").pop()}] ${line.slice(0, 200)}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
this.proc.on("exit", () => {
|
|
67
|
+
this.dead = true;
|
|
68
|
+
const err = new Error("ACP process exited");
|
|
69
|
+
for (const { rej } of this.pending.values()) rej(err);
|
|
70
|
+
this.pending.clear();
|
|
71
|
+
});
|
|
72
|
+
await this._request("initialize", {
|
|
73
|
+
protocolVersion: 1,
|
|
74
|
+
clientCapabilities: { fs: {} },
|
|
75
|
+
}, 30_000).then((r) => {
|
|
76
|
+
this.capabilities = r.agentCapabilities ?? {};
|
|
77
|
+
this.agentInfo = r.agentInfo ?? {};
|
|
78
|
+
// 方言协商:优先 sessionCapabilities.resume(OpenCode),次选规范 loadSession→session/load
|
|
79
|
+
const sc = this.capabilities.sessionCapabilities ?? {};
|
|
80
|
+
this.resumeMethod = "resume" in sc ? "session/resume"
|
|
81
|
+
: this.capabilities.loadSession ? "session/load" : null;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 平台 MCP server({name,url,token})→ ACP mcpServers 形状(OpenCode 方言:type 必填、headers 为 [{name,value}] 数组) */
|
|
86
|
+
toAcpMcp(list) {
|
|
87
|
+
return (list ?? this.mcpServers ?? [])
|
|
88
|
+
.filter((s) => s && s.url)
|
|
89
|
+
.map((s) => ({
|
|
90
|
+
name: s.name,
|
|
91
|
+
type: "http",
|
|
92
|
+
url: s.url,
|
|
93
|
+
headers: s.token ? [{ name: "Authorization", value: `Bearer ${s.token}` }] : [],
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async newSession() {
|
|
98
|
+
await this.start();
|
|
99
|
+
const r = await this._request("session/new", { cwd: this.workspace, mcpServers: this.toAcpMcp() });
|
|
100
|
+
this._captureModel(r);
|
|
101
|
+
return r.sessionId;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 从 ACP 结果的 configOptions 提取当前模型(如 kimi-for-coding/kimi-for-coding) */
|
|
105
|
+
_captureModel(result) {
|
|
106
|
+
try {
|
|
107
|
+
const opt = (result?.configOptions ?? []).find((o) => o?.id === "model");
|
|
108
|
+
if (opt?.currentValue) this.model = String(opt.currentValue);
|
|
109
|
+
} catch { /* 模型提取失败不影响会话 */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async resumeSession(sessionId) {
|
|
113
|
+
await this.start();
|
|
114
|
+
const method = this.resumeMethod ?? "session/resume";
|
|
115
|
+
const params = { cwd: this.workspace, mcpServers: this.toAcpMcp(), sessionId };
|
|
116
|
+
const r = await this._request(method, params);
|
|
117
|
+
this._captureModel(r);
|
|
118
|
+
return r.sessionId ?? sessionId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 发送用户消息并等待终态;流式事件经 onUpdate 回调 */
|
|
122
|
+
async prompt(sessionId, text) {
|
|
123
|
+
return this._request("session/prompt", {
|
|
124
|
+
sessionId,
|
|
125
|
+
prompt: [{ type: "text", text }],
|
|
126
|
+
}, DEFAULT_TIMEOUT_MS * 3);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async cancel(sessionId) {
|
|
130
|
+
if (!this.alive) return;
|
|
131
|
+
this._notify("session/cancel", { sessionId }).catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 优雅终止:SIGTERM → 宽限 → SIGKILL(进程组语义,Windows 退出为 kill) */
|
|
135
|
+
stop() {
|
|
136
|
+
if (!this.proc || this.dead) return;
|
|
137
|
+
try { this.proc.kill("SIGTERM"); } catch { /* noop */ }
|
|
138
|
+
const p = this.proc;
|
|
139
|
+
setTimeout(() => { try { p.kill("SIGKILL"); } catch { /* noop */ } }, 3000).unref?.();
|
|
140
|
+
this.dead = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- 内部:JSON-RPC/ndjson 传输 ----
|
|
144
|
+
_onStdout(d) {
|
|
145
|
+
this.buf += d;
|
|
146
|
+
let i;
|
|
147
|
+
while ((i = this.buf.indexOf("\n")) >= 0) {
|
|
148
|
+
const line = this.buf.slice(0, i).trim();
|
|
149
|
+
this.buf = this.buf.slice(i + 1);
|
|
150
|
+
if (!line || line.startsWith("Content-Type")) continue;
|
|
151
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
152
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
153
|
+
const p = this.pending.get(msg.id);
|
|
154
|
+
if (p) {
|
|
155
|
+
this.pending.delete(msg.id);
|
|
156
|
+
clearTimeout(p.timer);
|
|
157
|
+
if (msg.error) p.rej(new Error(`ACP ${p.method}: ${msg.error.message}`));
|
|
158
|
+
else p.res(msg.result);
|
|
159
|
+
}
|
|
160
|
+
} else if (msg.method === "session/update") {
|
|
161
|
+
this.onUpdate?.(msg.params?.update);
|
|
162
|
+
} else if (msg.method !== undefined && msg.id !== undefined) {
|
|
163
|
+
// 服务端→客户端请求(如 session/request_permission):必须应答,否则 opencode 永久等待(BUG-13)
|
|
164
|
+
this._onServerRequest(msg);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 服务端请求分发:权限请求按策略应答;未知请求回 -32601 防止对端悬挂 */
|
|
170
|
+
_onServerRequest(msg) {
|
|
171
|
+
if (msg.method === "session/request_permission") {
|
|
172
|
+
const decision = this._decidePermission(msg.params ?? {});
|
|
173
|
+
// ACP 响应形状(opencode 实测):{outcome:{outcome:"selected",optionId}} | {outcome:{outcome:"cancelled"}}
|
|
174
|
+
const result = decision.optionId
|
|
175
|
+
? { outcome: { outcome: "selected", optionId: decision.optionId } }
|
|
176
|
+
: { outcome: { outcome: "cancelled" } };
|
|
177
|
+
try {
|
|
178
|
+
this._write({ jsonrpc: "2.0", id: msg.id, result });
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.error(`[acp] permission 应答写入失败: ${e.message}`);
|
|
181
|
+
}
|
|
182
|
+
try { this.onPermissionEvent?.(decision.event); } catch { /* 监听器异常不得影响 agent */ }
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
console.error(`[acp] 未处理的服务端请求 ${msg.method}(id=${msg.id}) → -32601`);
|
|
186
|
+
try {
|
|
187
|
+
this._write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `tdxd: method not handled: ${msg.method}` } });
|
|
188
|
+
} catch { /* noop */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 依据 permissionPolicy 计算 allow/deny 与所选 optionId */
|
|
192
|
+
_decidePermission(params) {
|
|
193
|
+
const options = Array.isArray(params.options) ? params.options : [];
|
|
194
|
+
const allowOpt = options.find((o) => o?.optionId === "once" || /allow_once/i.test(String(o?.kind ?? "")));
|
|
195
|
+
const denyOpt = options.find((o) => /reject|deny|cancel/i.test(`${o?.optionId ?? ""} ${o?.kind ?? ""}`));
|
|
196
|
+
const command = String(params.toolCall?.rawInput?.command ?? params.toolCall?.title ?? "");
|
|
197
|
+
const verdict = this.permissionPolicy ? this.permissionPolicy(command, params) : "deny";
|
|
198
|
+
const meta = { command, requestId: params.toolCall?.toolCallId ?? null };
|
|
199
|
+
if (verdict === "allow" && allowOpt) {
|
|
200
|
+
return { optionId: allowOpt.optionId, event: { decision: "allow", reason: "policy", ...meta } };
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
optionId: denyOpt?.optionId ?? null,
|
|
204
|
+
event: { decision: "deny", reason: verdict === "allow" ? "no-allow-option" : "policy", ...meta },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
_write(obj) {
|
|
209
|
+
if (!this.alive) throw new Error("ACP process not running");
|
|
210
|
+
this.proc.stdin.write(JSON.stringify(obj) + "\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_notify(method, params) {
|
|
214
|
+
this._write({ jsonrpc: "2.0", method, params });
|
|
215
|
+
return Promise.resolve();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_request(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
219
|
+
const id = this.nextId++;
|
|
220
|
+
return new Promise((res, rej) => {
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
this.pending.delete(id);
|
|
223
|
+
rej(new Error(`ACP ${method} timeout`));
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
this.pending.set(id, { res, rej, method, timer });
|
|
226
|
+
try { this._write({ jsonrpc: "2.0", id, method, params }); }
|
|
227
|
+
catch (e) { clearTimeout(timer); this.pending.delete(id); rej(e); }
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
package/lib/perm.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// 权限策略引擎(纯函数,供 tdxd 与测试复用):
|
|
2
|
+
// - 切分器:引号内/heredoc 正文内不切分;; && || | 换行 为段边界
|
|
3
|
+
// - 命令替换($(...) 与反引号)内容递归过同套规则(深度上限 2)
|
|
4
|
+
// - 授权按「核心命令」(剥环境变量前缀/重定向符号后的 首命令+首路径型操作数)匹配,
|
|
5
|
+
// 使 模型变体重试(加 flag、加 ; echo 等无害段)可命中同一授权
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
export function stripEnvPrefix(seg) {
|
|
9
|
+
return seg.replace(/^[A-Za-z_][A-Za-z0-9_]*=(("[^"]*")|('[^']*')|(\S+))\s+/g, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 把 shell 命令切成顶层段,并抽取命令替换内容(引号/heredoc 感知) */
|
|
13
|
+
export function parseShell(cmd, depth = 0) {
|
|
14
|
+
const s = String(cmd);
|
|
15
|
+
const segments = [];
|
|
16
|
+
const subs = [];
|
|
17
|
+
let cur = "";
|
|
18
|
+
let i = 0;
|
|
19
|
+
let heredocDelim = null;
|
|
20
|
+
let quoteCh = null;
|
|
21
|
+
const flushSeg = () => { const t = cur.trim(); if (t) segments.push(t); cur = ""; };
|
|
22
|
+
while (i < s.length) {
|
|
23
|
+
const c = s[i];
|
|
24
|
+
if (heredocDelim !== null) {
|
|
25
|
+
cur += c;
|
|
26
|
+
if (c === "\n") {
|
|
27
|
+
const lineStart = i + 1;
|
|
28
|
+
const nl = s.indexOf("\n", lineStart);
|
|
29
|
+
const line = s.slice(lineStart, nl === -1 ? s.length : nl).trim();
|
|
30
|
+
if (line === heredocDelim) { heredocDelim = null; i = nl === -1 ? s.length : nl; continue; }
|
|
31
|
+
}
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (quoteCh) {
|
|
36
|
+
if (c === "\\" && quoteCh === '"') { cur += c + (s[i + 1] ?? ""); i += 2; continue; }
|
|
37
|
+
if (c === quoteCh) quoteCh = null;
|
|
38
|
+
cur += c; i++; continue;
|
|
39
|
+
}
|
|
40
|
+
if (c === "'" || c === '"') { quoteCh = c; cur += c; i++; continue; }
|
|
41
|
+
if (c === "\\") { cur += c + (s[i + 1] ?? ""); i += 2; continue; }
|
|
42
|
+
if (c === "<" && s[i + 1] === "<") {
|
|
43
|
+
const m = /^<<-?\s*(?:'([A-Za-z0-9_]+)'|"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))/.exec(s.slice(i));
|
|
44
|
+
if (m) {
|
|
45
|
+
heredocDelim = m[1] ?? m[2] ?? m[3];
|
|
46
|
+
cur += m[0]; i += m[0].length; continue;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (c === "$" && s[i + 1] === "(") {
|
|
50
|
+
let d = 1, j = i + 2;
|
|
51
|
+
while (j < s.length && d > 0) { if (s[j] === "(") d++; else if (s[j] === ")") d--; j++; }
|
|
52
|
+
const inner = s.slice(i + 2, j - 1);
|
|
53
|
+
if (depth < 2 && inner.trim()) subs.push(inner);
|
|
54
|
+
cur += s.slice(i, j); i = j; continue;
|
|
55
|
+
}
|
|
56
|
+
if (c === "`") {
|
|
57
|
+
const j = s.indexOf("`", i + 1);
|
|
58
|
+
const inner = j === -1 ? "" : s.slice(i + 1, j);
|
|
59
|
+
if (depth < 2 && inner.trim()) subs.push(inner);
|
|
60
|
+
const consumed = j === -1 ? s.length : j + 1;
|
|
61
|
+
cur += s.slice(i, consumed); i = consumed; continue;
|
|
62
|
+
}
|
|
63
|
+
if (c === ";" || c === "\n" || c === "|" || c === "\r") {
|
|
64
|
+
const two = s.slice(i, i + 2);
|
|
65
|
+
if (two === "&&" || two === "||" || two === "|&") { flushSeg(); i += 2; continue; }
|
|
66
|
+
flushSeg(); i += c === "\r" && s[i + 1] === "\n" ? 2 : 1; continue;
|
|
67
|
+
}
|
|
68
|
+
cur += c; i++;
|
|
69
|
+
}
|
|
70
|
+
flushSeg();
|
|
71
|
+
return { segments, subs };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 段核心:剥环境变量前缀与重定向符号,取 首命令+首个非 flag 操作数 */
|
|
75
|
+
export function coreOfSegment(segRaw) {
|
|
76
|
+
const seg = stripEnvPrefix(segRaw);
|
|
77
|
+
const toks = seg.match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) ?? [];
|
|
78
|
+
const first = (toks[0] ?? "").replace(/^['"]|['"]$/g, "");
|
|
79
|
+
let operand = "";
|
|
80
|
+
for (let k = 1; k < toks.length; k++) {
|
|
81
|
+
const t = toks[k].replace(/^['"]|['"]$/g, "");
|
|
82
|
+
if (/^(>>|>|<|2>|&>)$/.test(t) || t === "2>&1") continue;
|
|
83
|
+
if (t.startsWith("-")) continue;
|
|
84
|
+
operand = t; break;
|
|
85
|
+
}
|
|
86
|
+
return operand ? `${first} ${operand}` : first;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const permKeyOf = (core) => createHash("sha1").update(String(core).trim()).digest("hex").slice(0, 8);
|
|
90
|
+
|
|
91
|
+
/** 生成命令评估器:每段须命中白名单(剥 env 前缀后匹配)或已授权核心;命令替换递归 */
|
|
92
|
+
export function makeEvaluator(allowRes, defaultMode) {
|
|
93
|
+
const segAllowed = (seg) => {
|
|
94
|
+
if (defaultMode === "allow") return true;
|
|
95
|
+
const stripped = stripEnvPrefix(seg);
|
|
96
|
+
return allowRes.some((re) => re.test(stripped));
|
|
97
|
+
};
|
|
98
|
+
return function evalCommand(cmd, isCoreGranted, depth = 0) {
|
|
99
|
+
const { segments, subs } = parseShell(cmd, depth);
|
|
100
|
+
if (!segments.length && !subs.length) return "deny";
|
|
101
|
+
for (const seg of segments) {
|
|
102
|
+
if (assignmentOnly(seg)) continue; // 纯赋值段:可执行内容在 $()/反引号内,已递归校验
|
|
103
|
+
if (isCoreGranted && isCoreGranted(permKeyOf(coreOfSegment(seg)))) continue;
|
|
104
|
+
if (!segAllowed(seg)) return "deny";
|
|
105
|
+
}
|
|
106
|
+
for (const inner of subs) {
|
|
107
|
+
if (evalCommand(inner, isCoreGranted, depth + 1) === "deny") return "deny";
|
|
108
|
+
}
|
|
109
|
+
return "allow";
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 移除 $(...)(括号配平)与反引号跨度 */
|
|
114
|
+
function removeSubSpans(s) {
|
|
115
|
+
let out = "", i = 0;
|
|
116
|
+
while (i < s.length) {
|
|
117
|
+
if (s[i] === "$" && s[i + 1] === "(") {
|
|
118
|
+
let d = 1, j = i + 2;
|
|
119
|
+
while (j < s.length && d > 0) { if (s[j] === "(") d++; else if (s[j] === ")") d--; j++; }
|
|
120
|
+
out += " "; i = j; continue;
|
|
121
|
+
}
|
|
122
|
+
if (s[i] === "`") { const j = s.indexOf("`", i + 1); out += " "; i = j === -1 ? s.length : j + 1; continue; }
|
|
123
|
+
out += s[i++];
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 纯赋值段判定:去掉替换跨度后只剩 NAME=value 序列(如 BODY=$(cat <<'MD'…)) */
|
|
129
|
+
function assignmentOnly(seg) {
|
|
130
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(seg)) return false;
|
|
131
|
+
return removeSubSpans(seg).replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/g, "").replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*$/, "").trim() === "";
|
|
132
|
+
}
|
package/lib/router.mjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// SessionRouter(D13 会话即进程):
|
|
2
|
+
// conversationKey → { AcpAgent 进程, sessionId, lastActive, envKey }
|
|
3
|
+
// · 新对话 → spawn + session/new
|
|
4
|
+
// · 续接(进程在)→ 复用;进程亡但有 nativeSessionId → respawn + resume
|
|
5
|
+
// · run 凭据变化(envKey 不同)→ 等锁后 respawn + resume(保证子进程 env 新鲜,不打断在途 run)
|
|
6
|
+
// · **同会话互斥(BUG-03)**:conversation 级独占锁——acquire 即持锁,release(run 结束)才解锁;
|
|
7
|
+
// 后到 run 排队等待,超时(busyWaitTimeoutMs,默认 8min > prompt 超时 6min)失败返回。
|
|
8
|
+
// 锁挂在 conversation 而非会话条目上:会话未建立时同样互斥(并发首建竞态)。
|
|
9
|
+
// · 空闲 TTL → 进程回收(sessionId 交由平台 sessionParams 留档)
|
|
10
|
+
// · 并发上限闸(超过即排队等待;创建失败即回收,不占闸——BUG-04)
|
|
11
|
+
import { AcpAgent, envKeyOf } from "./acp.mjs";
|
|
12
|
+
|
|
13
|
+
export class SessionRouter {
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.command = opts.command;
|
|
16
|
+
this.args = opts.args;
|
|
17
|
+
this.workspace = opts.workspace;
|
|
18
|
+
this.idleTtlMs = opts.idleTtlMs ?? 10 * 60_000; // 默认 10 分钟
|
|
19
|
+
this.maxSessions = opts.maxSessions ?? 5;
|
|
20
|
+
this.busyWaitTimeoutMs = opts.busyWaitTimeoutMs ?? 8 * 60_000; // 同会话忙等上限
|
|
21
|
+
this.permissionPolicy = opts.permissionPolicy ?? null; // 透传给 AcpAgent(tdxd 提供)
|
|
22
|
+
this.sessions = new Map(); // key → { agent, sessionId, lastActive, envKey }
|
|
23
|
+
this.waiters = []; // maxSessions 全局闸等待
|
|
24
|
+
this.locks = new Set(); // 持有 conversation 锁的 key(acquire→release)
|
|
25
|
+
this.lockWaiters = new Map(); // key → [wake,...] 锁等待队列
|
|
26
|
+
this._reaper = setInterval(() => this._reap(), 60_000);
|
|
27
|
+
this._reaper.unref?.();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 取得(或建立)一个可用的 {agent, sessionId};返回即持有该 conversation 的锁,run 结束须调 release() */
|
|
31
|
+
async acquire(conversationKey, nativeSessionId, onUpdate, env = null, mcpServers = []) {
|
|
32
|
+
const key = envKeyOf(env);
|
|
33
|
+
await this._lock(conversationKey); // 同会话互斥:先于一切(含凭据检查——不打断在途 run)
|
|
34
|
+
try {
|
|
35
|
+
let entry = this.sessions.get(conversationKey);
|
|
36
|
+
if (entry && entry.agent.alive && entry.envKey === key) {
|
|
37
|
+
await this._waitSlot();
|
|
38
|
+
// BUG-12:重派 acquire 不再刷新 lastActive(防 idleTTL 无限滑动喂僵尸);
|
|
39
|
+
// 仅真实流事件(_touchWrap)与 release(run 真实结束)刷新
|
|
40
|
+
entry.agent.onUpdate = this._touchWrap(entry, onUpdate);
|
|
41
|
+
return { entry, sessionId: entry.sessionId, resumed: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 凭据变化或进程亡 → 回收旧进程,重建(resume 续接)
|
|
45
|
+
if (entry) { entry.agent.stop(); this.sessions.delete(conversationKey); }
|
|
46
|
+
|
|
47
|
+
await this._waitSlot();
|
|
48
|
+
let agent = new AcpAgent({ command: this.command, args: this.args, workspace: this.workspace, env, mcpServers, permissionPolicy: this.permissionPolicy });
|
|
49
|
+
agent.onUpdate = onUpdate;
|
|
50
|
+
let sessionId;
|
|
51
|
+
let resumed = false;
|
|
52
|
+
if (nativeSessionId) {
|
|
53
|
+
try {
|
|
54
|
+
sessionId = await agent.resumeSession(nativeSessionId);
|
|
55
|
+
resumed = true;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// resume 失败(会话不存在/方言不匹配)→ 退化为新会话
|
|
58
|
+
agent.stop();
|
|
59
|
+
const fresh = new AcpAgent({ command: this.command, args: this.args, workspace: this.workspace, env, mcpServers, permissionPolicy: this.permissionPolicy });
|
|
60
|
+
fresh.onUpdate = onUpdate;
|
|
61
|
+
let sid;
|
|
62
|
+
try {
|
|
63
|
+
sid = await fresh.newSession();
|
|
64
|
+
} catch (e2) {
|
|
65
|
+
fresh.stop(); // BUG-04:创建失败即回收,不留僵尸条目
|
|
66
|
+
throw e2;
|
|
67
|
+
}
|
|
68
|
+
const freshEntry = { agent: fresh, sessionId: sid, lastActive: Date.now(), envKey: key };
|
|
69
|
+
freshEntry.agent.onUpdate = this._touchWrap(freshEntry, onUpdate);
|
|
70
|
+
this.sessions.set(conversationKey, freshEntry);
|
|
71
|
+
return { entry: freshEntry, sessionId: sid, resumed: false, note: `resume 失败已重建: ${e.message}` };
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
try {
|
|
75
|
+
sessionId = await agent.newSession();
|
|
76
|
+
} catch (e) {
|
|
77
|
+
agent.stop(); // BUG-04:创建失败即回收
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const newEntry = { agent, sessionId, lastActive: Date.now(), envKey: key };
|
|
82
|
+
newEntry.agent.onUpdate = this._touchWrap(newEntry, onUpdate);
|
|
83
|
+
this.sessions.set(conversationKey, newEntry);
|
|
84
|
+
return { entry: newEntry, sessionId, resumed };
|
|
85
|
+
} catch (e) {
|
|
86
|
+
this._unlock(conversationKey); // 失败路径必须放锁并唤醒下一个
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** BUG-12:lastActive 仅由真实流事件刷新——包装 onUpdate,事件到达即 touch */
|
|
92
|
+
_touchWrap(entry, onUpdate) {
|
|
93
|
+
return (u) => { entry.lastActive = Date.now(); onUpdate?.(u); };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** run 结束:解锁 conversation 并唤醒等待者 */
|
|
97
|
+
release(conversationKey) {
|
|
98
|
+
const entry = this.sessions.get(conversationKey);
|
|
99
|
+
if (entry) entry.lastActive = Date.now();
|
|
100
|
+
this._unlock(conversationKey);
|
|
101
|
+
const next = this.waiters.shift();
|
|
102
|
+
if (next) next();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 丢弃会话条目(BUG-11):prompt 超时/异常后调用——stop 进程 + 删条目,
|
|
106
|
+
* 下轮重派走 respawn+resume 而非复用僵尸;nativeSessionId 由平台留档,续接不受影响。 */
|
|
107
|
+
discard(conversationKey) {
|
|
108
|
+
const entry = this.sessions.get(conversationKey);
|
|
109
|
+
if (entry) { entry.agent.stop(); this.sessions.delete(conversationKey); }
|
|
110
|
+
this.lockWaiters.delete(conversationKey);
|
|
111
|
+
this._unlock(conversationKey);
|
|
112
|
+
const next = this.waiters.shift();
|
|
113
|
+
if (next) next();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_waitSlot() {
|
|
117
|
+
if (this.sessions.size < this.maxSessions) return Promise.resolve();
|
|
118
|
+
return new Promise((res) => this.waiters.push(res));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** conversation 锁:忙则入队,release 唤醒一个(唤醒即同步转交锁,防闯入);超时拒绝 */
|
|
122
|
+
async _lock(conversationKey) {
|
|
123
|
+
if (!this.locks.has(conversationKey)) { this.locks.add(conversationKey); return; }
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
let queue = this.lockWaiters.get(conversationKey);
|
|
126
|
+
if (!queue) { queue = []; this.lockWaiters.set(conversationKey, queue); }
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
const idx = queue.indexOf(wake);
|
|
129
|
+
if (idx >= 0) queue.splice(idx, 1);
|
|
130
|
+
reject(new Error(`tdxd: 同会话忙等超时(${Math.round(this.busyWaitTimeoutMs / 1000)}s),在途 run 未释放: ${conversationKey}`));
|
|
131
|
+
}, this.busyWaitTimeoutMs);
|
|
132
|
+
const wake = () => { clearTimeout(timer); this.locks.add(conversationKey); resolve(); };
|
|
133
|
+
queue.push(wake);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_unlock(conversationKey) {
|
|
138
|
+
this.locks.delete(conversationKey);
|
|
139
|
+
const q = this.lockWaiters.get(conversationKey);
|
|
140
|
+
const wake = q?.shift();
|
|
141
|
+
if (wake) wake(); // wake 内部同步重新加锁(转交)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_reap() {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
for (const [key, entry] of this.sessions) {
|
|
147
|
+
if (now - entry.lastActive > this.idleTtlMs) {
|
|
148
|
+
entry.agent.stop();
|
|
149
|
+
this.sessions.delete(key);
|
|
150
|
+
this.lockWaiters.delete(key);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
stopAll() {
|
|
156
|
+
clearInterval(this._reaper);
|
|
157
|
+
for (const entry of this.sessions.values()) entry.agent.stop();
|
|
158
|
+
this.sessions.clear();
|
|
159
|
+
this.lockWaiters.clear();
|
|
160
|
+
this.locks.clear();
|
|
161
|
+
}
|
|
162
|
+
}
|
package/lib/tpl.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// init 脚手架模板:实例 config / workspace 骨架(AGENTS.md 角色模板 + opencode.json)
|
|
2
|
+
|
|
3
|
+
export function configTpl({ name, displayName, port, workspace, model, url }) {
|
|
4
|
+
return `${JSON.stringify({
|
|
5
|
+
name: displayName,
|
|
6
|
+
port,
|
|
7
|
+
bind: "0.0.0.0",
|
|
8
|
+
workspace,
|
|
9
|
+
command: "opencode",
|
|
10
|
+
commandArgs: [],
|
|
11
|
+
idleTtlMin: 10,
|
|
12
|
+
maxSessions: 5,
|
|
13
|
+
promptTimeoutMs: 360000,
|
|
14
|
+
paperclip: { url },
|
|
15
|
+
permissions: {
|
|
16
|
+
default: "deny",
|
|
17
|
+
allow: [
|
|
18
|
+
{ command: "^(cat|head|tail|ls|stat|file|wc|grep|rg|find|du|df|free|uname|whoami|id|date|nproc|which|env|printenv|pwd|ps)\\b" },
|
|
19
|
+
{ command: "^(node|npm|pnpm|npx|python3?|pip3?|java|javac|mvn|go|rustc|cargo|gcc|g\\+\\+|make|cmake|git)( +--version| +version| +-v)$" },
|
|
20
|
+
{ command: "^git +(status|diff|log|show|branch)\\b" },
|
|
21
|
+
{ command: "^curl +.*(\\\\$PAPERCLIP_API_URL|\\\\$PAPERCLIP_API_BASE|127\\\\.0\\\\.0\\\\.1:3100|localhost:3100)" },
|
|
22
|
+
{ command: "^(jq|echo|cd|sleep|mkdir +-p +/tmp/opencode)\\b" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
pricing: {
|
|
26
|
+
models: {
|
|
27
|
+
[model]: {
|
|
28
|
+
inputPerMillion: 0.6,
|
|
29
|
+
cachedReadPerMillion: 0.15,
|
|
30
|
+
outputPerMillion: 2.5,
|
|
31
|
+
_doc: "占位价(USD/百万token):请按模型官方定价校准",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}, null, 2)}\n`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function opencodeJsonTpl(model) {
|
|
39
|
+
return `${JSON.stringify({ $schema: "https://opencode.ai/config.json", model }, null, 2)}\n`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PERSONAS = {
|
|
43
|
+
manager: `# {NAME}
|
|
44
|
+
|
|
45
|
+
你是团队负责人(Manager)。收到任务时:
|
|
46
|
+
|
|
47
|
+
- 先判断:亲自做、拆分给合适成员、还是需要澄清——不确定就到任务里提问,不要猜
|
|
48
|
+
- 拆分时建子任务(子任务写清背景/验收标准/依赖),设置好阻塞关系再分派
|
|
49
|
+
- 跟踪进度:关注子任务状态,全部完成后汇总收尾、更新父任务
|
|
50
|
+
- 输出简洁:状态行 + 要点列表,明确"已完成/待办/谁负责"
|
|
51
|
+
`,
|
|
52
|
+
coder: `# {NAME}
|
|
53
|
+
|
|
54
|
+
你是开发工程师。收到任务时:
|
|
55
|
+
|
|
56
|
+
- 先读相关代码再动手;改动遵循仓库现有风格与约定
|
|
57
|
+
- 改完必须验证(跑最窄的相关测试/编译),验证不了要明说
|
|
58
|
+
- 提交信息简洁;在任务评论里留下:改了什么、为什么、如何验证
|
|
59
|
+
- 发现超出本任务的问题:记录,不擅自扩大改动面
|
|
60
|
+
`,
|
|
61
|
+
qa: `# {NAME}
|
|
62
|
+
|
|
63
|
+
你是测试工程师。收到任务时:
|
|
64
|
+
|
|
65
|
+
- 明确被测对象与验收标准;先设计用例覆盖正常/边界/异常路径
|
|
66
|
+
- 如实报告:通过/失败逐条列出,失败附复现步骤与期望结果
|
|
67
|
+
- 不掩盖问题,不为了"通过"放宽标准
|
|
68
|
+
`,
|
|
69
|
+
blank: `# {NAME}
|
|
70
|
+
|
|
71
|
+
(在此描述该 Agent 的角色职责与工作规范。)
|
|
72
|
+
`,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function agentsMdTpl(persona, name) {
|
|
76
|
+
return (PERSONAS[persona] ?? PERSONAS.blank).replaceAll("{NAME}", name);
|
|
77
|
+
}
|