deskbuddy 0.1.0 → 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/cli.js +2942 -171
- package/dist/gate.cjs +249 -0
- package/dist/hook.cjs +271 -3
- package/dist/opencode.mjs +329 -0
- package/hook/gate.cjs +249 -0
- package/hook/hook.cjs +271 -3
- package/hook/opencode.d.mts +7 -0
- package/hook/opencode.mjs +329 -0
- package/package.json +5 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// DeskBuddy 的 opencode 插件本体(二期 P9)。
|
|
2
|
+
// `deskbuddy enable opencode` 在 opencode 的插件目录放一个入口文件 import 本文件;
|
|
3
|
+
// 本文件随 hook.cjs / gate.cjs 一起同步到 ~/.deskbuddy/,CLI 升级即生效。
|
|
4
|
+
//
|
|
5
|
+
// 做的事只有翻译:把 opencode 的事件翻成 cc-hook 同构的输入,喂给同目录的 hook.cjs(状态上报)
|
|
6
|
+
// 和 gate.cjs(授权关卡)。状态机、关卡、和设备打交道全在它们和 daemon 里,这里不重复。
|
|
7
|
+
//
|
|
8
|
+
// 实测依据(opencode 1.18.32;1.4.3 远程关卡与窗口确认同样实测通过,spec 2026-09-27-DeskBuddy二期 §P9 O1–O8):
|
|
9
|
+
// - 插件的 `permission.ask` 钩子不会被调用;授权只能从事件流的 permission.asked / permission.replied 拿
|
|
10
|
+
// - `tool.execute.before` 在权限判断之前触发、可以 await 任意久、抛错 = 这次调用失败
|
|
11
|
+
// - `opencode run` 遇到 ask 立即自动拒绝
|
|
12
|
+
// - `config` 钩子原地改 permission 能生效(整对象替换不行)
|
|
13
|
+
//
|
|
14
|
+
// 🔴 远程任务(环境变量 DESKBUDDY_GATE_RUN)的安全模型,三层:
|
|
15
|
+
// ① daemon 起进程前确认插件装在 opencode 会读的位置(daemon/index.ts);
|
|
16
|
+
// ② OPENCODE_CONFIG_CONTENT 把 OC_GATED_KEYS 逐项压成 deny(全局 + build agent 两级)。
|
|
17
|
+
// opencode 按"最后一条匹配的规则"生效且配置是深合并:只写 `*: deny` 挡不住用户配置里排在后面的
|
|
18
|
+
// `bash: allow`(评审 2026-09-28 实测),逐项写才会把同名键的值覆盖掉;
|
|
19
|
+
// ③ 本插件加载后才在 config 里放开、并在 tool.execute.before 里等设备。
|
|
20
|
+
// 残余:用户给清单外的权限名写了 allow,且插件恰好加载失败 —— 那一项会放行。远程模式下本文件任何失败都必须走"拒绝"。
|
|
21
|
+
// 🔴 窗口模式反过来:任何失败都不表态,授权框照常由人处理。
|
|
22
|
+
|
|
23
|
+
import { spawn } from 'node:child_process'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
|
|
27
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
const HOOK = join(HERE, 'hook.cjs')
|
|
29
|
+
const GATE = join(HERE, 'gate.cjs')
|
|
30
|
+
const TOOL = 'opencode'
|
|
31
|
+
|
|
32
|
+
/** 设备允许过的工具调用,后续同一调用的第二个授权框直接代答(O6:rm /tmp/x 会连问 external_directory 和 bash) */
|
|
33
|
+
const APPROVED_CALL_TTL_MS = 60 * 1000
|
|
34
|
+
/** 工具调用参数的缓存时长:tool.execute.before 记下,随后的授权框靠它显示真实命令 */
|
|
35
|
+
const CALL_TTL_MS = 10 * 60 * 1000
|
|
36
|
+
/** hook.cjs 自己有 2 秒上报超时,这里再兜一层:一次卡死不能堵住后面所有状态 */
|
|
37
|
+
const HOOK_KILL_MS = 10 * 1000
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 远程模式要压成 deny、插件加载后再放开的权限名。与 src/daemon/executor.ts 的 OPENCODE_GATED_KEYS 必须一致(有测试)。
|
|
41
|
+
* 新版本 opencode 多出来的权限名不在这里时,只剩 `*` 兜,见顶部"残余"
|
|
42
|
+
*/
|
|
43
|
+
export const OC_GATED_KEYS = [
|
|
44
|
+
'*', 'bash', 'edit', 'write', 'read', 'glob', 'grep', 'list', 'task', 'webfetch', 'websearch', 'codesearch',
|
|
45
|
+
'external_directory', 'todowrite', 'todoread', 'lsp', 'skill', 'doom_loop', 'patch', 'apply_patch',
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
function runIdFromEnv() {
|
|
49
|
+
const v = process.env.DESKBUDDY_GATE_RUN || ''
|
|
50
|
+
return /^[0-9a-zA-Z_-]{1,64}$/.test(v) ? v : ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `opencode run` 是非交互(O2),其余(TUI、`opencode <目录>`)当窗口。编译版 argv = [bun, 入口, 子命令…]。
|
|
55
|
+
* 子命令前可能有全局参数(`--print-logs run`),所以看 `--` 之前有没有 `run`;误判成非交互只是少一个确认渠道
|
|
56
|
+
*/
|
|
57
|
+
export function isHeadless(argv = process.argv) {
|
|
58
|
+
const args = argv.slice(2)
|
|
59
|
+
const end = args.indexOf('--')
|
|
60
|
+
return (end >= 0 ? args.slice(0, end) : args).includes('run')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** opencode 工具 → cc-hook 同构的 tool_name / tool_input(daemon 的 classify 按 Claude Code 的工具名判断) */
|
|
64
|
+
export function toCcTool(tool, args) {
|
|
65
|
+
const a = args && typeof args === 'object' ? args : {}
|
|
66
|
+
const s = (v) => (typeof v === 'string' ? v : '')
|
|
67
|
+
switch (tool) {
|
|
68
|
+
case 'bash':
|
|
69
|
+
return { tool_name: 'Bash', tool_input: { command: s(a.command) } }
|
|
70
|
+
case 'read':
|
|
71
|
+
return { tool_name: 'Read', tool_input: { file_path: s(a.filePath) } }
|
|
72
|
+
case 'edit':
|
|
73
|
+
return { tool_name: 'Edit', tool_input: { file_path: s(a.filePath) } }
|
|
74
|
+
case 'write':
|
|
75
|
+
return { tool_name: 'Write', tool_input: { file_path: s(a.filePath) } }
|
|
76
|
+
case 'glob':
|
|
77
|
+
return { tool_name: 'Glob', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
|
|
78
|
+
case 'grep':
|
|
79
|
+
return { tool_name: 'Grep', tool_input: { pattern: s(a.pattern), path: s(a.path) } }
|
|
80
|
+
case 'list':
|
|
81
|
+
return { tool_name: 'LS', tool_input: { path: s(a.path) } }
|
|
82
|
+
case 'webfetch':
|
|
83
|
+
return { tool_name: 'WebFetch', tool_input: { url: s(a.url) } }
|
|
84
|
+
case 'task':
|
|
85
|
+
return { tool_name: 'Task', tool_input: {} }
|
|
86
|
+
case 'todowrite':
|
|
87
|
+
case 'todoread':
|
|
88
|
+
return { tool_name: 'TodoWrite', tool_input: {} }
|
|
89
|
+
case 'patch':
|
|
90
|
+
case 'apply_patch':
|
|
91
|
+
return { tool_name: 'apply_patch', tool_input: { patch: s(a.patchText) || s(a.patch) || s(a.input) } }
|
|
92
|
+
default:
|
|
93
|
+
// 不认识的工具(MCP 等)原名原参数交出去:classify 对不认识的一律推设备
|
|
94
|
+
return { tool_name: String(tool || ''), tool_input: a }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** permission.asked → PermissionRequest 的 tool_name / tool_input,给设备卡片生成摘要 */
|
|
99
|
+
export function askToCc(props) {
|
|
100
|
+
const md = (props && props.metadata) || {}
|
|
101
|
+
if (typeof md.command === 'string' && md.command) return { tool_name: 'Bash', tool_input: { command: md.command } }
|
|
102
|
+
const file = md.filepath || md.filePath
|
|
103
|
+
if (typeof file === 'string' && file) return { tool_name: 'Edit', tool_input: { file_path: file } }
|
|
104
|
+
const patterns = Array.isArray(props && props.patterns) ? props.patterns.join(' ') : ''
|
|
105
|
+
return { tool_name: String((props && props.permission) || 'permission'), tool_input: { path: patterns } }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 远程模式:关卡改由 tool.execute.before 把,放开权限。🔴 必须原地改(整对象替换不生效,O8)。
|
|
110
|
+
* - OC_GATED_KEYS 是 daemon 压成 deny 的,放成 allow(用户原来在这些键上写的值已被覆盖,找不回来 —— 每一步都要过设备)
|
|
111
|
+
* - 其余键上的 ask 放成 allow(run 下 ask = 立即拒),用户显式的 deny 保留
|
|
112
|
+
* 全局与 build agent 两级都放(daemon 两级都压了)
|
|
113
|
+
*/
|
|
114
|
+
export function openPermissions(cfg) {
|
|
115
|
+
if (!cfg || typeof cfg !== 'object') return
|
|
116
|
+
const open = (perm) => {
|
|
117
|
+
const walk = (obj) => {
|
|
118
|
+
for (const k of Object.keys(obj)) {
|
|
119
|
+
const v = obj[k]
|
|
120
|
+
if (v === 'ask') obj[k] = 'allow'
|
|
121
|
+
else if (v && typeof v === 'object') walk(v)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
walk(perm)
|
|
125
|
+
for (const k of OC_GATED_KEYS) perm[k] = 'allow'
|
|
126
|
+
}
|
|
127
|
+
if (!cfg.permission || typeof cfg.permission !== 'object') cfg.permission = {}
|
|
128
|
+
open(cfg.permission)
|
|
129
|
+
const build = cfg.agent && cfg.agent.build
|
|
130
|
+
if (build && build.permission && typeof build.permission === 'object') open(build.permission)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 活着的子进程;opencode 退出时一并结束,不留孤儿 gate 挂着设备卡片(评审) */
|
|
134
|
+
const LIVE = new Set()
|
|
135
|
+
process.on('exit', () => {
|
|
136
|
+
for (const c of LIVE) {
|
|
137
|
+
try {
|
|
138
|
+
c.kill('SIGTERM')
|
|
139
|
+
} catch (_) {
|
|
140
|
+
/* 已经退了 */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** 起 node 子进程跑 hook.cjs / gate.cjs,stdin 喂 JSON。返回子进程句柄与结束结果(起不来 = code -1)。killMs > 0 时到点结束 */
|
|
146
|
+
function startChild(node, script, args, input, killMs = 0) {
|
|
147
|
+
let child = null
|
|
148
|
+
const done = new Promise((resolve) => {
|
|
149
|
+
let out = ''
|
|
150
|
+
let err = ''
|
|
151
|
+
try {
|
|
152
|
+
child = spawn(node, [script, ...args], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
153
|
+
} catch (e) {
|
|
154
|
+
resolve({ code: -1, out, err: String(e) })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
LIVE.add(child)
|
|
158
|
+
const timer = killMs > 0 ? setTimeout(() => child.kill('SIGKILL'), killMs) : null
|
|
159
|
+
const finish = (r) => {
|
|
160
|
+
LIVE.delete(child)
|
|
161
|
+
if (timer) clearTimeout(timer)
|
|
162
|
+
resolve(r)
|
|
163
|
+
}
|
|
164
|
+
child.stdout.on('data', (c) => (out += c))
|
|
165
|
+
child.stderr.on('data', (c) => (err += c))
|
|
166
|
+
child.on('error', (e) => finish({ code: -1, out, err: String(e) }))
|
|
167
|
+
child.on('close', (code) => finish({ code, out, err }))
|
|
168
|
+
child.stdin.on('error', () => {})
|
|
169
|
+
child.stdin.end(JSON.stringify(input))
|
|
170
|
+
})
|
|
171
|
+
return { child, done }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const runChild = (node, script, args, input, killMs) => startChild(node, script, args, input, killMs).done
|
|
175
|
+
|
|
176
|
+
export function make(opts = {}) {
|
|
177
|
+
return async function DeskBuddy(ctx) {
|
|
178
|
+
const node = process.env.DESKBUDDY_NODE || opts.node || 'node'
|
|
179
|
+
const client = ctx && ctx.client
|
|
180
|
+
const cwd = (ctx && (ctx.directory || ctx.worktree)) || process.cwd()
|
|
181
|
+
const runId = runIdFromEnv()
|
|
182
|
+
const headless = isHeadless()
|
|
183
|
+
|
|
184
|
+
// ---- 状态上报:串行喂 hook.cjs,保证 PreToolUse / PostToolUse / Stop 的先后 ----
|
|
185
|
+
let queue = Promise.resolve()
|
|
186
|
+
const report = (event, sessionID, extra) => {
|
|
187
|
+
if (!sessionID) return
|
|
188
|
+
const input = { hook_event_name: event, session_id: sessionID, cwd, ...extra }
|
|
189
|
+
queue = queue.then(() => runChild(node, HOOK, [TOOL], input, HOOK_KILL_MS)).catch(() => {})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- 窗口确认 ----
|
|
193
|
+
const pending = new Map() // requestID → gate 子进程
|
|
194
|
+
const approvedCalls = new Map() // callID → 允许时间
|
|
195
|
+
const calls = new Map() // callID → { cc, at }:这次工具调用的真实参数
|
|
196
|
+
const prune = () => {
|
|
197
|
+
const now = Date.now()
|
|
198
|
+
for (const [k, v] of calls) if (now - v.at > CALL_TTL_MS) calls.delete(k)
|
|
199
|
+
for (const [k, at] of approvedCalls) if (now - at > APPROVED_CALL_TTL_MS) approvedCalls.delete(k)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const reply = async (sessionID, id, response) => {
|
|
203
|
+
try {
|
|
204
|
+
await client.postSessionIdPermissionsPermissionId({ path: { id: sessionID, permissionID: id }, body: { response } })
|
|
205
|
+
} catch (_) {
|
|
206
|
+
/* 已被人答复过等,无所谓 */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const toast = async (message) => {
|
|
211
|
+
try {
|
|
212
|
+
await client.tui.showToast({ body: { message, variant: 'info' } })
|
|
213
|
+
} catch (_) {
|
|
214
|
+
/* 没有 TUI(web / acp)就不提示 */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const onAsked = async (p) => {
|
|
219
|
+
const { id, sessionID } = p || {}
|
|
220
|
+
if (!id || !sessionID) return
|
|
221
|
+
prune()
|
|
222
|
+
const callID = p.tool && p.tool.callID
|
|
223
|
+
// 🔴 卡片用这次工具调用的真实参数(tool.execute.before 先于授权触发,O3)。
|
|
224
|
+
// external_directory 的 ask 自己不带命令,按它出卡片设备只看到"访问 /tmp/*"(评审)
|
|
225
|
+
const call = callID ? calls.get(callID) : null
|
|
226
|
+
const cc = call ? call.cc : askToCc(p)
|
|
227
|
+
report('PermissionRequest', sessionID, cc)
|
|
228
|
+
if (runId || headless || !client) return
|
|
229
|
+
// 同一调用的后续授权框直接代答:只在设备那张卡显示的就是真实参数时(approvedCalls 只在那时写入)
|
|
230
|
+
const at = callID && approvedCalls.get(callID)
|
|
231
|
+
if (at && Date.now() - at < APPROVED_CALL_TTL_MS) {
|
|
232
|
+
await reply(sessionID, id, 'once')
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc }
|
|
236
|
+
const { child, done } = startChild(node, GATE, ['--window', '--tool', TOOL], input)
|
|
237
|
+
if (child) pending.set(id, child)
|
|
238
|
+
const r = await done
|
|
239
|
+
pending.delete(id)
|
|
240
|
+
if (r.code !== 0 || !r.out) return
|
|
241
|
+
let out
|
|
242
|
+
try {
|
|
243
|
+
out = JSON.parse(r.out)
|
|
244
|
+
} catch (_) {
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const d = out && out.hookSpecificOutput && out.hookSpecificOutput.decision
|
|
248
|
+
if (!d) return
|
|
249
|
+
if (d.behavior === 'allow') {
|
|
250
|
+
if (callID && call) approvedCalls.set(callID, Date.now())
|
|
251
|
+
await reply(sessionID, id, 'once')
|
|
252
|
+
} else if (d.behavior === 'deny') {
|
|
253
|
+
await reply(sessionID, id, 'reject')
|
|
254
|
+
} else return
|
|
255
|
+
if (typeof out.systemMessage === 'string' && out.systemMessage) await toast(out.systemMessage)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const onReplied = (p) => {
|
|
259
|
+
// 人先在窗口里答了:结束 gate 子进程,daemon 随之撤掉设备上的卡片
|
|
260
|
+
const child = p && pending.get(p.requestID)
|
|
261
|
+
if (child) {
|
|
262
|
+
pending.delete(p.requestID)
|
|
263
|
+
try {
|
|
264
|
+
child.kill('SIGTERM')
|
|
265
|
+
} catch (_) {
|
|
266
|
+
/* 已经退了 */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
config: async (cfg) => {
|
|
273
|
+
if (runId) openPermissions(cfg)
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
'chat.message': async (input) => {
|
|
277
|
+
report('UserPromptSubmit', input && input.sessionID)
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
'tool.execute.before': async (input, output) => {
|
|
281
|
+
const cc = toCcTool(input && input.tool, output && output.args)
|
|
282
|
+
if (input && input.callID) calls.set(input.callID, { cc, at: Date.now() })
|
|
283
|
+
report('PreToolUse', input && input.sessionID, cc)
|
|
284
|
+
if (!runId) return
|
|
285
|
+
// 远程关卡:等设备。任何失败都拒(见顶部)
|
|
286
|
+
const r = await runChild(node, GATE, ['--run', runId], {
|
|
287
|
+
hook_event_name: 'PreToolUse',
|
|
288
|
+
session_id: (input && input.sessionID) || '',
|
|
289
|
+
cwd,
|
|
290
|
+
...cc,
|
|
291
|
+
})
|
|
292
|
+
let decision = ''
|
|
293
|
+
try {
|
|
294
|
+
decision = JSON.parse(r.out).hookSpecificOutput.permissionDecision
|
|
295
|
+
} catch (_) {
|
|
296
|
+
decision = ''
|
|
297
|
+
}
|
|
298
|
+
if (r.code === 0 && decision === 'allow') return
|
|
299
|
+
throw new Error((r.err || '').trim() || '用户在设备上拒绝了这一步')
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
'tool.execute.after': async (input) => {
|
|
303
|
+
if (input && input.callID) calls.delete(input.callID)
|
|
304
|
+
approvedCalls.delete(input && input.callID)
|
|
305
|
+
const cc = toCcTool(input && input.tool, input && input.args)
|
|
306
|
+
report('PostToolUse', input && input.sessionID, cc)
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
event: async ({ event }) => {
|
|
310
|
+
const p = (event && event.properties) || {}
|
|
311
|
+
switch (event && event.type) {
|
|
312
|
+
case 'permission.asked':
|
|
313
|
+
onAsked(p).catch(() => {})
|
|
314
|
+
break
|
|
315
|
+
case 'permission.replied':
|
|
316
|
+
onReplied(p)
|
|
317
|
+
break
|
|
318
|
+
case 'session.idle':
|
|
319
|
+
report('Stop', p.sessionID)
|
|
320
|
+
break
|
|
321
|
+
case 'session.deleted':
|
|
322
|
+
report('SessionEnd', (p.info && p.info.id) || p.sessionID)
|
|
323
|
+
break
|
|
324
|
+
default:
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
package/hook/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}`))
|