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
package/hook/hook.cjs
CHANGED
|
@@ -22,6 +22,8 @@ const { execFileSync } = require('node:child_process')
|
|
|
22
22
|
const DEFAULT_API_BASE = process.env.DESKBUDDY_API_BASE || 'https://api.shifengintelli.com'
|
|
23
23
|
const KEEPALIVE_MS = 2 * 60 * 1000
|
|
24
24
|
const MAX_SESSIONS = 20
|
|
25
|
+
/** 状态文件里保留的最近事件条数,与 src/core/state.ts 的 RECENT_MAX 一致 */
|
|
26
|
+
const RECENT_MAX = 8
|
|
25
27
|
const REPORT_TIMEOUT_MS = 2000
|
|
26
28
|
/** ServiceApi 版本,不是包路径里的 v2 */
|
|
27
29
|
const GATEWAY_VERSION = '1.0'
|
|
@@ -63,7 +65,16 @@ const FALLBACK_REGISTRY = {
|
|
|
63
65
|
format: 'cc-hook',
|
|
64
66
|
processNames: ['claude'],
|
|
65
67
|
titleSources: ['cc-ai-title', 'git-branch', 'project'],
|
|
66
|
-
events: {
|
|
68
|
+
events: {
|
|
69
|
+
UserPromptSubmit: 'watch',
|
|
70
|
+
PreToolUse: 'watch',
|
|
71
|
+
PostToolUse: 'watch',
|
|
72
|
+
// 工具跑失败时 Claude Code 发这个而不是 PostToolUse;窗口待批准撤卡也要认它
|
|
73
|
+
PostToolUseFailure: 'watch',
|
|
74
|
+
Stop: 'done',
|
|
75
|
+
// 窗口里弹授权框的那一刻就是待确认(S10),不等之后那条权限通知
|
|
76
|
+
PermissionRequest: 'ask_approval',
|
|
77
|
+
},
|
|
67
78
|
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
68
79
|
},
|
|
69
80
|
codex: {
|
|
@@ -81,6 +92,46 @@ const FALLBACK_REGISTRY = {
|
|
|
81
92
|
},
|
|
82
93
|
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
83
94
|
},
|
|
95
|
+
workbuddy: {
|
|
96
|
+
format: 'cc-hook',
|
|
97
|
+
processNames: ['WorkBuddy'],
|
|
98
|
+
titleSources: ['git-branch', 'project'],
|
|
99
|
+
events: {
|
|
100
|
+
UserPromptSubmit: 'watch',
|
|
101
|
+
PreToolUse: 'watch',
|
|
102
|
+
PostToolUse: 'watch',
|
|
103
|
+
Stop: 'done',
|
|
104
|
+
PermissionRequest: 'ask_approval',
|
|
105
|
+
},
|
|
106
|
+
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
107
|
+
},
|
|
108
|
+
qoder: {
|
|
109
|
+
format: 'cc-hook',
|
|
110
|
+
processNames: ['Qoder CN'],
|
|
111
|
+
titleSources: ['git-branch', 'project'],
|
|
112
|
+
events: {
|
|
113
|
+
UserPromptSubmit: 'watch',
|
|
114
|
+
PreToolUse: 'watch',
|
|
115
|
+
PostToolUse: 'watch',
|
|
116
|
+
Stop: 'done',
|
|
117
|
+
PermissionRequest: 'ask_approval',
|
|
118
|
+
},
|
|
119
|
+
lifecycle: { SessionStart: 'ignore', SessionEnd: 'remove' },
|
|
120
|
+
},
|
|
121
|
+
opencode: {
|
|
122
|
+
format: 'oc-plugin',
|
|
123
|
+
processNames: ['opencode'],
|
|
124
|
+
// opencode 自带的会话标题是模型对提问的摘要,违反隐私约束(spec §6.4),不用
|
|
125
|
+
titleSources: ['git-branch', 'project'],
|
|
126
|
+
events: {
|
|
127
|
+
UserPromptSubmit: 'watch',
|
|
128
|
+
PreToolUse: 'watch',
|
|
129
|
+
PostToolUse: 'watch',
|
|
130
|
+
Stop: 'done',
|
|
131
|
+
PermissionRequest: 'ask_approval',
|
|
132
|
+
},
|
|
133
|
+
lifecycle: { SessionEnd: 'remove' },
|
|
134
|
+
},
|
|
84
135
|
},
|
|
85
136
|
}
|
|
86
137
|
|
|
@@ -429,6 +480,7 @@ function buildSnapshot(states, tool, cfg, nowMs) {
|
|
|
429
480
|
state: s.state,
|
|
430
481
|
startedAt: s.startedAt,
|
|
431
482
|
updatedAt: s.updatedAt,
|
|
483
|
+
interactive: s.interactive === true,
|
|
432
484
|
}))
|
|
433
485
|
return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions }
|
|
434
486
|
}
|
|
@@ -554,6 +606,172 @@ async function reportIfAuthorized(tool, nowMs) {
|
|
|
554
606
|
await postReport(cfg, buildSnapshot(readAliveStates(tool), tool, cfg, nowMs))
|
|
555
607
|
}
|
|
556
608
|
|
|
609
|
+
/**
|
|
610
|
+
* 工具调用指纹前 16 位,与 src/daemon/gate/approvals.ts 的 fingerprint 同算法。
|
|
611
|
+
* daemon 靠它认出"跑完的正是设备上等着的那一步",并行的同名工具不会误撤卡(S10 评审 #2)
|
|
612
|
+
*/
|
|
613
|
+
function toolFingerprint(toolName, toolInput) {
|
|
614
|
+
let input = ''
|
|
615
|
+
try {
|
|
616
|
+
input = JSON.stringify(toolInput === undefined ? null : toolInput)
|
|
617
|
+
} catch (e) {
|
|
618
|
+
input = String(toolInput)
|
|
619
|
+
}
|
|
620
|
+
return crypto.createHash('sha256').update(toolName).update('\0').update(input).digest('hex').slice(0, 16)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 这个智能体进程是不是【交互窗口】(二期 P1)。参数里有 -p / --print / stream-json / exec 的是
|
|
625
|
+
* 脚本、SDK 或 daemon 起的远程任务;桌面 App 与终端里的 TUI 是窗口。与 gate.cjs 的 isInteractiveClaude 同一判据。
|
|
626
|
+
* 🔴 判不出来按 false:false 只意味着"不挡远程指令",与改动前的行为一致
|
|
627
|
+
*/
|
|
628
|
+
function isInteractiveArgs(args) {
|
|
629
|
+
const all = String(args || '').trim().split(/\s+/)
|
|
630
|
+
const tokens = all.slice(1)
|
|
631
|
+
if (tokens.length === 0) return true
|
|
632
|
+
// exec 只认 Codex 的子命令位置:`claude "exec the tests"` 这种带提示词启动的窗口不能误判(评审)
|
|
633
|
+
if (tokens[0] === 'exec' && /codex/i.test(path.basename(all[0]))) return false
|
|
634
|
+
// opencode 的非交互是 `opencode run …`(二期 P9);TUI 是 `opencode [目录]`
|
|
635
|
+
if (tokens[0] === 'run' && /opencode/i.test(path.basename(all[0]))) return false
|
|
636
|
+
return !tokens.some(
|
|
637
|
+
(t) =>
|
|
638
|
+
t === '--print' ||
|
|
639
|
+
t === 'stream-json' ||
|
|
640
|
+
t.startsWith('--output-format=stream') ||
|
|
641
|
+
/^-[a-zA-Z]*p[a-zA-Z]*$/.test(t)
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 只看进程在不在(kill 0),不查进程名:每次事件都要调,ps 太贵 */
|
|
646
|
+
function pidExists(pid) {
|
|
647
|
+
try {
|
|
648
|
+
process.kill(pid, 0)
|
|
649
|
+
return true
|
|
650
|
+
} catch (e) {
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function detectInteractive(pid) {
|
|
656
|
+
if (!pid) return false
|
|
657
|
+
try {
|
|
658
|
+
const args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], {
|
|
659
|
+
encoding: 'utf8',
|
|
660
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
661
|
+
})
|
|
662
|
+
return args.trim() !== '' && isInteractiveArgs(args)
|
|
663
|
+
} catch (e) {
|
|
664
|
+
return false
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------- /clear 改绑(二期 P2) ----------
|
|
669
|
+
|
|
670
|
+
/** 与 src/core/state.ts 的 ALIAS_TTL_MS 一致 */
|
|
671
|
+
const ALIAS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
672
|
+
/** SessionEnd(clear) 到 SessionStart(clear) 之间允许的间隔 */
|
|
673
|
+
const CLEAR_PAIR_MS = 60 * 1000
|
|
674
|
+
|
|
675
|
+
function clearMarkPath(pid) {
|
|
676
|
+
return path.join(deskbuddyHome(), 'clear', String(pid) + '.json')
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** SessionEnd(reason=clear):按智能体进程号记下旧会话,等同一进程的 SessionStart(source=clear) 来配对 */
|
|
680
|
+
function markClear(pid, oldSessionId) {
|
|
681
|
+
try {
|
|
682
|
+
fs.mkdirSync(path.join(deskbuddyHome(), 'clear'), { recursive: true })
|
|
683
|
+
fs.writeFileSync(clearMarkPath(pid), JSON.stringify({ old: oldSessionId, at: Date.now() }), 'utf8')
|
|
684
|
+
} catch (e) {
|
|
685
|
+
/* 改绑是锦上添花,失败就退回"旧会话失效" */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** SessionStart(source=clear):配对成功就写别名 旧 → 新 */
|
|
690
|
+
function pairClear(pid, newSessionId) {
|
|
691
|
+
let mark
|
|
692
|
+
try {
|
|
693
|
+
mark = JSON.parse(fs.readFileSync(clearMarkPath(pid), 'utf8'))
|
|
694
|
+
fs.unlinkSync(clearMarkPath(pid))
|
|
695
|
+
} catch (e) {
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
if (!mark || typeof mark.old !== 'string' || !mark.old || mark.old === newSessionId) return
|
|
699
|
+
if (!(Date.now() - mark.at < CLEAR_PAIR_MS)) return
|
|
700
|
+
const file = path.join(deskbuddyHome(), 'alias.json')
|
|
701
|
+
let map = {}
|
|
702
|
+
try {
|
|
703
|
+
map = JSON.parse(fs.readFileSync(file, 'utf8')) || {}
|
|
704
|
+
} catch (e) {
|
|
705
|
+
map = {}
|
|
706
|
+
}
|
|
707
|
+
const now = Date.now()
|
|
708
|
+
for (const k of Object.keys(map)) {
|
|
709
|
+
if (!(map[k] && now - map[k].at < ALIAS_TTL_MS)) delete map[k]
|
|
710
|
+
}
|
|
711
|
+
map[mark.old] = { to: newSessionId, at: now }
|
|
712
|
+
try {
|
|
713
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(map), 'utf8')
|
|
714
|
+
fs.renameSync(file + '.tmp', file)
|
|
715
|
+
} catch (e) {
|
|
716
|
+
/* 同上 */
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 最近目录最多记多少条(二期 P6,与 src/core/dirs.ts 同构;上报只取前 10) */
|
|
721
|
+
const DIRS_KEEP = 30
|
|
722
|
+
|
|
723
|
+
/** UserPromptSubmit 时记下工作目录:{全路径: 最近使用毫秒}。🔴 全路径只留本机 */
|
|
724
|
+
function recordDir(cwd, now) {
|
|
725
|
+
if (!cwd) return
|
|
726
|
+
const file = path.join(deskbuddyHome(), 'dirs.json')
|
|
727
|
+
let map = {}
|
|
728
|
+
try {
|
|
729
|
+
map = JSON.parse(fs.readFileSync(file, 'utf8')) || {}
|
|
730
|
+
} catch (e) {
|
|
731
|
+
map = {}
|
|
732
|
+
}
|
|
733
|
+
map[cwd] = now
|
|
734
|
+
const kept = Object.entries(map)
|
|
735
|
+
.filter((e) => typeof e[1] === 'number')
|
|
736
|
+
.sort((a, b) => b[1] - a[1])
|
|
737
|
+
.slice(0, DIRS_KEEP)
|
|
738
|
+
try {
|
|
739
|
+
fs.mkdirSync(deskbuddyHome(), { recursive: true })
|
|
740
|
+
fs.writeFileSync(file + '.tmp', JSON.stringify(Object.fromEntries(kept)), 'utf8')
|
|
741
|
+
fs.renameSync(file + '.tmp', file)
|
|
742
|
+
} catch (e) {
|
|
743
|
+
/* 记不下只影响设备上的目录列表 */
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 与 src/core/state.ts 的 NOTICE_TTL_MS 一致 */
|
|
748
|
+
const NOTICE_TTL_MS = 60 * 60 * 1000
|
|
749
|
+
const NOTICE_EVENTS = { PostToolUse: 1, PostToolUseFailure: 1, Stop: 1 }
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 窗口回显(S10,见 src/core/state.ts 的 writeNotice):有就取出并删掉,返回要显示的文字。
|
|
753
|
+
* 先删再用:并发的两个 hook 最多一个拿到,不会重复显示
|
|
754
|
+
*/
|
|
755
|
+
function takeNotice(sessionId) {
|
|
756
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '')
|
|
757
|
+
if (!safe) return ''
|
|
758
|
+
const file = path.join(deskbuddyHome(), 'notice', safe + '.json')
|
|
759
|
+
let raw
|
|
760
|
+
try {
|
|
761
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
762
|
+
fs.unlinkSync(file)
|
|
763
|
+
} catch (e) {
|
|
764
|
+
return ''
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
const n = JSON.parse(raw)
|
|
768
|
+
if (typeof n.text !== 'string' || !n.text || !(Date.now() - n.at < NOTICE_TTL_MS)) return ''
|
|
769
|
+
return n.text.slice(0, 300)
|
|
770
|
+
} catch (e) {
|
|
771
|
+
return ''
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
557
775
|
async function main(argv) {
|
|
558
776
|
// 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
|
|
559
777
|
const tool = (argv && argv[2]) || 'claude_code'
|
|
@@ -562,10 +780,33 @@ async function main(argv) {
|
|
|
562
780
|
const sessionId = payload.session_id || ''
|
|
563
781
|
if (!sessionId) return
|
|
564
782
|
|
|
783
|
+
if (NOTICE_EVENTS[event]) {
|
|
784
|
+
const notice = takeNotice(sessionId)
|
|
785
|
+
// 🔴 writeSync:macOS 上 stdout 是管道时 process.stdout.write 是异步的,进程先退就丢了
|
|
786
|
+
if (notice) {
|
|
787
|
+
try {
|
|
788
|
+
fs.writeSync(1, JSON.stringify({ systemMessage: notice }))
|
|
789
|
+
} catch (e) {
|
|
790
|
+
/* 显示不了就算了 */
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
565
795
|
const registry = loadRegistry()
|
|
796
|
+
|
|
797
|
+
// /clear 改绑(二期 P2):SessionStart 在映射表里是 ignore,要在那之前处理
|
|
798
|
+
if (event === 'SessionStart' && payload.source === 'clear') {
|
|
799
|
+
const pid = resolveAgentPid(process.pid, agentNames(registry, tool))
|
|
800
|
+
if (pid) pairClear(pid, sessionId)
|
|
801
|
+
}
|
|
802
|
+
|
|
566
803
|
const action = mapEvent(registry, tool, event, payload)
|
|
567
804
|
if (action === 'ignore') return
|
|
568
805
|
if (action === 'remove') {
|
|
806
|
+
if (event === 'SessionEnd' && payload.reason === 'clear') {
|
|
807
|
+
const old = readState(sessionId)
|
|
808
|
+
if (old && old.pid) markClear(old.pid, sessionId)
|
|
809
|
+
}
|
|
569
810
|
removeState(sessionId)
|
|
570
811
|
// 删完补发一次剩余会话,否则这条会一直挂在屏上等服务端失联 TTL。
|
|
571
812
|
// 关掉最后一个窗口时发的是空列表——服务端放行空快照,该机器该工具随即从屏上消失
|
|
@@ -575,19 +816,41 @@ async function main(argv) {
|
|
|
575
816
|
|
|
576
817
|
const prev = readState(sessionId)
|
|
577
818
|
const now = Date.now()
|
|
578
|
-
|
|
819
|
+
if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
|
|
820
|
+
// 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
|
|
821
|
+
// 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
|
|
822
|
+
const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
|
|
823
|
+
const pid = (prevAlive && prev.pid) || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
|
|
579
824
|
const next = {
|
|
580
825
|
sessionId: sessionId,
|
|
581
826
|
tool: tool,
|
|
582
827
|
// 标题要读文件,只在真正上报时才取(见下面);这里先沿用上一次的
|
|
583
828
|
title: (prev && prev.title) || '',
|
|
584
829
|
project: payload.cwd || (prev && prev.project) || '',
|
|
830
|
+
// 🔴 和 project 装的是同一个值,不是冗余:project 这个名字读起来就像「只该存目录名」
|
|
831
|
+
// (上报侧确实只发 basename,见 snapshot.ts),早晚有人去「修」它。而远程指令靠
|
|
832
|
+
// 工作目录定位在哪执行 —— 实测 claude --resume 从任何目录都找得到会话,但 agent 的
|
|
833
|
+
// 工作目录跟着【启动目录】走:目录错了不报任何错,它会拿着对的对话去改错目录里的文件。
|
|
834
|
+
// 所以单独立一个名字说得清的字段,把这条契约钉死。
|
|
835
|
+
// ⚠️ 只落本机,绝不上报:快照里发出去的仍然只有 basename。
|
|
836
|
+
cwd: payload.cwd || (prev && prev.cwd) || '',
|
|
585
837
|
state: action,
|
|
586
838
|
startedAt: (prev && prev.startedAt) || now,
|
|
587
839
|
updatedAt: now,
|
|
588
840
|
pid: pid,
|
|
589
|
-
pidName: pid ? (
|
|
841
|
+
pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
|
|
842
|
+
// 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
|
|
843
|
+
interactive: prevAlive && typeof prev.interactive === 'boolean' ? prev.interactive : detectInteractive(pid),
|
|
590
844
|
lastReportAt: prev ? prev.lastReportAt : undefined,
|
|
845
|
+
// daemon 靠它给窗口待批准撤卡(S10),与 src/core/state.ts 的 recent 同构
|
|
846
|
+
recent: ((prev && Array.isArray(prev.recent) ? prev.recent : []).concat([
|
|
847
|
+
{
|
|
848
|
+
e: event,
|
|
849
|
+
tool: typeof payload.tool_name === 'string' ? payload.tool_name : undefined,
|
|
850
|
+
fp: typeof payload.tool_name === 'string' ? toolFingerprint(payload.tool_name, payload.tool_input) : undefined,
|
|
851
|
+
at: now,
|
|
852
|
+
},
|
|
853
|
+
])).slice(-RECENT_MAX),
|
|
591
854
|
}
|
|
592
855
|
|
|
593
856
|
if (!shouldReport(prev, next, now)) {
|
|
@@ -647,6 +910,11 @@ module.exports = {
|
|
|
647
910
|
removeState,
|
|
648
911
|
resolveAgentPid,
|
|
649
912
|
resolveTitle,
|
|
913
|
+
takeNotice,
|
|
914
|
+
isInteractiveArgs,
|
|
915
|
+
recordDir,
|
|
916
|
+
markClear,
|
|
917
|
+
pairClear,
|
|
650
918
|
shouldReport,
|
|
651
919
|
sign,
|
|
652
920
|
titleSources,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// hook/opencode.mjs 的类型声明(只给测试用;插件本体是零依赖 JS,跑在 opencode 自带的 bun 里)
|
|
2
|
+
export function isHeadless(argv?: string[]): boolean
|
|
3
|
+
export function toCcTool(tool: string, args: unknown): { tool_name: string; tool_input: Record<string, unknown> }
|
|
4
|
+
export function askToCc(props: unknown): { tool_name: string; tool_input: Record<string, unknown> }
|
|
5
|
+
export function openPermissions(cfg: unknown): void
|
|
6
|
+
export const OC_GATED_KEYS: string[]
|
|
7
|
+
export function make(opts?: { node?: string }): (ctx: unknown) => Promise<Record<string, unknown>>
|
|
@@ -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
|
+
}
|