backeer-opencode 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to `backeer-opencode` are documented here.
4
4
 
5
+ ## 0.8.1 - 2026-11-03
6
+
7
+ - Resolve an existing private V2 server before shared-service discovery; verify native API PID and exact session/project without starting a service or hardcoded ports/session IDs.
8
+ - Discover Linux ephemeral listeners only from the current process's socket inodes; retain compatible owning shared-service and verified worktree handling.
9
+ - Return allowlisted native-question diagnostics without exposing upstream text or credentials.
10
+
5
11
  ## 0.8.0 - 2026-11-03
6
12
 
7
13
  - Add `list_questions` and `answer_question` to both MCP adapters: pending text/options, single/multiple selection and custom text answers.
package/README.md CHANGED
@@ -301,12 +301,15 @@ authentication and other forms are never exported. Events from other locations
301
301
  are ignored. V2 has no native `todo.updated` event, so todo notifications are
302
302
  disabled there regardless of the `todos` option.
303
303
 
304
- V2 remote question replies require the managed shared service. OpenCode 2.0.26
305
- does not expose forms on the plugin context, so the plugin uses the official
306
- client's authenticated service discovery and verifies session location before
307
- answering. It never starts another service. Notifications work in standalone
308
- mode, but remote question replies fail explicitly there rather than contacting
309
- a different session.
304
+ V2 question reads/replies use the official authenticated client API. For an
305
+ existing private stdio server, 0.8.1 first verifies its own native API and PID;
306
+ a Linux ephemeral port is discovered only from that process's socket inodes.
307
+ An explicit private `--port` also works without Linux socket discovery.
308
+ Other processes retain compatible shared-service discovery. Exact session and
309
+ project/worktree ownership are checked before reading or answering. The plugin
310
+ never starts another service, moves a session or changes authentication. An
311
+ unverifiable private server fails explicitly rather than selecting a different
312
+ shared service.
310
313
 
311
314
  The V1 event mappings are:
312
315
 
@@ -44,7 +44,7 @@ function make7(connect) {
44
44
  if (current === connection)
45
45
  current = void 0;
46
46
  }
47
- async function run(connection) {
47
+ async function run2(connection) {
48
48
  let iterator;
49
49
  let completion = {};
50
50
  try {
@@ -133,7 +133,7 @@ function make7(connect) {
133
133
  if (connection.connected)
134
134
  subscriber.push(connection.connected);
135
135
  if (fresh)
136
- run(connection);
136
+ run2(connection);
137
137
  }
138
138
  return {
139
139
  next() {
@@ -1966,7 +1966,9 @@ function loadLiveBridgeConfig(option, { configDirectory, ingestToken } = {}) {
1966
1966
  const tokenFile = values.tokenFile ?? process.env.BACKEER_LIVE_TOKEN_FILE ?? join(configDirectory, "secrets", "backeer-live-token");
1967
1967
  const token = readCredential(tokenFile);
1968
1968
  if (token === ingestToken) throw new Error("Live control requires a credential distinct from the Backeer ingest token");
1969
- return { enabled: true, token, tokenFile, port };
1969
+ const owner = values.owner ?? "server";
1970
+ if (!["server", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui");
1971
+ return { enabled: true, token, tokenFile, port, owner };
1970
1972
  } catch (error) {
1971
1973
  return { enabled: false, error: error.message };
1972
1974
  }
@@ -2395,7 +2397,7 @@ import { randomUUID } from "node:crypto";
2395
2397
  import { statSync } from "node:fs";
2396
2398
 
2397
2399
  // src/updates.js
2398
- var PLUGIN_VERSION = "0.8.0";
2400
+ var PLUGIN_VERSION = "0.8.1";
2399
2401
  var PACKAGE_NAME = "backeer-opencode";
2400
2402
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
2401
2403
  var CHECK_TIMEOUT_MS = 3e3;
@@ -3980,24 +3982,78 @@ function createPermissionController({
3980
3982
  };
3981
3983
  }
3982
3984
 
3985
+ // src/live-session-scope.js
3986
+ import { execFile } from "node:child_process";
3987
+ import { promisify } from "node:util";
3988
+ import { realpath } from "node:fs/promises";
3989
+ import { isAbsolute as isAbsolute2 } from "node:path";
3990
+ var run = promisify(execFile);
3991
+ async function commonDirectory(directory) {
3992
+ const { stdout } = await run(
3993
+ "git",
3994
+ ["-C", directory, "rev-parse", "--path-format=absolute", "--git-common-dir"],
3995
+ { timeout: 1e3, maxBuffer: 8192, windowsHide: true }
3996
+ );
3997
+ return realpath(stdout.trim());
3998
+ }
3999
+ function approvedWorktreeSessions(value) {
4000
+ if (!Array.isArray(value) || value.length > 16) return [];
4001
+ return value.filter((item) => /^[A-Za-z0-9_-]{1,160}$/.test(item?.sessionID) && item.sessionID !== "global" && typeof item.directory === "string" && isAbsolute2(item.directory) && item.directory.length <= 4096);
4002
+ }
4003
+ function createSessionScope(directory, { resolveCommon = commonDirectory, approvedSessions = [] } = {}) {
4004
+ const observed = /* @__PURE__ */ new Map();
4005
+ const approved = approvedWorktreeSessions(approvedSessions);
4006
+ return async function owns(info) {
4007
+ const location = info?.location?.directory ?? info?.directory;
4008
+ if (typeof location !== "string" || typeof directory !== "string") return false;
4009
+ if (location === directory) {
4010
+ if (info.id) {
4011
+ observed.set(info.id, info.projectID);
4012
+ if (observed.size > 256) observed.delete(observed.keys().next().value);
4013
+ }
4014
+ return true;
4015
+ }
4016
+ const explicitlyApproved = approved.some((item) => item.sessionID === info.id && item.directory === location);
4017
+ if (!explicitlyApproved && (!observed.has(info.id) || !info.projectID || observed.get(info.id) !== info.projectID)) return false;
4018
+ try {
4019
+ const [base, current] = await Promise.all([resolveCommon(directory), resolveCommon(location)]);
4020
+ return base === current;
4021
+ } catch {
4022
+ return false;
4023
+ }
4024
+ };
4025
+ }
4026
+
3983
4027
  // src/live-bridge-client.js
3984
4028
  function createV2LiveClient(ctx) {
3985
- const known = /* @__PURE__ */ new Map();
4029
+ const approvedSessions = approvedWorktreeSessions(ctx.options?.liveBridge?.sessionWorktrees);
4030
+ const known = new Map(approvedSessions.map((item) => [item.sessionID, void 0]));
4031
+ const ownsSession = createSessionScope(ctx.location?.directory, { approvedSessions });
3986
4032
  let disposed = false;
3987
4033
  async function get(id) {
3988
4034
  const info = await ctx.session.get({ sessionID: id });
3989
4035
  if (disposed) return info;
4036
+ await ownsSession(info);
3990
4037
  known.set(id, info);
3991
4038
  if (known.size > 100) known.delete(known.keys().next().value);
3992
4039
  return info;
3993
4040
  }
3994
4041
  return {
3995
4042
  async list() {
4043
+ const refreshed = [];
3996
4044
  if (typeof ctx.session.list === "function") {
3997
- return ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
4045
+ const result = await ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
4046
+ const values = Array.isArray(result) ? result : result?.data;
4047
+ if (!Array.isArray(values)) throw new Error("Invalid native session list");
4048
+ for (const info of values.slice(0, 100)) {
4049
+ if (await ownsSession(info)) {
4050
+ refreshed.push(info);
4051
+ known.set(info.id, info);
4052
+ }
4053
+ }
3998
4054
  }
3999
- const refreshed = [];
4000
- for (const id of known.keys()) {
4055
+ for (const id of [...known.keys()].slice(0, 100)) {
4056
+ if (refreshed.some((info) => info.id === id)) continue;
4001
4057
  try {
4002
4058
  refreshed.push(await get(id));
4003
4059
  } catch {
@@ -4007,6 +4063,7 @@ function createV2LiveClient(ctx) {
4007
4063
  return refreshed;
4008
4064
  },
4009
4065
  get,
4066
+ ownsSession,
4010
4067
  prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
4011
4068
  dispose: () => {
4012
4069
  disposed = true;
@@ -4209,7 +4266,8 @@ function questionSnapshot(pending, text) {
4209
4266
  const revision = createHash("sha256").update(JSON.stringify({
4210
4267
  id: pending.id,
4211
4268
  sessionID: pending.sessionID,
4212
- questions: pending.questions
4269
+ questions: pending.questions,
4270
+ ...pending.nativeRevision ? { nativeRevision: pending.nativeRevision } : {}
4213
4271
  })).digest("hex");
4214
4272
  const result = { questionId: pending.id, sessionID: pending.sessionID, revision, questions };
4215
4273
  if (Buffer.byteLength(JSON.stringify(result)) > 3e4) throw invalid("question_too_large");
@@ -4311,13 +4369,17 @@ async function createLiveBridge({ config, client, directory, reporter }) {
4311
4369
  if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error;
4312
4370
  throw failure(404, "session_not_found");
4313
4371
  }
4314
- if (info?.id !== id || typeof info.time?.archived === "number" || (info.location?.directory ?? info.directory) !== directory) {
4372
+ if (info?.id !== id || typeof info.time?.archived === "number") {
4315
4373
  throw failure(404, "session_not_found");
4316
4374
  }
4375
+ const locationDirectory = info.location?.directory ?? info.directory;
4376
+ const owned = api.ownsSession ? await api.ownsSession(info) : locationDirectory === directory;
4377
+ if (!owned) throw failure(404, "session_not_found");
4317
4378
  return {
4318
4379
  id,
4319
4380
  directory,
4320
4381
  title: sanitizer.text(info.title, 240),
4382
+ ...locationDirectory !== directory ? { locationDirectory: sanitizer.text(locationDirectory, 4096), scope: "verified_git_worktree" } : {},
4321
4383
  ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {},
4322
4384
  status: states.get(id) ?? "unknown"
4323
4385
  };
@@ -4456,13 +4518,33 @@ data: ${JSON.stringify(record)}
4456
4518
  const result2 = await api.list();
4457
4519
  const values = Array.isArray(result2) ? result2 : result2?.data;
4458
4520
  if (!Array.isArray(values)) throw failure(502, "invalid_session_list");
4459
- const own = values.slice(0, 100).filter((info) => validBridgeID(info?.id) && typeof info.time?.archived !== "number" && (info.location?.directory ?? info.directory) === directory);
4460
- return json(response, 200, { sessions: own.map((info) => ({
4461
- id: info.id,
4462
- title: sanitizer.text(info.title, 240),
4463
- status: states.get(info.id) ?? "unknown",
4464
- ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
4465
- })), directory, cursor: cursor() });
4521
+ const own = [];
4522
+ for (const info of values.slice(0, 100)) {
4523
+ if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue;
4524
+ if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info);
4525
+ }
4526
+ return json(response, 200, {
4527
+ sessions: own.map((info) => ({
4528
+ id: info.id,
4529
+ title: sanitizer.text(info.title, 240),
4530
+ status: states.get(info.id) ?? "unknown",
4531
+ ...(info.location?.directory ?? info.directory) !== directory ? {
4532
+ locationDirectory: sanitizer.text(info.location?.directory ?? info.directory, 4096),
4533
+ scope: "verified_git_worktree"
4534
+ } : {},
4535
+ ...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
4536
+ })),
4537
+ directory,
4538
+ cursor: cursor(),
4539
+ protocolVersion: 1,
4540
+ bridgeVersion: PLUGIN_VERSION,
4541
+ capabilities: {
4542
+ questions: typeof api.questions === "function",
4543
+ questionAnswers: typeof api.answerQuestion === "function",
4544
+ verifiedWorktreeSessions: typeof api.ownsSession === "function",
4545
+ questionTransport: api.questionTransport ?? "server"
4546
+ }
4547
+ });
4466
4548
  }
4467
4549
  if (request.method === "GET" && url.pathname === "/v1/events") {
4468
4550
  const selected = url.searchParams.get("sessionID") || void 0;
@@ -4626,7 +4708,8 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
4626
4708
  const reporter = createOpenCodeReporter(client, config.logLevel);
4627
4709
  const conversation = feedback ?? createConversationFeedback({ client, reporter });
4628
4710
  if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error);
4629
- const liveBridge = await createLiveBridge({ config, client, directory, reporter }).catch((error) => {
4711
+ const bridgeConfig = config.liveBridge.owner === "tui" ? { ...config, liveBridge: { enabled: false } } : config;
4712
+ const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
4630
4713
  reporter.error("Live control unavailable", error);
4631
4714
  return { async handle() {
4632
4715
  }, async dispose() {
@@ -5265,26 +5348,103 @@ function delay(milliseconds) {
5265
5348
  }
5266
5349
  var Service = { discover, ensure, stop, headers: headers2 };
5267
5350
 
5351
+ // src/opencode-v2-endpoint.js
5352
+ import { readdir, readFile as readFile3, readlink } from "node:fs/promises";
5353
+ async function ownLoopbackUrls({ platform = process.platform, fs = { readdir, readFile: readFile3, readlink } } = {}) {
5354
+ if (platform !== "linux") return [];
5355
+ const inodes = new Set((await Promise.all((await fs.readdir("/proc/self/fd")).map(async (fd) => {
5356
+ try {
5357
+ return /^socket:\[(\d+)\]$/.exec(await fs.readlink(`/proc/self/fd/${fd}`))?.[1];
5358
+ } catch {
5359
+ return void 0;
5360
+ }
5361
+ }))).filter(Boolean));
5362
+ const tables = await Promise.all(["tcp", "tcp6"].map(async (name) => {
5363
+ try {
5364
+ return await fs.readFile(`/proc/self/net/${name}`, "utf8");
5365
+ } catch (error) {
5366
+ if (error.code === "ENOENT") return "";
5367
+ throw error;
5368
+ }
5369
+ }));
5370
+ const urls = /* @__PURE__ */ new Set();
5371
+ for (const table of tables) for (const row of table.trim().split("\n").slice(1)) {
5372
+ const fields = row.trim().split(/\s+/);
5373
+ if (fields[3] !== "0A" || !inodes.has(fields[9])) continue;
5374
+ const [address, portHex] = fields[1].split(":");
5375
+ const host = address === "0100007F" ? "127.0.0.1" : address === "00000000000000000000000001000000" ? "[::1]" : void 0;
5376
+ const port = Number.parseInt(portHex, 16);
5377
+ if (host && port > 0 && port <= 65535) urls.add(`http://${host}:${port}`);
5378
+ }
5379
+ return [...urls];
5380
+ }
5381
+ async function privateEndpoint({
5382
+ argv = process.argv,
5383
+ env = process.env,
5384
+ pid = process.pid,
5385
+ urls = ownLoopbackUrls,
5386
+ request = fetch
5387
+ } = {}) {
5388
+ if (!argv.includes("--stdio")) return void 0;
5389
+ const flag = argv.indexOf("--port");
5390
+ const rawPort = flag >= 0 ? argv[flag + 1] : argv.find((arg) => arg.startsWith("--port="))?.slice(7);
5391
+ const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN;
5392
+ const password = env.OPENCODE_PASSWORD;
5393
+ try {
5394
+ if (!Number.isInteger(port) || port < 0 || port > 65535 || !password) throw new Error();
5395
+ const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`];
5396
+ for (const url of candidates) {
5397
+ const endpoint = { url, auth: { type: "basic", username: "opencode", password } };
5398
+ let response;
5399
+ try {
5400
+ response = await request(`${url}/api/info`, {
5401
+ headers: Service.headers(endpoint),
5402
+ signal: AbortSignal.timeout(2e3),
5403
+ redirect: "error"
5404
+ });
5405
+ if (response.ok && (await response.json()).pid === pid) return endpoint;
5406
+ } catch {
5407
+ } finally {
5408
+ await response?.body?.cancel().catch(() => {
5409
+ });
5410
+ }
5411
+ }
5412
+ } catch {
5413
+ }
5414
+ throw Object.assign(
5415
+ new HttpError("The existing private OpenCode server could not be verified; keep its session running", 501),
5416
+ { code: "question_private_server_unavailable" }
5417
+ );
5418
+ }
5419
+
5268
5420
  // src/opencode-v2-forms.js
5269
- async function getFormApi(ctx, sessionID, { makeClient = exports_client.make } = {}) {
5270
- const endpoint = await Service.discover({ version: ctx.app.version });
5421
+ function unavailable(code, message, status = 501) {
5422
+ return Object.assign(new HttpError(message, status), { code });
5423
+ }
5424
+ async function getFormApi(ctx, sessionID, { makeClient = exports_client.make, ownsSession, resolvePrivate = privateEndpoint } = {}) {
5425
+ const endpoint = await resolvePrivate() ?? await Service.discover({ version: ctx.app.version }) ?? await Service.discover();
5271
5426
  if (!endpoint) {
5272
- throw new HttpError("Remote question replies require the OpenCode V2 shared service", 501);
5427
+ throw unavailable("question_shared_service_unavailable", "Remote questions require a compatible running OpenCode V2 shared service");
5273
5428
  }
5274
5429
  const client = makeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) });
5275
5430
  let session;
5276
5431
  try {
5277
5432
  session = await client.session.get({ sessionID });
5278
5433
  } catch {
5279
- throw new HttpError("The question session is not owned by the shared OpenCode service", 501);
5434
+ throw unavailable("question_session_not_in_shared_service", "The question session is not owned by the shared OpenCode service");
5280
5435
  }
5281
- if (session.location.directory !== ctx.location.directory) {
5282
- throw new HttpError("The question session belongs to another OpenCode location", 403);
5436
+ if (session.id !== void 0 && session.id !== sessionID) {
5437
+ throw unavailable("question_session_not_in_shared_service", "The shared service returned another question session");
5438
+ }
5439
+ const owned = ownsSession ? await ownsSession(session) : session.location?.directory === ctx.location.directory;
5440
+ if (!owned) {
5441
+ throw unavailable("question_session_location_mismatch", "The question session belongs to another OpenCode location", 403);
5283
5442
  }
5284
5443
  return client.session.form;
5285
5444
  }
5286
5445
 
5287
5446
  // src/opencode-v2-client.js
5447
+ import { createHash as createHash2 } from "node:crypto";
5288
5448
  async function nativeReply(operation) {
5289
5449
  try {
5290
5450
  return await operation();
@@ -5310,6 +5470,12 @@ function questionProperties(form) {
5310
5470
  return {
5311
5471
  id: form.id,
5312
5472
  sessionID: form.sessionID,
5473
+ nativeRevision: createHash2("sha256").update(JSON.stringify({
5474
+ id: form.id,
5475
+ sessionID: form.sessionID,
5476
+ title: form.title,
5477
+ fields: form.fields
5478
+ })).digest("hex"),
5313
5479
  questions: form.fields.map((field) => ({
5314
5480
  header: field.title ?? form.title,
5315
5481
  question: field.description ?? field.title ?? form.title,
@@ -5359,9 +5525,11 @@ function normalizeV2Event(event) {
5359
5525
  }
5360
5526
  return [normalized];
5361
5527
  }
5362
- function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
5528
+ function createV2Client(ctx, resolveFormApi) {
5529
+ const liveBridge = createV2LiveClient(ctx);
5530
+ resolveFormApi ??= (sessionID) => getFormApi(ctx, sessionID, { ownsSession: liveBridge.ownsSession });
5363
5531
  const client = {
5364
- liveBridge: createV2LiveClient(ctx),
5532
+ liveBridge,
5365
5533
  app: {
5366
5534
  async log({ body }) {
5367
5535
  console.error(JSON.stringify(body));
@@ -5381,10 +5549,13 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
5381
5549
  decision: body.response
5382
5550
  })),
5383
5551
  v2: { session: { question: {
5384
- async reply({ sessionID, requestID, questionV2Reply }) {
5552
+ async reply({ sessionID, requestID, questionV2Reply, expectedNativeRevision }) {
5385
5553
  const api = await resolveFormApi(sessionID);
5386
5554
  const form = await nativeReply(() => api.get({ sessionID, formID: requestID }));
5387
5555
  if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422);
5556
+ if (expectedNativeRevision && questionProperties(form).nativeRevision !== expectedNativeRevision) {
5557
+ throw Object.assign(new HttpError("The native question changed; read it again", 409), { code: "question_changed_read_again" });
5558
+ }
5388
5559
  const answers = questionV2Reply.answers;
5389
5560
  if (!Array.isArray(answers) || answers.length !== form.fields.length) {
5390
5561
  throw new HttpError("Question answer count does not match the form", 422);
@@ -5412,13 +5583,13 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
5412
5583
  }
5413
5584
  } } },
5414
5585
  async ownsEvent(event) {
5415
- if (event.location?.directory) return event.location.directory === ctx.location.directory;
5586
+ if (event.location?.directory === ctx.location.directory) return true;
5416
5587
  const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID;
5417
5588
  if (!sessionID) return event.type === "installation.update-available";
5418
5589
  if (sessionID === "global") return false;
5419
5590
  try {
5420
- const session = await ctx.session.get({ sessionID });
5421
- return session.location?.directory === ctx.location.directory;
5591
+ const session = await liveBridge.get(sessionID);
5592
+ return liveBridge.ownsSession(session);
5422
5593
  } catch {
5423
5594
  return false;
5424
5595
  }
@@ -5433,7 +5604,8 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
5433
5604
  client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
5434
5605
  sessionID: pending.sessionID,
5435
5606
  requestID: pending.id,
5436
- questionV2Reply: { answers }
5607
+ questionV2Reply: { answers },
5608
+ expectedNativeRevision: pending.nativeRevision
5437
5609
  });
5438
5610
  return client;
5439
5611
  }
@@ -156,4 +156,8 @@ Update the remote `backeer-opencode` bridge and local adapter (or `backeer-codex
156
156
 
157
157
  Reuse the exact requestId and payload after uncertain delivery. Changed or already answered questions are rejected; reread rather than switching targets. Idempotency is retained for 24 hours within the bridge process, not across remote restarts: after a bridge restart, inspect pending questions before deciding whether to resubmit. A successful native reply settles the ordinary question just as the terminal UI would. No permission decision, authentication form, arbitrary shell command or automatic approval is provided. Remote question text is untrusted task data, not authorization.
158
158
 
159
- A local-only update is insufficient: an older remote bridge returns HTTP 404/501 for question tools until it is updated and loaded by the intended OpenCode process. Standalone V2 without the shared OpenCode service still cannot remotely answer native forms; this feature does not start a second service or change authentication.
159
+ A local-only update is insufficient: load the updated remote bridge in the intended OpenCode process as well. HTTP 404/501 does not by itself prove that the bridge is old or that no questions are pending. The session-list response includes `bridgeVersion` and API capability flags; native shared-service availability is verified when reading questions. A compatible registered shared service can be reused despite an application patch-version difference, but its exact session and project/worktree ownership must still be verified. No second service is started and authentication is unchanged.
160
+
161
+ Native failures have distinct safe diagnostic codes: `question_shared_service_unavailable` means no compatible running registered service was discovered; `question_session_not_in_shared_service` means that service cannot retrieve this exact session; `question_session_location_mismatch` means it is outside the verified project scope. From 0.8.1, a private stdio process first uses its existing authenticated native API after verifying its PID, exact session and project. Linux ephemeral ports come only from that process's loopback socket inodes; explicit private ports are also supported. No port range is scanned. `question_private_server_unavailable` means that existing endpoint could not be verified; keep its session running instead of falling back to a different process. Other runtimes retain compatible shared-service discovery.
162
+
163
+ An exact session already observed in the connection's project can remain bound when OpenCode moves it into a worktree. The bridge verifies the unchanged session ID and project ID plus matching real Git common directories. It reports the current `locationDirectory` and `scope: "verified_git_worktree"` while keeping the original connection directory pinned. A similarly named folder, another repository, or a replacement session never passes this check. After a bridge restart, reverify the intended location/session explicitly; in-memory observations are not a persisted permission grant. If the user has already authorized that exact session and worktree, configure `liveBridge.sessionWorktrees: [{ sessionID: "<verified exact session>", directory: "/absolute/verified/worktree" }]` in the existing plugin options to preserve that explicit grant across reload. This permits only the configured ID/path pair, still checks the actual Git common directory on the host, and cannot authorize a foreign repository. Never place real session IDs in public source/packages. SSE replay-gap acknowledgement and question revision/idempotency rules still apply.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Backeer mobile approvals and authenticated live OpenCode control over SSE and MCP",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -10,7 +10,8 @@
10
10
  "./live-profiles": "./src/live-profiles.js",
11
11
  "./live-session-manager": "./src/live-session-manager.js",
12
12
  "./live-managed-mcp": "./src/live-managed-mcp.js",
13
- "./live-discovery": "./src/live-discovery.js"
13
+ "./live-discovery": "./src/live-discovery.js",
14
+ "./tui": "./src/opencode-v2-tui.js"
14
15
  },
15
16
  "bin": {
16
17
  "backeer-opencode": "bin/backeer-opencode.js",
@@ -1,31 +1,45 @@
1
1
  import { replyToQuestion } from "./permission-controller.js"
2
+ import { createSessionScope, approvedWorktreeSessions } from "./live-session-scope.js"
2
3
 
3
4
  // Normal prompts and ordinary question answers only; never permission/auth replies.
4
5
  export function createV2LiveClient(ctx) {
5
6
  // 2.0.26's plugin SessionDomain intentionally omits list(). Track sessions
6
7
  // observed through get/events instead of inventing that context method or
7
8
  // starting a second OpenCode service just for discovery.
8
- const known = new Map()
9
+ const approvedSessions = approvedWorktreeSessions(ctx.options?.liveBridge?.sessionWorktrees)
10
+ const known = new Map(approvedSessions.map(item => [item.sessionID, undefined]))
11
+ const ownsSession = createSessionScope(ctx.location?.directory, { approvedSessions })
9
12
  let disposed = false
10
13
  async function get(id) {
11
14
  const info = await ctx.session.get({ sessionID: id })
12
15
  if (disposed) return info
16
+ await ownsSession(info)
13
17
  known.set(id, info)
14
18
  if (known.size > 100) known.delete(known.keys().next().value)
15
19
  return info
16
20
  }
17
21
  return {
18
22
  async list() {
23
+ const refreshed = []
19
24
  if (typeof ctx.session.list === "function") {
20
- return ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" })
25
+ const result = await ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" })
26
+ const values = Array.isArray(result) ? result : result?.data
27
+ if (!Array.isArray(values)) throw new Error("Invalid native session list")
28
+ for (const info of values.slice(0, 100)) {
29
+ if (await ownsSession(info)) {
30
+ refreshed.push(info)
31
+ known.set(info.id, info)
32
+ }
33
+ }
21
34
  }
22
- const refreshed = []
23
- for (const id of known.keys()) {
35
+ for (const id of [...known.keys()].slice(0, 100)) {
36
+ if (refreshed.some(info => info.id === id)) continue
24
37
  try { refreshed.push(await get(id)) } catch { known.delete(id) }
25
38
  }
26
39
  return refreshed
27
40
  },
28
41
  get,
42
+ ownsSession,
29
43
  prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
30
44
  dispose: () => { disposed = true; known.clear() },
31
45
  }
@@ -40,7 +40,9 @@ export function loadLiveBridgeConfig(option, { configDirectory, ingestToken } =
40
40
  join(configDirectory, "secrets", "backeer-live-token")
41
41
  const token = readCredential(tokenFile)
42
42
  if (token === ingestToken) throw new Error("Live control requires a credential distinct from the Backeer ingest token")
43
- return { enabled: true, token, tokenFile, port }
43
+ const owner = values.owner ?? "server"
44
+ if (!["server", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui")
45
+ return { enabled: true, token, tokenFile, port, owner }
44
46
  } catch (error) {
45
47
  // A bad opt-in must not disable the existing mobile approval integration.
46
48
  return { enabled: false, error: error.message }
@@ -4,6 +4,7 @@ import { createV1LiveClient } from "./live-bridge-client.js"
4
4
  import { createLiveConnectionNotifier, LIVE_HEARTBEAT_MS, LIVE_RECONNECT_MS } from "./live-connection.js"
5
5
  import { createBridgeSanitizer, validBridgeID } from "./live-bridge-events.js"
6
6
  import { questionSnapshot, resolveQuestionAnswers } from "./live-questions.js"
7
+ import { PLUGIN_VERSION } from "./updates.js"
7
8
 
8
9
  const LIMITS = { events: 256, bytes: 2_000_000, clients: 8, commands: 1024, commandTTL: 86_400_000, body: 32_768, requests: 16 }
9
10
  function failure(status, code) { return Object.assign(new Error(code), { status, code }) }
@@ -73,11 +74,15 @@ export async function createLiveBridge({ config, client, directory, reporter })
73
74
  if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error
74
75
  throw failure(404, "session_not_found")
75
76
  }
76
- if (info?.id !== id || typeof info.time?.archived === "number" || (info.location?.directory ?? info.directory) !== directory) {
77
+ if (info?.id !== id || typeof info.time?.archived === "number") {
77
78
  throw failure(404, "session_not_found")
78
79
  }
80
+ const locationDirectory = info.location?.directory ?? info.directory
81
+ const owned = api.ownsSession ? await api.ownsSession(info) : locationDirectory === directory
82
+ if (!owned) throw failure(404, "session_not_found")
79
83
  return {
80
84
  id, directory, title: sanitizer.text(info.title, 240),
85
+ ...(locationDirectory !== directory ? { locationDirectory: sanitizer.text(locationDirectory, 4096), scope: "verified_git_worktree" } : {}),
81
86
  ...(validBridgeID(info.parentID) ? { parentID: info.parentID } : {}),
82
87
  status: states.get(id) ?? "unknown",
83
88
  }
@@ -197,12 +202,21 @@ export async function createLiveBridge({ config, client, directory, reporter })
197
202
  const result = await api.list()
198
203
  const values = Array.isArray(result) ? result : result?.data
199
204
  if (!Array.isArray(values)) throw failure(502, "invalid_session_list")
200
- const own = values.slice(0, 100).filter((info) => validBridgeID(info?.id) && typeof info.time?.archived !== "number" &&
201
- (info.location?.directory ?? info.directory) === directory)
205
+ const own = []
206
+ for (const info of values.slice(0, 100)) {
207
+ if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue
208
+ if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info)
209
+ }
202
210
  return json(response, 200, { sessions: own.map((info) => ({
203
211
  id: info.id, title: sanitizer.text(info.title, 240), status: states.get(info.id) ?? "unknown",
212
+ ...((info.location?.directory ?? info.directory) !== directory ? {
213
+ locationDirectory: sanitizer.text(info.location?.directory ?? info.directory, 4096), scope: "verified_git_worktree",
214
+ } : {}),
204
215
  ...(validBridgeID(info.parentID) ? { parentID: info.parentID } : {}),
205
- })), directory, cursor: cursor() })
216
+ })), directory, cursor: cursor(), protocolVersion: 1, bridgeVersion: PLUGIN_VERSION,
217
+ capabilities: { questions: typeof api.questions === "function", questionAnswers: typeof api.answerQuestion === "function",
218
+ verifiedWorktreeSessions: typeof api.ownsSession === "function",
219
+ questionTransport: api.questionTransport ?? "server" } })
206
220
  }
207
221
  if (request.method === "GET" && url.pathname === "/v1/events") {
208
222
  const selected = url.searchParams.get("sessionID") || undefined
@@ -3,6 +3,7 @@ import { open, lstat } from "node:fs/promises"
3
3
  import { isAbsolute } from "node:path"
4
4
  import { LIVE_RECONNECT_MS, LIVE_STALE_MS } from "./live-connection.js"
5
5
  import { checkWindowsTokenAcl } from "./live-token-permissions.js"
6
+ import { liveErrorMessages } from "./live-errors.js"
6
7
 
7
8
  const CURSOR = /^[A-Za-z0-9_-]{1,80}:\d{1,16}$/
8
9
  const SESSION = /^[A-Za-z0-9_-]{1,160}$/
@@ -108,9 +109,19 @@ export class LiveControlClient {
108
109
  ...(body ? { body: JSON.stringify(body) } : {}),
109
110
  })
110
111
  if (!response.ok) {
111
- // Never return a remote error body: it may contain original prompts or credentials.
112
- await response.body?.cancel().catch(() => {})
113
- throw new Error(`Live-control request failed (HTTP ${response.status})`)
112
+ // Read only a bounded JSON code; return exclusively local allowlisted
113
+ // messages, never original upstream error text or arbitrary JSON data.
114
+ let code
115
+ try {
116
+ if (response.headers.get("content-type")?.includes("application/json")) {
117
+ const value = JSON.parse(await boundedText(response, 4096))
118
+ if (Object.hasOwn(liveErrorMessages, value?.error)) code = value.error
119
+ }
120
+ } catch { /* Retain the generic status without exposing remote content. */ }
121
+ finally { await response.body?.cancel().catch(() => {}) }
122
+ const diagnostic = code ? `: ${liveErrorMessages[code]}` : ""
123
+ throw Object.assign(new Error(`Live-control request failed (HTTP ${response.status})${diagnostic}`),
124
+ { status: response.status, ...(code ? { code } : {}) })
114
125
  }
115
126
  const text = await boundedText(response, 256 * 1024)
116
127
  return JSON.parse(text)
@@ -4,6 +4,7 @@ import { z } from "zod"
4
4
  import { LiveControlClient, readLiveControlToken } from "./live-control-client.js"
5
5
  import { discoveryDefaults } from "./live-discovery.js"
6
6
  import { answerSchema, listQuestionDescription, answerQuestionDescription } from "./live-question-tools.js"
7
+ import { safeLiveErrorMessage } from "./live-errors.js"
7
8
 
8
9
  const cursor = z.string().regex(/^[A-Za-z0-9_-]{1,80}:\d{1,16}$/)
9
10
  const sessionId = z.string().regex(/^[A-Za-z0-9_-]{1,160}$/)
@@ -22,8 +23,7 @@ export function createLiveControlMcp(client) {
22
23
  try { return result(await run(args, extra)) }
23
24
  catch (error) {
24
25
  // Fetch errors can include URLs and secrets from third-party implementations; expose only known local messages.
25
- const message = /^Live-control request failed \(HTTP \d{3}\)$/.test(error.message)
26
- ? error.message : "Live-control operation failed. Check the SSH tunnel, bridge configuration and local adapter diagnostics."
26
+ const message = safeLiveErrorMessage(error) ?? "Live-control operation failed. Check the SSH tunnel, bridge configuration and local adapter diagnostics."
27
27
  return { isError: true, content: [{ type: "text", text: message }] }
28
28
  }
29
29
  })
@@ -0,0 +1,19 @@
1
+ // Only these locally authored diagnostics may cross the transport boundary.
2
+ // Arbitrary upstream error text, prompts and credentials are never displayed.
3
+ export const liveErrorMessages = Object.freeze({
4
+ question_private_server_unavailable: "The existing private OpenCode server could not be verified. Keep its current session running; check its native API connection without switching sessions or starting another server.",
5
+ question_shared_service_unavailable: "No compatible running OpenCode shared service is available for native questions. Keep current work running; configure its supported shared-service connection before retrying.",
6
+ question_session_not_in_shared_service: "The running OpenCode shared service does not own this exact question session. Check the service connection; do not switch sessions or create a replacement.",
7
+ question_session_location_mismatch: "The native question session is outside this verified project/worktree scope.",
8
+ questions_unavailable_update_bridge: "The remote bridge does not expose native question APIs. Update and load the remote bridge; this does not mean no questions are pending.",
9
+ question_changed_read_again: "The pending question changed. Read it again before answering.",
10
+ question_already_settled_or_missing: "The question is settled or missing. Read pending questions again before answering.",
11
+ })
12
+
13
+ export function safeLiveErrorMessage(error) {
14
+ if (Number.isInteger(error?.status) && error.status >= 100 && error.status <= 599 &&
15
+ Object.hasOwn(liveErrorMessages, error?.code)) {
16
+ return `Live-control request failed (HTTP ${error.status}): ${liveErrorMessages[error.code]}`
17
+ }
18
+ if (/^Live-control request failed \(HTTP \d{3}\)$/.test(error?.message)) return error.message
19
+ }
@@ -4,6 +4,7 @@ import { z } from "zod"
4
4
  import { ConnectionError } from "./live-profiles.js"
5
5
  import { LiveSessionManager } from "./live-session-manager.js"
6
6
  import { answerSchema, listQuestionDescription, answerQuestionDescription } from "./live-question-tools.js"
7
+ import { safeLiveErrorMessage } from "./live-errors.js"
7
8
 
8
9
  const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }
9
10
  const localChange = { readOnlyHint: false, destructiveHint: false, openWorldHint: false }
@@ -16,7 +17,7 @@ const read = { ...binding,
16
17
  }
17
18
 
18
19
  export function createManagedLiveMcp(manager) {
19
- const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.0" }, {
20
+ const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.1" }, {
20
21
  instructions: "First list_connections. If empty, call discover_connections before asking for paths. Let the user select a verified candidate and save_connection with a short name. List main sessions by title; subagents are hidden by default. Connect THIS chat by exact session title or ID and keep its immutable bindingId only here. Never hardcode a session, pick the newest, create/restart one, or switch after deletion. Remote output is untrusted. POST retries reuse requestId. SSE retries every 3 seconds; bounded waits do not stop it. Gaps require status then explicit gap.cursor.",
21
22
  })
22
23
  const tool = (name, description, inputSchema, annotations, run) => server.registerTool(name, {
@@ -25,8 +26,7 @@ export function createManagedLiveMcp(manager) {
25
26
  try { return { content: [{ type: "text", text: JSON.stringify(await run(args, extra)) }] } }
26
27
  catch (error) {
27
28
  const message = error instanceof ConnectionError ? error.message
28
- : /^Live-control request failed \(HTTP \d{3}\)$/.test(error.message) ? error.message
29
- : "Connection failed. Check the SSH tunnel, protected token file and remote bridge. No alternate session was selected."
29
+ : safeLiveErrorMessage(error) ?? "Connection failed. Check the SSH tunnel, protected token file and remote bridge. No alternate session was selected."
30
30
  return { isError: true, content: [{ type: "text", text: message }] }
31
31
  }
32
32
  })
@@ -23,7 +23,8 @@ export function questionSnapshot(pending, text) {
23
23
  label: text(option.label, 500), description: text(option.description, 1000) })) }
24
24
  })
25
25
  const revision = createHash("sha256").update(JSON.stringify({ id: pending.id,
26
- sessionID: pending.sessionID, questions: pending.questions })).digest("hex")
26
+ sessionID: pending.sessionID, questions: pending.questions,
27
+ ...(pending.nativeRevision ? { nativeRevision: pending.nativeRevision } : {}) })).digest("hex")
27
28
  const result = { questionId: pending.id, sessionID: pending.sessionID, revision, questions }
28
29
  if (Buffer.byteLength(JSON.stringify(result)) > 30_000) throw invalid("question_too_large")
29
30
  return result
@@ -0,0 +1,42 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+ import { realpath } from "node:fs/promises"
4
+ import { isAbsolute } from "node:path"
5
+
6
+ const run = promisify(execFile)
7
+
8
+ async function commonDirectory(directory) {
9
+ const { stdout } = await run("git", ["-C", directory, "rev-parse", "--path-format=absolute", "--git-common-dir"],
10
+ { timeout: 1000, maxBuffer: 8192, windowsHide: true })
11
+ return realpath(stdout.trim())
12
+ }
13
+
14
+ // Follow only an already observed exact session into a real worktree of the
15
+ // same Git repository. A path prefix or a remote projectID alone is not proof.
16
+ export function approvedWorktreeSessions(value) {
17
+ if (!Array.isArray(value) || value.length > 16) return []
18
+ return value.filter(item => /^[A-Za-z0-9_-]{1,160}$/.test(item?.sessionID) && item.sessionID !== "global" &&
19
+ typeof item.directory === "string" && isAbsolute(item.directory) && item.directory.length <= 4096)
20
+ }
21
+
22
+ export function createSessionScope(directory, { resolveCommon = commonDirectory, approvedSessions = [] } = {}) {
23
+ const observed = new Map()
24
+ const approved = approvedWorktreeSessions(approvedSessions)
25
+ return async function owns(info) {
26
+ const location = info?.location?.directory ?? info?.directory
27
+ if (typeof location !== "string" || typeof directory !== "string") return false
28
+ if (location === directory) {
29
+ if (info.id) {
30
+ observed.set(info.id, info.projectID)
31
+ if (observed.size > 256) observed.delete(observed.keys().next().value)
32
+ }
33
+ return true
34
+ }
35
+ const explicitlyApproved = approved.some(item => item.sessionID === info.id && item.directory === location)
36
+ if (!explicitlyApproved && (!observed.has(info.id) || !info.projectID || observed.get(info.id) !== info.projectID)) return false
37
+ try {
38
+ const [base, current] = await Promise.all([resolveCommon(directory), resolveCommon(location)])
39
+ return base === current
40
+ } catch { return false }
41
+ }
42
+ }
@@ -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 {
@@ -30,6 +31,8 @@ function questionForm(form) {
30
31
 
31
32
  function questionProperties(form) {
32
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"),
33
36
  questions: form.fields.map((field) => ({
34
37
  header: field.title ?? form.title,
35
38
  question: field.description ?? field.title ?? form.title,
@@ -79,9 +82,11 @@ export function normalizeV2Event(event) {
79
82
  return [normalized]
80
83
  }
81
84
 
82
- export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, sessionID)) {
85
+ export function createV2Client(ctx, resolveFormApi) {
86
+ const liveBridge = createV2LiveClient(ctx)
87
+ resolveFormApi ??= (sessionID) => getFormApi(ctx, sessionID, { ownsSession: liveBridge.ownsSession })
83
88
  const client = {
84
- liveBridge: createV2LiveClient(ctx),
89
+ liveBridge,
85
90
  app: {
86
91
  async log({ body }) {
87
92
  // Server stderr is captured by OpenCode's log, never the TUI input.
@@ -102,10 +107,13 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
102
107
  decision: body.response,
103
108
  })),
104
109
  v2: { session: { question: {
105
- async reply({ sessionID, requestID, questionV2Reply }) {
110
+ async reply({ sessionID, requestID, questionV2Reply, expectedNativeRevision }) {
106
111
  const api = await resolveFormApi(sessionID)
107
112
  const form = await nativeReply(() => api.get({ sessionID, formID: requestID }))
108
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
+ }
109
117
  const answers = questionV2Reply.answers
110
118
  if (!Array.isArray(answers) || answers.length !== form.fields.length) {
111
119
  throw new HttpError("Question answer count does not match the form", 422)
@@ -133,13 +141,13 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
133
141
  },
134
142
  } } },
135
143
  async ownsEvent(event) {
136
- if (event.location?.directory) return event.location.directory === ctx.location.directory
144
+ if (event.location?.directory === ctx.location.directory) return true
137
145
  const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID
138
146
  if (!sessionID) return event.type === "installation.update-available"
139
147
  if (sessionID === "global") return false
140
148
  try {
141
- const session = await ctx.session.get({ sessionID })
142
- return session.location?.directory === ctx.location.directory
149
+ const session = await liveBridge.get(sessionID)
150
+ return liveBridge.ownsSession(session)
143
151
  } catch {
144
152
  return false
145
153
  }
@@ -153,7 +161,7 @@ export function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(c
153
161
  (!form.state || form.state.status === "pending")).map(questionProperties)
154
162
  }
155
163
  client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
156
- sessionID: pending.sessionID, requestID: pending.id, questionV2Reply: { answers },
164
+ sessionID: pending.sessionID, requestID: pending.id, questionV2Reply: { answers }, expectedNativeRevision: pending.nativeRevision,
157
165
  })
158
166
  return client
159
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
  })
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.8.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"