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/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}`))