backeer-opencode 0.7.0 → 0.8.1

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.
@@ -1,6 +1,7 @@
1
1
  import { HttpError } from "./http.js"
2
2
  import { getFormApi } from "./opencode-v2-forms.js"
3
3
  import { createV2LiveClient } from "./live-bridge-client.js"
4
+ import { createHash } from "node:crypto"
4
5
 
5
6
  async function nativeReply(operation) {
6
7
  try {
@@ -28,25 +29,28 @@ function questionForm(form) {
28
29
  )
29
30
  }
30
31
 
32
+ function questionProperties(form) {
33
+ return { id: form.id, sessionID: form.sessionID,
34
+ nativeRevision: createHash("sha256").update(JSON.stringify({ id: form.id,
35
+ sessionID: form.sessionID, title: form.title, fields: form.fields })).digest("hex"),
36
+ questions: form.fields.map((field) => ({
37
+ header: field.title ?? form.title,
38
+ question: field.description ?? field.title ?? form.title,
39
+ options: (field.options ?? []).map((option) => ({
40
+ label: option.label, description: option.description ?? "",
41
+ })),
42
+ multiple: field.type === "multiselect",
43
+ custom: field.custom === true || !field.options?.length,
44
+ })) }
45
+ }
46
+
31
47
  export function normalizeV2Event(event) {
32
48
  const properties = event.data ?? {}
33
49
  const normalized = { ...event, properties }
34
50
  if (event.type === "form.created") {
35
51
  const form = properties.form
36
52
  if (!questionForm(form)) return []
37
- return [{ ...normalized, type: "question.v2.asked", properties: {
38
- id: form.id,
39
- sessionID: form.sessionID,
40
- questions: form.fields.map((field) => ({
41
- header: field.title ?? form.title,
42
- question: field.description ?? field.title ?? form.title,
43
- options: (field.options ?? []).map((option) => ({
44
- label: option.label, description: option.description ?? "",
45
- })),
46
- multiple: field.type === "multiselect",
47
- custom: field.custom === true || !field.options?.length,
48
- })),
49
- } }]
53
+ return [{ ...normalized, type: "question.v2.asked", properties: questionProperties(form) }]
50
54
  }
51
55
  if (event.type === "form.replied" || event.type === "form.cancelled") {
52
56
  return [{ ...normalized, type: event.type === "form.replied"
@@ -78,9 +82,11 @@ export function normalizeV2Event(event) {
78
82
  return [normalized]
79
83
  }
80
84
 
81
- export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
82
- return {
83
- liveBridge: createV2LiveClient(ctx),
85
+ export function createV2Client(ctx, resolveFormApi) {
86
+ const liveBridge = createV2LiveClient(ctx)
87
+ resolveFormApi ??= (sessionID) => getFormApi(ctx, sessionID, { ownsSession: liveBridge.ownsSession })
88
+ const client = {
89
+ liveBridge,
84
90
  app: {
85
91
  async log({ body }) {
86
92
  // Server stderr is captured by OpenCode's log, never the TUI input.
@@ -101,10 +107,13 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
101
107
  decision: body.response,
102
108
  })),
103
109
  v2: { session: { question: {
104
- async reply({ sessionID, requestID, questionV2Reply }) {
110
+ async reply({ sessionID, requestID, questionV2Reply, expectedNativeRevision }) {
105
111
  const api = await resolveFormApi(sessionID)
106
112
  const form = await nativeReply(() => api.get({ sessionID, formID: requestID }))
107
113
  if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422)
114
+ if (expectedNativeRevision && questionProperties(form).nativeRevision !== expectedNativeRevision) {
115
+ throw Object.assign(new HttpError("The native question changed; read it again", 409), { code: "question_changed_read_again" })
116
+ }
108
117
  const answers = questionV2Reply.answers
109
118
  if (!Array.isArray(answers) || answers.length !== form.fields.length) {
110
119
  throw new HttpError("Question answer count does not match the form", 422)
@@ -132,16 +141,27 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
132
141
  },
133
142
  } } },
134
143
  async ownsEvent(event) {
135
- if (event.location?.directory) return event.location.directory === ctx.location.directory
144
+ if (event.location?.directory === ctx.location.directory) return true
136
145
  const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID
137
146
  if (!sessionID) return event.type === "installation.update-available"
138
147
  if (sessionID === "global") return false
139
148
  try {
140
- const session = await ctx.session.get({ sessionID })
141
- return session.location?.directory === ctx.location.directory
149
+ const session = await liveBridge.get(sessionID)
150
+ return liveBridge.ownsSession(session)
142
151
  } catch {
143
152
  return false
144
153
  }
145
154
  },
146
155
  }
156
+ client.liveBridge.questions = async (sessionID) => {
157
+ const api = await resolveFormApi(sessionID)
158
+ const forms = await nativeReply(() => api.list({ sessionID }))
159
+ if (!Array.isArray(forms)) throw new HttpError("Invalid question list", 502)
160
+ return forms.filter(form => form.sessionID === sessionID && questionForm(form) &&
161
+ (!form.state || form.state.status === "pending")).map(questionProperties)
162
+ }
163
+ client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
164
+ sessionID: pending.sessionID, requestID: pending.id, questionV2Reply: { answers }, expectedNativeRevision: pending.nativeRevision,
165
+ })
166
+ return client
147
167
  }
@@ -0,0 +1,54 @@
1
+ import { readdir, readFile, readlink } from "node:fs/promises"
2
+ import { Service } from "@opencode/client/service"
3
+
4
+ import { HttpError } from "./http.js"
5
+
6
+ // Inspect only sockets already owned by this server; never scan other ports.
7
+ export async function ownLoopbackUrls({ platform = process.platform, fs = { readdir, readFile, readlink } } = {}) {
8
+ if (platform !== "linux") return []
9
+ const inodes = new Set((await Promise.all((await fs.readdir("/proc/self/fd")).map(async fd => {
10
+ try { return /^socket:\[(\d+)\]$/.exec(await fs.readlink(`/proc/self/fd/${fd}`))?.[1] }
11
+ catch { return undefined } // Descriptors can close while being inspected.
12
+ }))).filter(Boolean))
13
+ const tables = await Promise.all(["tcp", "tcp6"].map(async name => {
14
+ try { return await fs.readFile(`/proc/self/net/${name}`, "utf8") }
15
+ catch (error) { if (error.code === "ENOENT") return ""; throw error }
16
+ }))
17
+ const urls = new Set()
18
+ for (const table of tables) for (const row of table.trim().split("\n").slice(1)) {
19
+ const fields = row.trim().split(/\s+/)
20
+ if (fields[3] !== "0A" || !inodes.has(fields[9])) continue
21
+ const [address, portHex] = fields[1].split(":")
22
+ const host = address === "0100007F" ? "127.0.0.1"
23
+ : address === "00000000000000000000000001000000" ? "[::1]" : undefined
24
+ const port = Number.parseInt(portHex, 16)
25
+ if (host && port > 0 && port <= 65535) urls.add(`http://${host}:${port}`)
26
+ }
27
+ return [...urls]
28
+ }
29
+
30
+ export async function privateEndpoint({ argv = process.argv, env = process.env, pid = process.pid,
31
+ urls = ownLoopbackUrls, request = fetch } = {}) {
32
+ if (!argv.includes("--stdio")) return undefined
33
+ const flag = argv.indexOf("--port")
34
+ const rawPort = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith("--port="))?.slice(7)
35
+ const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN
36
+ const password = env.OPENCODE_PASSWORD
37
+ try {
38
+ if (!Number.isInteger(port) || port < 0 || port > 65535 || !password) throw new Error()
39
+ const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`]
40
+ for (const url of candidates) {
41
+ const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
42
+ let response
43
+ try {
44
+ response = await request(`${url}/api/info`, {
45
+ headers: Service.headers(endpoint), signal: AbortSignal.timeout(2000), redirect: "error",
46
+ })
47
+ if (response.ok && (await response.json()).pid === pid) return endpoint
48
+ } catch { /* Another listener owned by this process is not its native API. */ }
49
+ finally { await response?.body?.cancel().catch(() => {}) }
50
+ }
51
+ } catch { /* Report only a safe diagnostic, never upstream credentials or output. */ }
52
+ throw Object.assign(new HttpError("The existing private OpenCode server could not be verified; keep its session running", 501),
53
+ { code: "question_private_server_unavailable" })
54
+ }
@@ -2,23 +2,34 @@ import { OpenCode } from "@opencode/client"
2
2
  import { Service } from "@opencode/client/service"
3
3
 
4
4
  import { HttpError } from "./http.js"
5
+ import { privateEndpoint } from "./opencode-v2-endpoint.js"
5
6
 
6
- export async function getFormApi(ctx, sessionID, { makeClient = OpenCode.make } = {}) {
7
+ function unavailable(code, message, status = 501) {
8
+ return Object.assign(new HttpError(message, status), { code })
9
+ }
10
+
11
+ export async function getFormApi(ctx, sessionID, { makeClient = OpenCode.make, ownsSession, resolvePrivate = privateEndpoint } = {}) {
7
12
  // 2.0.26 exposes forms on the client API, but not on the plugin context.
8
- // Discover only: never start a second service or change its configuration.
9
- const endpoint = await Service.discover({ version: ctx.app.version })
13
+ // Prefer the current private server; never start a second service or change it.
14
+ // discover() itself checks the registered service's protocol compatibility.
15
+ // An app patch-version difference must not hide a compatible owning service.
16
+ const endpoint = await resolvePrivate() ?? await Service.discover({ version: ctx.app.version }) ?? await Service.discover()
10
17
  if (!endpoint) {
11
- throw new HttpError("Remote question replies require the OpenCode V2 shared service", 501)
18
+ throw unavailable("question_shared_service_unavailable", "Remote questions require a compatible running OpenCode V2 shared service")
12
19
  }
13
20
  const client = makeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
14
21
  let session
15
22
  try {
16
23
  session = await client.session.get({ sessionID })
17
24
  } catch {
18
- throw new HttpError("The question session is not owned by the shared OpenCode service", 501)
25
+ throw unavailable("question_session_not_in_shared_service", "The question session is not owned by the shared OpenCode service")
26
+ }
27
+ if (session.id !== undefined && session.id !== sessionID) {
28
+ throw unavailable("question_session_not_in_shared_service", "The shared service returned another question session")
19
29
  }
20
- if (session.location.directory !== ctx.location.directory) {
21
- throw new HttpError("The question session belongs to another OpenCode location", 403)
30
+ const owned = ownsSession ? await ownsSession(session) : session.location?.directory === ctx.location.directory
31
+ if (!owned) {
32
+ throw unavailable("question_session_location_mismatch", "The question session belongs to another OpenCode location", 403)
22
33
  }
23
34
  return client.session.form
24
35
  }
@@ -0,0 +1,49 @@
1
+ import { loadConfig } from "./config.js"
2
+ import { createLiveBridge } from "./live-bridge.js"
3
+ import { createV2Client, normalizeV2Event } from "./opencode-v2-client.js"
4
+ import { HttpError } from "./http.js"
5
+
6
+ // The public TUI plugin context injects the client of the actual connected
7
+ // OpenCode owner, including standalone processes. Never discover another server.
8
+ export async function setup(ctx) {
9
+ if (ctx.options?.liveBridge?.owner !== "tui" || !ctx.location?.directory) return
10
+ const native = ctx.client
11
+ if (!native?.session?.form?.list || !native.session.form.get || !native.session.form.reply ||
12
+ !native.session.form.cancel || !native.session.get || !native.session.prompt) {
13
+ throw new HttpError("The injected OpenCode client does not expose native question forms", 501)
14
+ }
15
+ const config = loadConfig(ctx.options)
16
+ if (!config.liveBridge.enabled) return
17
+ const ownerContext = { app: ctx.app, options: ctx.options, location: ctx.location, session: native.session }
18
+ let client
19
+ client = createV2Client(ownerContext, async sessionID => {
20
+ const info = await native.session.get({ sessionID })
21
+ if (info.id !== sessionID || !await client.liveBridge.ownsSession(info)) {
22
+ throw Object.assign(new HttpError("The native question session is outside the verified project", 403),
23
+ { code: "question_session_location_mismatch" })
24
+ }
25
+ return native.session.form
26
+ })
27
+ client.liveBridge.questionTransport = "injected_native_client"
28
+ const bridge = await createLiveBridge({ config, client, directory: ctx.location.directory })
29
+ let disposed = false
30
+ // Public native data events, not terminal content or UI automation. New
31
+ // subscribers read authoritative pending forms directly from the same owner.
32
+ const unlisten = ctx.data.listen(({ details }) => {
33
+ void (async () => {
34
+ if (disposed || !await client.ownsEvent(details)) return
35
+ for (const event of normalizeV2Event(details)) {
36
+ if (disposed) return
37
+ await bridge.handle(event)
38
+ }
39
+ })().catch(() => { /* A failed event must not affect the native TUI or active work. */ })
40
+ })
41
+ let cleanup
42
+ return () => cleanup ??= (async () => {
43
+ disposed = true
44
+ unlisten()
45
+ await bridge.dispose()
46
+ })()
47
+ }
48
+
49
+ export default { id: "backeer-opencode", setup }
package/src/plugin.js CHANGED
@@ -48,7 +48,9 @@ export default async function BackeerPlugin({ client, directory, feedback, v2 =
48
48
  const reporter = createOpenCodeReporter(client, config.logLevel)
49
49
  const conversation = feedback ?? createConversationFeedback({ client, reporter })
50
50
  if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error)
51
- const liveBridge = await createLiveBridge({ config, client, directory, reporter }).catch((error) => {
51
+ const bridgeConfig = config.liveBridge.owner === "tui"
52
+ ? { ...config, liveBridge: { enabled: false } } : config
53
+ const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
52
54
  reporter.error("Live control unavailable", error)
53
55
  return { async handle() {}, async dispose() {} }
54
56
  })
@@ -14,22 +14,27 @@ export function validateQuestionCommand(command, pending, now = Date.now()) {
14
14
  }
15
15
  if (response.action === "reject") return
16
16
 
17
- if (!Array.isArray(response.answers) || response.answers.length !== pending.questions.length) {
18
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
17
+ const errorCode = validateQuestionAnswers(response.answers, pending.questions)
18
+ if (errorCode) return { status: "rejected", action: response.action, errorCode }
19
+ }
20
+
21
+ export function validateQuestionAnswers(values, questions) {
22
+ if (!Array.isArray(values) || values.length !== questions.length) {
23
+ return "invalid_answers"
19
24
  }
20
- for (let index = 0; index < pending.questions.length; index++) {
21
- const question = pending.questions[index]
22
- const answers = response.answers[index]
25
+ for (let index = 0; index < questions.length; index++) {
26
+ const question = questions[index]
27
+ const answers = values[index]
23
28
  if (!Array.isArray(answers) || (!question.multiple && answers.length > 1)) {
24
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
29
+ return "invalid_answers"
25
30
  }
26
31
  for (const answer of answers) {
27
32
  if (typeof answer !== "string" || answer.trim().length === 0) {
28
- return { status: "rejected", action: response.action, errorCode: "invalid_answers" }
33
+ return "invalid_answers"
29
34
  }
30
35
  const known = question.options.some((option) => option.label === answer)
31
36
  if (!known && !question.custom) {
32
- return { status: "rejected", action: response.action, errorCode: "custom_not_allowed" }
37
+ return "custom_not_allowed"
33
38
  }
34
39
  }
35
40
  }
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.7.0"
1
+ export const PLUGIN_VERSION = "0.8.1"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"