Skip to content

Commit 18c0b08

Browse files
fix: harden OpenCode event correlation
Co-authored-by: Shukan Shah <shukanshah14@gmail.com>
1 parent b69b873 commit 18c0b08

6 files changed

Lines changed: 126 additions & 32 deletions

File tree

cli/beacon-hooks/cmd/opencode_event.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,10 @@ func opencodeEndpointEvents(input map[string]interface{}, sessionID string) []op
172172
if tool := opencodeToolName(input); tool != "" {
173173
fields["tool"] = map[string]interface{}{"name": tool}
174174
}
175+
mergeMap(fields, opencodePermissionCorrelation(properties))
175176
return one("approval.requested", "approval", "info", "opencode permission requested", fields)
176177
case "permission.replied", "permission.updated", "permission.v2.replied":
178+
properties := opencodeProperties(input)
177179
decision := opencodeDecision(input)
178180
if decision == "" {
179181
decision = "unknown"
@@ -182,6 +184,7 @@ func opencodeEndpointEvents(input map[string]interface{}, sessionID string) []op
182184
if tool := opencodeToolName(input); tool != "" {
183185
fields["tool"] = map[string]interface{}{"name": tool}
184186
}
187+
mergeMap(fields, opencodePermissionCorrelation(properties))
185188
action := opencodeApprovalAction(decision)
186189
return one(action, "approval", "info", "opencode permission "+decision, fields)
187190
default:
@@ -196,6 +199,7 @@ func supportedOpenCodeEventTypes() []string {
196199
"command.executed",
197200
"file.edited",
198201
"file.watcher.updated",
202+
"message.part.delta",
199203
"message.part.updated",
200204
"message.updated",
201205
"permission.asked",
@@ -613,6 +617,28 @@ func opencodeApprovalAction(decision string) string {
613617
}
614618
}
615619

620+
func opencodePermissionCorrelation(properties map[string]interface{}) map[string]interface{} {
621+
toolRef := opencodeMap(properties, "tool")
622+
callID := getFirstStr(toolRef, "callID", "call_id")
623+
if callID == "" {
624+
return nil
625+
}
626+
toolName := getFirstStr(properties, "permission")
627+
fields := map[string]interface{}{
628+
"gen_ai": map[string]interface{}{
629+
"operation": map[string]interface{}{"name": "execute_tool"},
630+
"tool": map[string]interface{}{
631+
"name": toolName,
632+
"call": map[string]interface{}{"id": callID},
633+
},
634+
},
635+
}
636+
if toolName != "" {
637+
fields["tool"] = map[string]interface{}{"name": toolName}
638+
}
639+
return fields
640+
}
641+
616642
func opencodeProperties(input map[string]interface{}) map[string]interface{} {
617643
if properties := opencodeMap(input, "properties", "data"); len(properties) > 0 {
618644
return properties

cli/beacon-hooks/cmd/opencode_event_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,10 @@ func TestOpenCodePermissionRepliesMapAllowAndDeny(t *testing.T) {
332332
action, _, _, _, _ := opencodeEndpointEvent(map[string]interface{}{
333333
"type": "permission.replied",
334334
"session_id": "ses_test",
335-
"properties": map[string]interface{}{"reply": tt.reply},
335+
"properties": map[string]interface{}{
336+
"reply": tt.reply, "permission": "bash",
337+
"tool": map[string]interface{}{"messageID": "msg_1", "callID": "call_permission"},
338+
},
336339
}, "ses_test")
337340
if action != tt.action {
338341
t.Fatalf("reply %q action = %q, want %q", tt.reply, action, tt.action)

cli/beacon/internal/endpoint/hooks/assets/opencode/beacon.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ async function debugLog(client, message, extra) {
4747
}
4848

4949
async function sendToBeacon(client, payload) {
50+
const testSender = globalThis[Symbol.for("beacon.opencode.testSender")]
51+
if (typeof testSender === "function") {
52+
await testSender(payload)
53+
return
54+
}
5055
let proc
5156
try {
5257
proc = Bun.spawn(["/bin/sh", "-lc", beaconCommand], {
@@ -87,6 +92,7 @@ function sessionID(value) {
8792
}
8893

8994
function modelName(value) {
95+
if (value?.providerID && value?.modelID) return value.providerID + "/" + value.modelID
9096
const model = value?.model || value?.modelInfo
9197
if (!model || typeof model === "string") return model || ""
9298
if (model.providerID && model.modelID) return model.providerID + "/" + model.modelID
@@ -118,6 +124,9 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
118124
const pendingParts = new Map()
119125
const partDeltas = new Map()
120126
const messageModels = new Map()
127+
const completedMessages = new Set()
128+
const permissionRequests = new Map()
129+
const sessionStates = new Map()
121130
const recentFilePaths = new Map()
122131
let eventQueue = Promise.resolve()
123132

@@ -128,10 +137,10 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
128137
.catch((err) => debugLog(client, "Beacon event queue failed", { error: String(err), type: value?.type }))
129138
return eventQueue
130139
}
131-
const rememberFilePath = (tool, args) => {
140+
const rememberFilePath = (tool, args, sid, callID) => {
132141
if (!isFileMutationTool(tool)) return
133142
const path = filePath(args)
134-
if (path) recentFilePaths.set(path, Date.now())
143+
if (path) recentFilePaths.set(path, { time: Date.now(), sessionID: sid, callID })
135144
}
136145
const partKey = (part) => `${part?.sessionID || ""}:${part?.messageID || ""}:${part?.id || ""}`
137146
const emitPart = (part, sid, model) => {
@@ -208,7 +217,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
208217
"tool.execute.after": async (input, output) => {
209218
const active = activeCalls.get(input?.callID)
210219
const args = input?.args || active?.args || {}
211-
rememberFilePath(input?.tool, args)
220+
rememberFilePath(input?.tool, args, sessionID(input), input?.callID)
212221
await sendToBeacon(
213222
client,
214223
payload("tool.execute.after", {
@@ -235,6 +244,10 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
235244
const model = modelName(info)
236245
if (info?.id && model) messageModels.set(info.id, model)
237246
if (!info?.time?.completed && !info?.error) return
247+
if (info?.id) {
248+
if (completedMessages.has(info.id)) return
249+
completedMessages.add(info.id)
250+
}
238251
}
239252
if (type === "message.part.delta") {
240253
const key = `${sid}:${properties?.messageID || ""}:${properties?.partID || ""}`
@@ -246,27 +259,51 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
246259
emitPart(part, sid, messageModels.get(part?.messageID) || "")
247260
return
248261
}
249-
if (type === "session.status" && properties?.status?.type === "idle") flushParts(sid)
250-
if (type === "session.idle") flushParts(sid)
262+
if (type === "session.status") {
263+
const status = properties?.status?.type || ""
264+
sessionStates.set(sid, status)
265+
if (status === "idle") flushParts(sid)
266+
}
267+
if (type === "session.idle") {
268+
flushParts(sid)
269+
if (sessionStates.get(sid) === "idle") return
270+
sessionStates.set(sid, "idle")
271+
}
272+
if (type === "permission.asked" || type === "permission.v2.asked") {
273+
if (properties?.id) {
274+
permissionRequests.set(properties.id, { tool: properties?.tool, permission: properties?.permission })
275+
}
276+
}
277+
if (type === "permission.replied" || type === "permission.v2.replied") {
278+
const request = permissionRequests.get(properties?.requestID)
279+
if (request?.tool && !properties.tool) properties.tool = request.tool
280+
if (request?.permission && !properties.permission) properties.permission = request.permission
281+
permissionRequests.delete(properties?.requestID)
282+
}
251283
if (type === "session.diff") {
252284
const diffs = Array.isArray(properties?.diff) ? properties.diff : []
253285
const now = Date.now()
254286
const filtered = diffs.filter((item) => {
255287
const path = filePath(item)
256288
if (!path) return false
257289
const seen = recentFilePaths.get(path)
258-
if (seen && now - seen < 5000) return false
290+
if (seen && now - seen.time < 5000) return false
259291
return item?.before !== item?.after || item?.additions || item?.deletions
260292
})
261293
if (filtered.length === 0) return
262294
properties.diff = filtered
263295
}
264296
if (type === "file.edited" || type === "file.watcher.updated") {
265-
if (!sid) return
297+
if (!sid) {
298+
const seen = recentFilePaths.get(filePath(properties))
299+
if (!seen || Date.now() - seen.time >= 5000) return
300+
properties.sessionID = seen.sessionID
301+
properties.callID = seen.callID
302+
}
266303
}
267304
void enqueue(
268305
payload(type, {
269-
session_id: sid,
306+
session_id: sessionID(properties) || sid,
270307
model: modelName(info || properties),
271308
properties,
272309
event,

cli/beacon/internal/endpoint/hooks/opencode_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ func TestOpenCodeInstalledUsesManagedMarker(t *testing.T) {
132132
if isOpenCodeInstalledAt(path) {
133133
t.Fatal("unmarked plugin should not be detected as installed")
134134
}
135+
if err := os.Remove(path); err != nil {
136+
t.Fatalf("remove unmarked plugin: %v", err)
137+
}
135138
if err := installOpenCodePlugin(path, "/tmp/beacon-hooks", "/tmp/runtime.jsonl", "/tmp/config.json"); err != nil {
136139
t.Fatalf("installOpenCodePlugin returned error: %v", err)
137140
}

plugins/opencode-beacon/src/beacon.test.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,16 @@
11
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
22
import { BeaconEndpointPlugin } from "./beacon"
33

4-
const originalSpawn = Bun.spawn
54
const payloads: any[] = []
5+
const senderKey = Symbol.for("beacon.opencode.testSender")
66

77
beforeEach(() => {
88
payloads.length = 0
9-
Object.defineProperty(Bun, "spawn", {
10-
configurable: true,
11-
value: () => ({
12-
stdin: {
13-
write(value: string) {
14-
payloads.push(JSON.parse(value))
15-
},
16-
end() {},
17-
},
18-
exited: Promise.resolve(0),
19-
kill() {},
20-
}),
21-
})
9+
;(globalThis as any)[senderKey] = async (payload: any) => payloads.push(structuredClone(payload))
2210
})
2311

2412
afterEach(() => {
25-
Object.defineProperty(Bun, "spawn", { configurable: true, value: originalSpawn })
13+
delete (globalThis as any)[senderKey]
2614
})
2715

2816
async function plugin() {

plugins/opencode-beacon/src/beacon.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ async function debugLog(client, message, extra) {
4747
}
4848

4949
async function sendToBeacon(client, payload) {
50+
const testSender = globalThis[Symbol.for("beacon.opencode.testSender")]
51+
if (typeof testSender === "function") {
52+
await testSender(payload)
53+
return
54+
}
5055
let proc
5156
try {
5257
proc = Bun.spawn(["/bin/sh", "-lc", beaconCommand], {
@@ -87,6 +92,7 @@ function sessionID(value) {
8792
}
8893

8994
function modelName(value) {
95+
if (value?.providerID && value?.modelID) return value.providerID + "/" + value.modelID
9096
const model = value?.model || value?.modelInfo
9197
if (!model || typeof model === "string") return model || ""
9298
if (model.providerID && model.modelID) return model.providerID + "/" + model.modelID
@@ -118,6 +124,9 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
118124
const pendingParts = new Map()
119125
const partDeltas = new Map()
120126
const messageModels = new Map()
127+
const completedMessages = new Set()
128+
const permissionRequests = new Map()
129+
const sessionStates = new Map()
121130
const recentFilePaths = new Map()
122131
let eventQueue = Promise.resolve()
123132

@@ -128,10 +137,10 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
128137
.catch((err) => debugLog(client, "Beacon event queue failed", { error: String(err), type: value?.type }))
129138
return eventQueue
130139
}
131-
const rememberFilePath = (tool, args) => {
140+
const rememberFilePath = (tool, args, sid, callID) => {
132141
if (!isFileMutationTool(tool)) return
133142
const path = filePath(args)
134-
if (path) recentFilePaths.set(path, Date.now())
143+
if (path) recentFilePaths.set(path, { time: Date.now(), sessionID: sid, callID })
135144
}
136145
const partKey = (part) => `${part?.sessionID || ""}:${part?.messageID || ""}:${part?.id || ""}`
137146
const emitPart = (part, sid, model) => {
@@ -208,7 +217,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
208217
"tool.execute.after": async (input, output) => {
209218
const active = activeCalls.get(input?.callID)
210219
const args = input?.args || active?.args || {}
211-
rememberFilePath(input?.tool, args)
220+
rememberFilePath(input?.tool, args, sessionID(input), input?.callID)
212221
await sendToBeacon(
213222
client,
214223
payload("tool.execute.after", {
@@ -235,6 +244,10 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
235244
const model = modelName(info)
236245
if (info?.id && model) messageModels.set(info.id, model)
237246
if (!info?.time?.completed && !info?.error) return
247+
if (info?.id) {
248+
if (completedMessages.has(info.id)) return
249+
completedMessages.add(info.id)
250+
}
238251
}
239252
if (type === "message.part.delta") {
240253
const key = `${sid}:${properties?.messageID || ""}:${properties?.partID || ""}`
@@ -246,27 +259,51 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
246259
emitPart(part, sid, messageModels.get(part?.messageID) || "")
247260
return
248261
}
249-
if (type === "session.status" && properties?.status?.type === "idle") flushParts(sid)
250-
if (type === "session.idle") flushParts(sid)
262+
if (type === "session.status") {
263+
const status = properties?.status?.type || ""
264+
sessionStates.set(sid, status)
265+
if (status === "idle") flushParts(sid)
266+
}
267+
if (type === "session.idle") {
268+
flushParts(sid)
269+
if (sessionStates.get(sid) === "idle") return
270+
sessionStates.set(sid, "idle")
271+
}
272+
if (type === "permission.asked" || type === "permission.v2.asked") {
273+
if (properties?.id) {
274+
permissionRequests.set(properties.id, { tool: properties?.tool, permission: properties?.permission })
275+
}
276+
}
277+
if (type === "permission.replied" || type === "permission.v2.replied") {
278+
const request = permissionRequests.get(properties?.requestID)
279+
if (request?.tool && !properties.tool) properties.tool = request.tool
280+
if (request?.permission && !properties.permission) properties.permission = request.permission
281+
permissionRequests.delete(properties?.requestID)
282+
}
251283
if (type === "session.diff") {
252284
const diffs = Array.isArray(properties?.diff) ? properties.diff : []
253285
const now = Date.now()
254286
const filtered = diffs.filter((item) => {
255287
const path = filePath(item)
256288
if (!path) return false
257289
const seen = recentFilePaths.get(path)
258-
if (seen && now - seen < 5000) return false
290+
if (seen && now - seen.time < 5000) return false
259291
return item?.before !== item?.after || item?.additions || item?.deletions
260292
})
261293
if (filtered.length === 0) return
262294
properties.diff = filtered
263295
}
264296
if (type === "file.edited" || type === "file.watcher.updated") {
265-
if (!sid) return
297+
if (!sid) {
298+
const seen = recentFilePaths.get(filePath(properties))
299+
if (!seen || Date.now() - seen.time >= 5000) return
300+
properties.sessionID = seen.sessionID
301+
properties.callID = seen.callID
302+
}
266303
}
267304
void enqueue(
268305
payload(type, {
269-
session_id: sid,
306+
session_id: sessionID(properties) || sid,
270307
model: modelName(info || properties),
271308
properties,
272309
event,

0 commit comments

Comments
 (0)