backeer-opencode 0.1.2 → 0.1.3
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/CHANGELOG.md +8 -0
- package/README.md +20 -0
- package/bin/backeer-opencode.js +1 -1
- package/dist/backeer-permission.js +730 -0
- package/package.json +9 -2
- package/src/config.js +56 -0
- package/src/event-publishers.js +214 -0
- package/src/http.js +87 -0
- package/src/index.js +1 -829
- package/src/permission-controller.js +183 -0
- package/src/plugin.js +143 -0
- package/src/reporter.js +60 -0
- package/src/sse.js +139 -0
- package/src/utilities.js +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backeer-opencode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Backeer notifications and remote permission decisions for OpenCode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,16 +12,20 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
+
"dist",
|
|
15
16
|
"bin",
|
|
16
17
|
"README.md",
|
|
17
18
|
"CHANGELOG.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"scripts": {
|
|
21
|
-
"
|
|
22
|
+
"build": "esbuild src/index.js --bundle --platform=node --format=esm --target=node18 --outfile=dist/backeer-permission.js --legal-comments=none",
|
|
23
|
+
"check": "node --check bin/backeer-opencode.js && npm test",
|
|
24
|
+
"pretest": "npm run build",
|
|
22
25
|
"test": "node --test",
|
|
23
26
|
"pack:check": "npm pack --dry-run",
|
|
24
27
|
"publish:check": "npm publish --dry-run --access public",
|
|
28
|
+
"prepack": "npm run build",
|
|
25
29
|
"prepublishOnly": "npm run check"
|
|
26
30
|
},
|
|
27
31
|
"keywords": [
|
|
@@ -45,5 +49,8 @@
|
|
|
45
49
|
"publishConfig": {
|
|
46
50
|
"access": "public"
|
|
47
51
|
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"esbuild": "^0.28.2"
|
|
54
|
+
},
|
|
48
55
|
"license": "SEE LICENSE IN LICENSE"
|
|
49
56
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
import { normalizeLogLevel } from "./reporter.js"
|
|
6
|
+
import { enabled } from "./utilities.js"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ORIGIN = "https://external-gateway.backeer.com"
|
|
9
|
+
|
|
10
|
+
function defaultConfigDirectory() {
|
|
11
|
+
const base = process.env.XDG_CONFIG_HOME?.trim()
|
|
12
|
+
return base ? join(base, "opencode") : join(homedir(), ".config", "opencode")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadToken(tokenFile) {
|
|
16
|
+
const environmentToken = process.env.BACKEER_INGEST_TOKEN?.trim()
|
|
17
|
+
if (environmentToken) return environmentToken
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(tokenFile, "utf8").trim()
|
|
21
|
+
} catch {
|
|
22
|
+
return ""
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadConfig(options = {}) {
|
|
27
|
+
const origin = new URL(
|
|
28
|
+
options.origin ?? process.env.BACKEER_ORIGIN ?? DEFAULT_ORIGIN,
|
|
29
|
+
)
|
|
30
|
+
if (origin.protocol !== "https:") {
|
|
31
|
+
throw new Error("Backeer origin must use HTTPS")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tokenFile =
|
|
35
|
+
options.tokenFile ??
|
|
36
|
+
process.env.BACKEER_TOKEN_FILE ??
|
|
37
|
+
join(defaultConfigDirectory(), "secrets", "backeer-ingest-token")
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
origin,
|
|
41
|
+
eventsEndpoint: new URL("/v1/events", origin),
|
|
42
|
+
commandStreamEndpoint: new URL("/v1/commands/stream", origin),
|
|
43
|
+
token: loadToken(tokenFile),
|
|
44
|
+
tokenFile,
|
|
45
|
+
permissions: enabled(
|
|
46
|
+
options.permissions ?? process.env.BACKEER_NOTIFY_PERMISSIONS,
|
|
47
|
+
),
|
|
48
|
+
questions: enabled(
|
|
49
|
+
options.questions ?? process.env.BACKEER_NOTIFY_QUESTIONS,
|
|
50
|
+
),
|
|
51
|
+
todos: enabled(options.todos ?? process.env.BACKEER_NOTIFY_TODOS),
|
|
52
|
+
logLevel: normalizeLogLevel(
|
|
53
|
+
options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { postJsonWithRetry } from "./http.js"
|
|
2
|
+
import { limit, stableHash } from "./utilities.js"
|
|
3
|
+
|
|
4
|
+
const ACTION_PROTOCOL = "opencode-permission/v1"
|
|
5
|
+
|
|
6
|
+
export function permissionIdentity(request) {
|
|
7
|
+
const requestID = request.id ?? request.requestID
|
|
8
|
+
const sessionID = request.sessionID
|
|
9
|
+
if (typeof requestID !== "string" || typeof sessionID !== "string") return
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
requestID,
|
|
13
|
+
sessionID,
|
|
14
|
+
externalId: `opencode:${sessionID}:${requestID}`,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function publishPermission(
|
|
19
|
+
config,
|
|
20
|
+
reporter,
|
|
21
|
+
request,
|
|
22
|
+
directory,
|
|
23
|
+
identity,
|
|
24
|
+
) {
|
|
25
|
+
const permissionType = request.permission ?? request.action ?? "unknown"
|
|
26
|
+
const resources = Array.isArray(request.patterns)
|
|
27
|
+
? request.patterns.join(", ")
|
|
28
|
+
: Array.isArray(request.resources)
|
|
29
|
+
? request.resources.join(", ")
|
|
30
|
+
: ""
|
|
31
|
+
|
|
32
|
+
const payload = [
|
|
33
|
+
{ key: "Permission", value: limit(permissionType) },
|
|
34
|
+
{ key: "Session", value: limit(identity.sessionID) },
|
|
35
|
+
{ key: "Request ID", value: limit(identity.requestID) },
|
|
36
|
+
{ key: "Patterns", value: limit(resources) },
|
|
37
|
+
{ key: "Working directory", value: limit(directory) },
|
|
38
|
+
{ key: "Action protocol", value: ACTION_PROTOCOL },
|
|
39
|
+
{ key: "Available decisions", value: "once, always, reject" },
|
|
40
|
+
].filter((item) => item.value.length > 0)
|
|
41
|
+
|
|
42
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
43
|
+
type: "application",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
title: `OpenCode permission required: ${limit(permissionType, 200)}`,
|
|
46
|
+
body: resources
|
|
47
|
+
? `OpenCode is waiting for approval to access: ${limit(resources, 500)}`
|
|
48
|
+
: `OpenCode is waiting for approval for: ${limit(permissionType, 500)}`,
|
|
49
|
+
payload,
|
|
50
|
+
externalId: identity.externalId,
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
notify: true,
|
|
53
|
+
direction: "in",
|
|
54
|
+
})
|
|
55
|
+
reporter.success("Permission request sent", `HTTP ${result.status}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function questionIdentity(request) {
|
|
59
|
+
const requestID = request.id ?? request.requestID
|
|
60
|
+
const sessionID = request.sessionID
|
|
61
|
+
if (typeof requestID !== "string" || typeof sessionID !== "string") return
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
requestID,
|
|
65
|
+
sessionID,
|
|
66
|
+
externalId: `opencode-question:${sessionID}:${requestID}`,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatQuestionOptions(options) {
|
|
71
|
+
if (!Array.isArray(options)) return ""
|
|
72
|
+
|
|
73
|
+
return options
|
|
74
|
+
.map((option) => {
|
|
75
|
+
const label = limit(option?.label, 200)
|
|
76
|
+
const description = limit(option?.description, 500)
|
|
77
|
+
if (!label) return ""
|
|
78
|
+
return description ? `${label} — ${description}` : label
|
|
79
|
+
})
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(" | ")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveSessionTitle(client, directory, sessionID, reporter) {
|
|
85
|
+
if (typeof client?.session?.get !== "function") return ""
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const result = await client.session.get({
|
|
89
|
+
path: { id: sessionID },
|
|
90
|
+
query: { directory },
|
|
91
|
+
throwOnError: true,
|
|
92
|
+
})
|
|
93
|
+
return limit((result?.data ?? result)?.title)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
reporter.debug("Session title unavailable", error)
|
|
96
|
+
return ""
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function publishQuestion(
|
|
101
|
+
config,
|
|
102
|
+
reporter,
|
|
103
|
+
request,
|
|
104
|
+
identity,
|
|
105
|
+
client,
|
|
106
|
+
directory,
|
|
107
|
+
) {
|
|
108
|
+
const questions = Array.isArray(request.questions) ? request.questions : []
|
|
109
|
+
if (questions.length === 0) return
|
|
110
|
+
|
|
111
|
+
const sessionTitle = await resolveSessionTitle(
|
|
112
|
+
client,
|
|
113
|
+
directory,
|
|
114
|
+
identity.sessionID,
|
|
115
|
+
reporter,
|
|
116
|
+
)
|
|
117
|
+
const payload = [
|
|
118
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
119
|
+
...questions.flatMap((question, index) => {
|
|
120
|
+
const suffix = questions.length > 1 ? ` ${index + 1}` : ""
|
|
121
|
+
return [
|
|
122
|
+
{ key: `Question title${suffix}`, value: limit(question?.header) },
|
|
123
|
+
{ key: `Question${suffix}`, value: limit(question?.question) },
|
|
124
|
+
{
|
|
125
|
+
key: `Options${suffix}`,
|
|
126
|
+
value: limit(formatQuestionOptions(question?.options)),
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
}),
|
|
130
|
+
].filter((item) => item.value.length > 0)
|
|
131
|
+
|
|
132
|
+
const firstQuestion = questions[0]
|
|
133
|
+
const body = questions
|
|
134
|
+
.map((question) => {
|
|
135
|
+
const header = limit(question?.header, 200)
|
|
136
|
+
const text = limit(question?.question, 500)
|
|
137
|
+
const options = limit(formatQuestionOptions(question?.options), 500)
|
|
138
|
+
return [header, text, options ? `Options: ${options}` : ""]
|
|
139
|
+
.filter(Boolean)
|
|
140
|
+
.join("\n")
|
|
141
|
+
})
|
|
142
|
+
.join("\n\n")
|
|
143
|
+
|
|
144
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
145
|
+
type: "application",
|
|
146
|
+
severity: "info",
|
|
147
|
+
title: `OpenCode question: ${limit(firstQuestion?.header, 200) || "Question"}`,
|
|
148
|
+
body: limit(body, 2000),
|
|
149
|
+
payload,
|
|
150
|
+
externalId: identity.externalId,
|
|
151
|
+
timestamp: new Date().toISOString(),
|
|
152
|
+
notify: true,
|
|
153
|
+
direction: "in",
|
|
154
|
+
})
|
|
155
|
+
reporter.success("Question sent", `HTTP ${result.status}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function publishTodoCompletion(
|
|
159
|
+
config,
|
|
160
|
+
reporter,
|
|
161
|
+
request,
|
|
162
|
+
eventID,
|
|
163
|
+
client,
|
|
164
|
+
directory,
|
|
165
|
+
) {
|
|
166
|
+
const todos = request.todos
|
|
167
|
+
const sessionTitle = await resolveSessionTitle(
|
|
168
|
+
client,
|
|
169
|
+
directory,
|
|
170
|
+
request.sessionID,
|
|
171
|
+
reporter,
|
|
172
|
+
)
|
|
173
|
+
const correlation =
|
|
174
|
+
typeof eventID === "string" && eventID.length > 0
|
|
175
|
+
? limit(eventID, 200)
|
|
176
|
+
: stableHash(JSON.stringify(todos))
|
|
177
|
+
const externalId = `opencode-todo:${request.sessionID}:${correlation}`
|
|
178
|
+
const visibleTodos = todos.slice(0, 20)
|
|
179
|
+
const payload = [
|
|
180
|
+
{ key: "OpenCode session", value: sessionTitle },
|
|
181
|
+
{ key: "Completed tasks", value: String(todos.length) },
|
|
182
|
+
...visibleTodos.flatMap((todo, index) => [
|
|
183
|
+
{ key: `Task ${index + 1}`, value: limit(todo?.content) },
|
|
184
|
+
{ key: `Priority ${index + 1}`, value: limit(todo?.priority, 100) },
|
|
185
|
+
]),
|
|
186
|
+
...(todos.length > visibleTodos.length
|
|
187
|
+
? [{ key: "Additional tasks", value: String(todos.length - visibleTodos.length) }]
|
|
188
|
+
: []),
|
|
189
|
+
].filter((item) => item.value.length > 0)
|
|
190
|
+
|
|
191
|
+
const summary = todos
|
|
192
|
+
.slice(0, 5)
|
|
193
|
+
.map((todo) => limit(todo?.content, 200))
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.join(" • ")
|
|
196
|
+
const location = sessionTitle ? ` in ${sessionTitle}` : ""
|
|
197
|
+
const body = [
|
|
198
|
+
`All ${todos.length} OpenCode tasks completed${location}.`,
|
|
199
|
+
summary,
|
|
200
|
+
].filter(Boolean).join("\n")
|
|
201
|
+
|
|
202
|
+
const result = await postJsonWithRetry(config, config.eventsEndpoint, {
|
|
203
|
+
type: "application",
|
|
204
|
+
severity: "info",
|
|
205
|
+
title: "OpenCode todo list completed",
|
|
206
|
+
body: limit(body, 2000),
|
|
207
|
+
payload,
|
|
208
|
+
externalId,
|
|
209
|
+
timestamp: new Date().toISOString(),
|
|
210
|
+
notify: true,
|
|
211
|
+
direction: "in",
|
|
212
|
+
})
|
|
213
|
+
reporter.success("Todo completion sent", `HTTP ${result.status}`)
|
|
214
|
+
}
|
package/src/http.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { limit } from "./utilities.js"
|
|
2
|
+
|
|
3
|
+
const RETRY_DELAYS_MS = [0, 500, 1500]
|
|
4
|
+
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
constructor(message, status, responseBody = "") {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = "HttpError"
|
|
9
|
+
this.status = status
|
|
10
|
+
this.responseBody = responseBody
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isRetryable(status) {
|
|
15
|
+
return status === 429 || status >= 500
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isAbortError(error) {
|
|
19
|
+
return error instanceof Error && error.name === "AbortError"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sleep(milliseconds, signal) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
if (signal?.aborted) {
|
|
25
|
+
resolve()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const timeout = setTimeout(done, milliseconds)
|
|
30
|
+
|
|
31
|
+
function done() {
|
|
32
|
+
clearTimeout(timeout)
|
|
33
|
+
signal?.removeEventListener("abort", done)
|
|
34
|
+
resolve()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
signal?.addEventListener("abort", done, { once: true })
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function postJsonWithRetry(config, url, body, options = {}) {
|
|
42
|
+
const retryDelays = options.retryDelays ?? RETRY_DELAYS_MS
|
|
43
|
+
|
|
44
|
+
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
|
45
|
+
if (retryDelays[attempt] > 0) {
|
|
46
|
+
await sleep(retryDelays[attempt], options.signal)
|
|
47
|
+
}
|
|
48
|
+
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
49
|
+
|
|
50
|
+
const controller = new AbortController()
|
|
51
|
+
const abort = () => controller.abort()
|
|
52
|
+
const timeout = setTimeout(abort, options.timeoutMs ?? 5000)
|
|
53
|
+
options.signal?.addEventListener("abort", abort, { once: true })
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: {
|
|
59
|
+
Authorization: `Bearer ${config.token}`,
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
...options.headers,
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
})
|
|
66
|
+
const responseBody = limit(await response.text(), 1000)
|
|
67
|
+
|
|
68
|
+
if (response.ok) return { status: response.status, responseBody }
|
|
69
|
+
if (!isRetryable(response.status) || attempt === retryDelays.length - 1) {
|
|
70
|
+
throw new HttpError(
|
|
71
|
+
`HTTP request failed with status ${response.status}`,
|
|
72
|
+
response.status,
|
|
73
|
+
responseBody,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
78
|
+
if (error instanceof HttpError) throw error
|
|
79
|
+
if (attempt === retryDelays.length - 1) throw error
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timeout)
|
|
82
|
+
options.signal?.removeEventListener("abort", abort)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new Error("HTTP retry loop ended unexpectedly")
|
|
87
|
+
}
|