deskbuddy 0.0.1 → 0.2.0-beta.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 +44 -8
- package/dist/cli.js +3760 -0
- package/dist/gate.cjs +249 -0
- package/dist/hook.cjs +922 -0
- package/dist/opencode.mjs +329 -0
- package/docs/INSTALL.md +152 -0
- package/hook/gate.cjs +249 -0
- package/hook/hook.cjs +922 -0
- package/hook/opencode.d.mts +7 -0
- package/hook/opencode.mjs +329 -0
- package/package.json +23 -3
- package/bin/deskbuddy.js +0 -9
package/dist/gate.cjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
// 远程执行的「授权关卡」。daemon 起智能体时把它挂成 PreToolUse hook:
|
|
4
|
+
// node gate.cjs --run <runId>
|
|
5
|
+
// 智能体每次要调工具都会先跑一次它。它只做一件事:把 hook 输入转给 daemon,
|
|
6
|
+
// 把 daemon 的决定翻译成 hook 输出。分类、等待、和设备打交道全在 daemon 里。
|
|
7
|
+
//
|
|
8
|
+
// 🔴 铁律:**任何失败都 exit 2**。
|
|
9
|
+
// 实测(2026-09-23,Qoder CN 1.1.61):hook 崩溃、HTTP hook 连不上,智能体【照常执行】。
|
|
10
|
+
// Qoder 必须开 bypass_permissions 才能用,这时本脚本就是唯一的关卡 ——
|
|
11
|
+
// 一个没接住的异常、一次 exit 0 的兜底,就等于把设备授权整个绕过去。
|
|
12
|
+
// exit 2 是四家公认的「阻止这次工具调用」,实测能拦住。
|
|
13
|
+
//
|
|
14
|
+
// 🔴 与 hook.cjs 相反,这里【不设超时】:用户可能过很久才在设备上按下允许,
|
|
15
|
+
// 等待就是它的本职。超时由智能体那边的 hook timeout 兜(daemon 配 7 天)。
|
|
16
|
+
//
|
|
17
|
+
// 🔴 `--window` 模式(S10)的兜底方向**相反**:任何失败都 exit 0 且不输出。
|
|
18
|
+
// 那是用户自己窗口里的 PermissionRequest hook,设备只是多一个答复的地方;不表态 = 授权框照常由人处理。
|
|
19
|
+
// PermissionRequest 下 exit 2 = 拒绝 —— 沿用上面的铁律,daemon 一挂,用户窗口里每个授权都会被自动拒掉。
|
|
20
|
+
//
|
|
21
|
+
// 只 require Node 内置模块,理由同 hook.cjs 顶部 ①。
|
|
22
|
+
const fs = require('node:fs')
|
|
23
|
+
const os = require('node:os')
|
|
24
|
+
const path = require('node:path')
|
|
25
|
+
const http = require('node:http')
|
|
26
|
+
const { execFileSync } = require('node:child_process')
|
|
27
|
+
|
|
28
|
+
const DECISIONS = new Set(['allow', 'deny', 'defer'])
|
|
29
|
+
const WINDOW = process.argv.includes('--window')
|
|
30
|
+
|
|
31
|
+
function fail(why) {
|
|
32
|
+
// 窗口模式:不表态,见顶部
|
|
33
|
+
if (WINDOW) process.exit(0)
|
|
34
|
+
try {
|
|
35
|
+
process.stderr.write(`DeskBuddy 授权关卡:${why},已阻止这次操作\n`)
|
|
36
|
+
} catch (_) {
|
|
37
|
+
/* stderr 都写不了也必须继续走到 exit 2 */
|
|
38
|
+
}
|
|
39
|
+
process.exit(2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function deskbuddyHome() {
|
|
43
|
+
return process.env.DESKBUDDY_HOME || path.join(os.homedir(), '.deskbuddy')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function argRun(argv) {
|
|
47
|
+
const i = argv.indexOf('--run')
|
|
48
|
+
const v = i >= 0 ? argv[i + 1] : ''
|
|
49
|
+
return typeof v === 'string' && /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStdin() {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let buf = ''
|
|
55
|
+
process.stdin.setEncoding('utf8')
|
|
56
|
+
process.stdin.on('data', (c) => (buf += c))
|
|
57
|
+
process.stdin.on('end', () => resolve(buf))
|
|
58
|
+
process.stdin.on('error', reject)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function post(port, token, body) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const req = http.request(
|
|
65
|
+
{
|
|
66
|
+
host: '127.0.0.1',
|
|
67
|
+
port,
|
|
68
|
+
method: 'POST',
|
|
69
|
+
path: '/gate',
|
|
70
|
+
headers: {
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
'content-length': Buffer.byteLength(body),
|
|
73
|
+
'x-gate-token': token,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
(res) => {
|
|
77
|
+
let data = ''
|
|
78
|
+
res.setEncoding('utf8')
|
|
79
|
+
res.on('data', (c) => (data += c))
|
|
80
|
+
res.on('end', () => resolve({ status: res.statusCode, data }))
|
|
81
|
+
res.on('error', reject)
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
req.on('error', reject)
|
|
85
|
+
req.end(body)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 这个 hook 是不是由【交互窗口】里的 Claude Code 触发的。
|
|
91
|
+
* 🔴 实测(2026-09-24,2.1.259):`claude -p` 碰到要授权的工具**也会**触发 PermissionRequest,
|
|
92
|
+
* 而 hook 输入里没有区分字段。不拦的话,用户脚本 / IDE 插件 / SDK 里的 claude 会被推到设备上一直挂着。
|
|
93
|
+
* 做法:沿父进程找到 claude,启动参数里有 -p / --print / stream-json 的一律当非交互。找不到也当非交互
|
|
94
|
+
*/
|
|
95
|
+
/** `--tool <name>`(二期 P8:窗口模式不只 Claude Code)。缺省 claude_code,兼容已装出去的 hook 命令 */
|
|
96
|
+
function argTool(argv) {
|
|
97
|
+
const i = argv.indexOf('--tool')
|
|
98
|
+
const v = i >= 0 ? argv[i + 1] : ''
|
|
99
|
+
return typeof v === 'string' && /^[a-z_]{1,32}$/.test(v) ? v : 'claude_code'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 按智能体判断是不是交互窗口。
|
|
104
|
+
* - Qoder CN(2026-09-27 实测):桌面端弹授权框时 hook 的父进程就是 `Qoder CN.app` 主进程;
|
|
105
|
+
* daemon 起的远程任务走 worker(node),父进程链上没有它
|
|
106
|
+
* - Claude Code:见 isInteractiveClaude
|
|
107
|
+
*/
|
|
108
|
+
function isInteractiveAgent(tool) {
|
|
109
|
+
if (tool === 'qoder') return hasAncestor((comm) => comm.includes('/Qoder CN.app/'))
|
|
110
|
+
if (tool === 'claude_code') return isInteractiveClaude()
|
|
111
|
+
// opencode 的窗口确认由插件发起,插件已排除 `opencode run`(hook/opencode.mjs isHeadless)
|
|
112
|
+
if (tool === 'opencode') return true
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasAncestor(match) {
|
|
117
|
+
let pid = process.ppid
|
|
118
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
119
|
+
let line
|
|
120
|
+
try {
|
|
121
|
+
line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
122
|
+
} catch (_) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
const m = line.match(/^(\d+)\s+(.*)$/)
|
|
126
|
+
if (!m) return false
|
|
127
|
+
if (match(m[2])) return true
|
|
128
|
+
pid = Number(m[1])
|
|
129
|
+
}
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isInteractiveClaude() {
|
|
134
|
+
let pid = process.ppid
|
|
135
|
+
for (let i = 0; i < 8 && pid > 1; i++) {
|
|
136
|
+
let line
|
|
137
|
+
try {
|
|
138
|
+
line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
139
|
+
} catch (_) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
const m = line.match(/^(\d+)\s+(.*)$/)
|
|
143
|
+
if (!m) return false
|
|
144
|
+
if (path.basename(m[2]) === 'claude') {
|
|
145
|
+
let args = ''
|
|
146
|
+
try {
|
|
147
|
+
args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
148
|
+
} catch (_) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
const tokens = args.trim().split(/\s+/)
|
|
152
|
+
// 短参数可能合写(`-cp`);args 里 argv[0] 之后才算参数
|
|
153
|
+
return !tokens.slice(1).some(
|
|
154
|
+
(t) => t === '--print' || /^-[a-zA-Z]*p[a-zA-Z]*$/.test(t) || t === 'stream-json' || t.startsWith('--output-format=stream')
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
pid = Number(m[1])
|
|
158
|
+
}
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 窗口模式:daemon 回 allow / deny 才表态,其余一律不输出、exit 0 */
|
|
163
|
+
async function windowMain() {
|
|
164
|
+
// DESKBUDDY_GATE_ASSUME_INTERACTIVE 只给测试用:1 当交互、0 当非交互,其余真去查进程
|
|
165
|
+
const tool = argTool(process.argv)
|
|
166
|
+
const forced = process.env.DESKBUDDY_GATE_ASSUME_INTERACTIVE
|
|
167
|
+
if (forced === '0' || (forced !== '1' && !isInteractiveAgent(tool))) return fail()
|
|
168
|
+
const conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
|
|
169
|
+
if (!conf || !Number.isInteger(conf.port) || typeof conf.token !== 'string' || !conf.token) return fail()
|
|
170
|
+
const hook = JSON.parse(await readStdin())
|
|
171
|
+
const res = await post(conf.port, conf.token, JSON.stringify({ mode: 'window', tool, hook }))
|
|
172
|
+
if (res.status !== 200) return fail()
|
|
173
|
+
const out = JSON.parse(res.data)
|
|
174
|
+
if (!out || (out.decision !== 'allow' && out.decision !== 'deny')) return fail()
|
|
175
|
+
const decision =
|
|
176
|
+
out.decision === 'allow'
|
|
177
|
+
? Array.isArray(out.updatedPermissions) && out.updatedPermissions.length > 0
|
|
178
|
+
? { behavior: 'allow', updatedPermissions: out.updatedPermissions }
|
|
179
|
+
: { behavior: 'allow' }
|
|
180
|
+
: { behavior: 'deny', message: (typeof out.reason === 'string' && out.reason) || '用户在设备上拒绝了这一步' }
|
|
181
|
+
const body = { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
|
|
182
|
+
// 窗口里告诉人是在哪台设备上处理的
|
|
183
|
+
if (typeof out.notice === 'string' && out.notice) body.systemMessage = out.notice.slice(0, 300)
|
|
184
|
+
process.stdout.write(JSON.stringify(body), () => process.exit(0))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function main() {
|
|
188
|
+
if (WINDOW) return windowMain()
|
|
189
|
+
const runId = argRun(process.argv)
|
|
190
|
+
if (!runId) return fail('缺少 --run')
|
|
191
|
+
|
|
192
|
+
let conf
|
|
193
|
+
try {
|
|
194
|
+
conf = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'gate.json'), 'utf8'))
|
|
195
|
+
} catch (_) {
|
|
196
|
+
return fail('找不到守护进程(gate.json 读不到)')
|
|
197
|
+
}
|
|
198
|
+
if (!conf || !Number.isInteger(conf.port) || typeof conf.token !== 'string' || !conf.token) {
|
|
199
|
+
return fail('gate.json 内容不对')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let hook
|
|
203
|
+
try {
|
|
204
|
+
hook = JSON.parse(await readStdin())
|
|
205
|
+
} catch (_) {
|
|
206
|
+
return fail('读不懂 hook 输入')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let res
|
|
210
|
+
try {
|
|
211
|
+
res = await post(conf.port, conf.token, JSON.stringify({ runId, hook }))
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return fail(`连不上守护进程(${(e && e.code) || 'error'})`)
|
|
214
|
+
}
|
|
215
|
+
if (res.status !== 200) return fail(`守护进程返回 ${res.status}`)
|
|
216
|
+
|
|
217
|
+
let out
|
|
218
|
+
try {
|
|
219
|
+
out = JSON.parse(res.data)
|
|
220
|
+
} catch (_) {
|
|
221
|
+
return fail('守护进程的回复读不懂')
|
|
222
|
+
}
|
|
223
|
+
if (!out || !DECISIONS.has(out.decision)) return fail('守护进程给了不认识的决定')
|
|
224
|
+
|
|
225
|
+
const reason = typeof out.reason === 'string' ? out.reason : ''
|
|
226
|
+
|
|
227
|
+
// 🔴 拒绝一律走 stderr + exit 2,不走 JSON(评审 S5):exit 2 是四家公认的"拦下",
|
|
228
|
+
// 而 JSON 那条路要是 stdout 没写完进程就退了,智能体拿到的是"exit 0 + 没有决定",
|
|
229
|
+
// Qoder 在 bypass 下会当成放行。拒绝的方向必须不依赖任何一次写入成功
|
|
230
|
+
if (out.decision === 'deny') {
|
|
231
|
+
process.stderr.write(`${reason || '用户在设备上拒绝了这一步'}\n`, () => process.exit(2))
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const payload = JSON.stringify({
|
|
236
|
+
hookSpecificOutput: {
|
|
237
|
+
hookEventName: 'PreToolUse',
|
|
238
|
+
permissionDecision: out.decision,
|
|
239
|
+
permissionDecisionReason: reason,
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
// 等写完再退:管道写入是异步的,写一半就 exit 的话 allow / defer 会丢
|
|
243
|
+
process.stdout.write(payload, (err) => (err ? fail('写 hook 输出失败') : process.exit(0)))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 兜住一切没想到的异常 —— 见顶部铁律
|
|
247
|
+
process.on('uncaughtException', (e) => fail(`内部错误 ${(e && e.message) || e}`))
|
|
248
|
+
process.on('unhandledRejection', (e) => fail(`内部错误 ${(e && e.message) || e}`))
|
|
249
|
+
main().catch((e) => fail(`内部错误 ${(e && e.message) || e}`))
|