backeer-opencode 0.8.0 → 0.8.2
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 +12 -0
- package/README.md +9 -6
- package/dist/backeer-permission.js +235 -46
- package/docs/live-control.md +31 -1
- package/package.json +3 -2
- package/src/live-bridge-client.js +18 -4
- package/src/live-bridge-config.js +8 -1
- package/src/live-bridge.js +23 -5
- package/src/live-control-client.js +14 -3
- package/src/live-control-mcp.js +2 -2
- package/src/live-errors.js +19 -0
- package/src/live-managed-mcp.js +3 -3
- package/src/live-questions.js +2 -1
- package/src/live-session-scope.js +42 -0
- package/src/opencode-v2-client.js +15 -7
- package/src/opencode-v2-endpoint.js +60 -0
- package/src/opencode-v2-forms.js +18 -7
- package/src/opencode-v2-tui.js +49 -0
- package/src/plugin.js +4 -1
- package/src/updates.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `backeer-opencode` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.8.2 - 2026-11-03
|
|
6
|
+
|
|
7
|
+
- Reuse the current Linux server's initial native password when OpenCode removes it from its runtime environment; credentials stay internal and no other process is inspected.
|
|
8
|
+
- Add optional `liveBridge.projectDirectory` to keep unrelated locations from claiming the listener; `owner: "standalone"` limits that listener to the existing private stdio server when a shared server also loads the same project.
|
|
9
|
+
- Include the optional native TUI companion entrypoint; activation remains explicit and the server owner remains the default.
|
|
10
|
+
|
|
11
|
+
## 0.8.1 - 2026-11-03
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- Discover Linux ephemeral listeners only from the current process's socket inodes; retain compatible owning shared-service and verified worktree handling.
|
|
15
|
+
- Return allowlisted native-question diagnostics without exposing upstream text or credentials.
|
|
16
|
+
|
|
5
17
|
## 0.8.0 - 2026-11-03
|
|
6
18
|
|
|
7
19
|
- 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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
-
|
|
136
|
+
run2(connection);
|
|
137
137
|
}
|
|
138
138
|
return {
|
|
139
139
|
next() {
|
|
@@ -1966,7 +1966,20 @@ 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
|
-
|
|
1969
|
+
const owner = values.owner ?? "server";
|
|
1970
|
+
if (!["server", "standalone", "tui"].includes(owner)) throw new Error("Live control owner must be server, standalone or tui");
|
|
1971
|
+
const projectDirectory = values.projectDirectory;
|
|
1972
|
+
if (projectDirectory !== void 0 && (typeof projectDirectory !== "string" || !isAbsolute(projectDirectory))) {
|
|
1973
|
+
throw new Error("Live control projectDirectory must be an absolute path");
|
|
1974
|
+
}
|
|
1975
|
+
return {
|
|
1976
|
+
enabled: true,
|
|
1977
|
+
token,
|
|
1978
|
+
tokenFile,
|
|
1979
|
+
port,
|
|
1980
|
+
owner,
|
|
1981
|
+
...projectDirectory === void 0 ? {} : { projectDirectory }
|
|
1982
|
+
};
|
|
1970
1983
|
} catch (error) {
|
|
1971
1984
|
return { enabled: false, error: error.message };
|
|
1972
1985
|
}
|
|
@@ -2141,16 +2154,16 @@ function isAbortError(error) {
|
|
|
2141
2154
|
return error instanceof Error && error.name === "AbortError";
|
|
2142
2155
|
}
|
|
2143
2156
|
function sleep(milliseconds, signal2) {
|
|
2144
|
-
return new Promise((
|
|
2157
|
+
return new Promise((resolve3) => {
|
|
2145
2158
|
if (signal2?.aborted) {
|
|
2146
|
-
|
|
2159
|
+
resolve3();
|
|
2147
2160
|
return;
|
|
2148
2161
|
}
|
|
2149
2162
|
const timeout = setTimeout(done, milliseconds);
|
|
2150
2163
|
function done() {
|
|
2151
2164
|
clearTimeout(timeout);
|
|
2152
2165
|
signal2?.removeEventListener("abort", done);
|
|
2153
|
-
|
|
2166
|
+
resolve3();
|
|
2154
2167
|
}
|
|
2155
2168
|
signal2?.addEventListener("abort", done, { once: true });
|
|
2156
2169
|
});
|
|
@@ -2395,7 +2408,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2395
2408
|
import { statSync } from "node:fs";
|
|
2396
2409
|
|
|
2397
2410
|
// src/updates.js
|
|
2398
|
-
var PLUGIN_VERSION = "0.8.
|
|
2411
|
+
var PLUGIN_VERSION = "0.8.2";
|
|
2399
2412
|
var PACKAGE_NAME = "backeer-opencode";
|
|
2400
2413
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
2401
2414
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -3416,6 +3429,7 @@ function createLifecycleController({
|
|
|
3416
3429
|
// src/live-bridge.js
|
|
3417
3430
|
import { createServer } from "node:http";
|
|
3418
3431
|
import { randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
|
|
3432
|
+
import { resolve as resolve2 } from "node:path";
|
|
3419
3433
|
|
|
3420
3434
|
// src/permission-command.js
|
|
3421
3435
|
var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
|
|
@@ -3980,24 +3994,78 @@ function createPermissionController({
|
|
|
3980
3994
|
};
|
|
3981
3995
|
}
|
|
3982
3996
|
|
|
3997
|
+
// src/live-session-scope.js
|
|
3998
|
+
import { execFile } from "node:child_process";
|
|
3999
|
+
import { promisify } from "node:util";
|
|
4000
|
+
import { realpath } from "node:fs/promises";
|
|
4001
|
+
import { isAbsolute as isAbsolute2 } from "node:path";
|
|
4002
|
+
var run = promisify(execFile);
|
|
4003
|
+
async function commonDirectory(directory) {
|
|
4004
|
+
const { stdout } = await run(
|
|
4005
|
+
"git",
|
|
4006
|
+
["-C", directory, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
4007
|
+
{ timeout: 1e3, maxBuffer: 8192, windowsHide: true }
|
|
4008
|
+
);
|
|
4009
|
+
return realpath(stdout.trim());
|
|
4010
|
+
}
|
|
4011
|
+
function approvedWorktreeSessions(value) {
|
|
4012
|
+
if (!Array.isArray(value) || value.length > 16) return [];
|
|
4013
|
+
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);
|
|
4014
|
+
}
|
|
4015
|
+
function createSessionScope(directory, { resolveCommon = commonDirectory, approvedSessions = [] } = {}) {
|
|
4016
|
+
const observed = /* @__PURE__ */ new Map();
|
|
4017
|
+
const approved = approvedWorktreeSessions(approvedSessions);
|
|
4018
|
+
return async function owns(info) {
|
|
4019
|
+
const location = info?.location?.directory ?? info?.directory;
|
|
4020
|
+
if (typeof location !== "string" || typeof directory !== "string") return false;
|
|
4021
|
+
if (location === directory) {
|
|
4022
|
+
if (info.id) {
|
|
4023
|
+
observed.set(info.id, info.projectID);
|
|
4024
|
+
if (observed.size > 256) observed.delete(observed.keys().next().value);
|
|
4025
|
+
}
|
|
4026
|
+
return true;
|
|
4027
|
+
}
|
|
4028
|
+
const explicitlyApproved = approved.some((item) => item.sessionID === info.id && item.directory === location);
|
|
4029
|
+
if (!explicitlyApproved && (!observed.has(info.id) || !info.projectID || observed.get(info.id) !== info.projectID)) return false;
|
|
4030
|
+
try {
|
|
4031
|
+
const [base, current] = await Promise.all([resolveCommon(directory), resolveCommon(location)]);
|
|
4032
|
+
return base === current;
|
|
4033
|
+
} catch {
|
|
4034
|
+
return false;
|
|
4035
|
+
}
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
|
|
3983
4039
|
// src/live-bridge-client.js
|
|
3984
4040
|
function createV2LiveClient(ctx) {
|
|
3985
|
-
const
|
|
4041
|
+
const approvedSessions = approvedWorktreeSessions(ctx.options?.liveBridge?.sessionWorktrees);
|
|
4042
|
+
const known = new Map(approvedSessions.map((item) => [item.sessionID, void 0]));
|
|
4043
|
+
const ownsSession = createSessionScope(ctx.location?.directory, { approvedSessions });
|
|
3986
4044
|
let disposed = false;
|
|
3987
4045
|
async function get(id) {
|
|
3988
4046
|
const info = await ctx.session.get({ sessionID: id });
|
|
3989
4047
|
if (disposed) return info;
|
|
4048
|
+
await ownsSession(info);
|
|
3990
4049
|
known.set(id, info);
|
|
3991
4050
|
if (known.size > 100) known.delete(known.keys().next().value);
|
|
3992
4051
|
return info;
|
|
3993
4052
|
}
|
|
3994
4053
|
return {
|
|
3995
4054
|
async list() {
|
|
4055
|
+
const refreshed = [];
|
|
3996
4056
|
if (typeof ctx.session.list === "function") {
|
|
3997
|
-
|
|
4057
|
+
const result = await ctx.session.list({ directory: ctx.location.directory, limit: 100, order: "desc" });
|
|
4058
|
+
const values = Array.isArray(result) ? result : result?.data;
|
|
4059
|
+
if (!Array.isArray(values)) throw new Error("Invalid native session list");
|
|
4060
|
+
for (const info of values.slice(0, 100)) {
|
|
4061
|
+
if (await ownsSession(info)) {
|
|
4062
|
+
refreshed.push(info);
|
|
4063
|
+
known.set(info.id, info);
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
3998
4066
|
}
|
|
3999
|
-
const
|
|
4000
|
-
|
|
4067
|
+
for (const id of [...known.keys()].slice(0, 100)) {
|
|
4068
|
+
if (refreshed.some((info) => info.id === id)) continue;
|
|
4001
4069
|
try {
|
|
4002
4070
|
refreshed.push(await get(id));
|
|
4003
4071
|
} catch {
|
|
@@ -4007,6 +4075,7 @@ function createV2LiveClient(ctx) {
|
|
|
4007
4075
|
return refreshed;
|
|
4008
4076
|
},
|
|
4009
4077
|
get,
|
|
4078
|
+
ownsSession,
|
|
4010
4079
|
prompt: (id, text) => ctx.session.prompt({ sessionID: id, text, delivery: "queue" }),
|
|
4011
4080
|
dispose: () => {
|
|
4012
4081
|
disposed = true;
|
|
@@ -4209,7 +4278,8 @@ function questionSnapshot(pending, text) {
|
|
|
4209
4278
|
const revision = createHash("sha256").update(JSON.stringify({
|
|
4210
4279
|
id: pending.id,
|
|
4211
4280
|
sessionID: pending.sessionID,
|
|
4212
|
-
questions: pending.questions
|
|
4281
|
+
questions: pending.questions,
|
|
4282
|
+
...pending.nativeRevision ? { nativeRevision: pending.nativeRevision } : {}
|
|
4213
4283
|
})).digest("hex");
|
|
4214
4284
|
const result = { questionId: pending.id, sessionID: pending.sessionID, revision, questions };
|
|
4215
4285
|
if (Buffer.byteLength(JSON.stringify(result)) > 3e4) throw invalid("question_too_large");
|
|
@@ -4258,9 +4328,11 @@ async function readBody(request) {
|
|
|
4258
4328
|
}
|
|
4259
4329
|
async function createLiveBridge({ config, client, directory, reporter }) {
|
|
4260
4330
|
const options = config.liveBridge;
|
|
4261
|
-
if (!options?.enabled
|
|
4262
|
-
|
|
4263
|
-
|
|
4331
|
+
if (!options?.enabled || options.projectDirectory !== void 0 && resolve2(options.projectDirectory) !== resolve2(directory)) {
|
|
4332
|
+
return { handle() {
|
|
4333
|
+
}, async dispose() {
|
|
4334
|
+
}, address: void 0 };
|
|
4335
|
+
}
|
|
4264
4336
|
const token = options.token;
|
|
4265
4337
|
if (typeof token !== "string" || token.length < 32 || token === config.token) {
|
|
4266
4338
|
throw new Error("Live bridge requires a separate credential of at least 32 characters");
|
|
@@ -4278,7 +4350,7 @@ async function createLiveBridge({ config, client, directory, reporter }) {
|
|
|
4278
4350
|
if (disposed) return Promise.reject(failure(503, "bridge_closed"));
|
|
4279
4351
|
if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"));
|
|
4280
4352
|
lookups++;
|
|
4281
|
-
return new Promise((
|
|
4353
|
+
return new Promise((resolve3, reject) => {
|
|
4282
4354
|
let finished = false;
|
|
4283
4355
|
const finish = (complete, value) => {
|
|
4284
4356
|
if (finished) return;
|
|
@@ -4293,7 +4365,7 @@ async function createLiveBridge({ config, client, directory, reporter }) {
|
|
|
4293
4365
|
Promise.resolve().then(() => api.get(id)).then(
|
|
4294
4366
|
(value) => {
|
|
4295
4367
|
lookups--;
|
|
4296
|
-
finish(
|
|
4368
|
+
finish(resolve3, value);
|
|
4297
4369
|
},
|
|
4298
4370
|
(error) => {
|
|
4299
4371
|
lookups--;
|
|
@@ -4311,13 +4383,17 @@ async function createLiveBridge({ config, client, directory, reporter }) {
|
|
|
4311
4383
|
if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error;
|
|
4312
4384
|
throw failure(404, "session_not_found");
|
|
4313
4385
|
}
|
|
4314
|
-
if (info?.id !== id || typeof info.time?.archived === "number"
|
|
4386
|
+
if (info?.id !== id || typeof info.time?.archived === "number") {
|
|
4315
4387
|
throw failure(404, "session_not_found");
|
|
4316
4388
|
}
|
|
4389
|
+
const locationDirectory = info.location?.directory ?? info.directory;
|
|
4390
|
+
const owned = api.ownsSession ? await api.ownsSession(info) : locationDirectory === directory;
|
|
4391
|
+
if (!owned) throw failure(404, "session_not_found");
|
|
4317
4392
|
return {
|
|
4318
4393
|
id,
|
|
4319
4394
|
directory,
|
|
4320
4395
|
title: sanitizer.text(info.title, 240),
|
|
4396
|
+
...locationDirectory !== directory ? { locationDirectory: sanitizer.text(locationDirectory, 4096), scope: "verified_git_worktree" } : {},
|
|
4321
4397
|
...validBridgeID(info.parentID) ? { parentID: info.parentID } : {},
|
|
4322
4398
|
status: states.get(id) ?? "unknown"
|
|
4323
4399
|
};
|
|
@@ -4456,13 +4532,33 @@ data: ${JSON.stringify(record)}
|
|
|
4456
4532
|
const result2 = await api.list();
|
|
4457
4533
|
const values = Array.isArray(result2) ? result2 : result2?.data;
|
|
4458
4534
|
if (!Array.isArray(values)) throw failure(502, "invalid_session_list");
|
|
4459
|
-
const own =
|
|
4460
|
-
|
|
4461
|
-
id
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4535
|
+
const own = [];
|
|
4536
|
+
for (const info of values.slice(0, 100)) {
|
|
4537
|
+
if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue;
|
|
4538
|
+
if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info);
|
|
4539
|
+
}
|
|
4540
|
+
return json(response, 200, {
|
|
4541
|
+
sessions: own.map((info) => ({
|
|
4542
|
+
id: info.id,
|
|
4543
|
+
title: sanitizer.text(info.title, 240),
|
|
4544
|
+
status: states.get(info.id) ?? "unknown",
|
|
4545
|
+
...(info.location?.directory ?? info.directory) !== directory ? {
|
|
4546
|
+
locationDirectory: sanitizer.text(info.location?.directory ?? info.directory, 4096),
|
|
4547
|
+
scope: "verified_git_worktree"
|
|
4548
|
+
} : {},
|
|
4549
|
+
...validBridgeID(info.parentID) ? { parentID: info.parentID } : {}
|
|
4550
|
+
})),
|
|
4551
|
+
directory,
|
|
4552
|
+
cursor: cursor(),
|
|
4553
|
+
protocolVersion: 1,
|
|
4554
|
+
bridgeVersion: PLUGIN_VERSION,
|
|
4555
|
+
capabilities: {
|
|
4556
|
+
questions: typeof api.questions === "function",
|
|
4557
|
+
questionAnswers: typeof api.answerQuestion === "function",
|
|
4558
|
+
verifiedWorktreeSessions: typeof api.ownsSession === "function",
|
|
4559
|
+
questionTransport: api.questionTransport ?? "server"
|
|
4560
|
+
}
|
|
4561
|
+
});
|
|
4466
4562
|
}
|
|
4467
4563
|
if (request.method === "GET" && url.pathname === "/v1/events") {
|
|
4468
4564
|
const selected = url.searchParams.get("sessionID") || void 0;
|
|
@@ -4575,11 +4671,11 @@ retry: ${LIVE_RECONNECT_MS}
|
|
|
4575
4671
|
server.requestTimeout = 15e3;
|
|
4576
4672
|
server.headersTimeout = 1e4;
|
|
4577
4673
|
server.keepAliveTimeout = 5e3;
|
|
4578
|
-
await new Promise((
|
|
4674
|
+
await new Promise((resolve3, reject) => {
|
|
4579
4675
|
server.once("error", reject);
|
|
4580
4676
|
server.listen(options.port ?? 43871, "127.0.0.1", () => {
|
|
4581
4677
|
server.off("error", reject);
|
|
4582
|
-
|
|
4678
|
+
resolve3();
|
|
4583
4679
|
});
|
|
4584
4680
|
});
|
|
4585
4681
|
server.on("error", () => {
|
|
@@ -4588,7 +4684,7 @@ retry: ${LIVE_RECONNECT_MS}
|
|
|
4588
4684
|
handle,
|
|
4589
4685
|
address: server.address(),
|
|
4590
4686
|
dispose() {
|
|
4591
|
-
disposeTask ??= new Promise((
|
|
4687
|
+
disposeTask ??= new Promise((resolve3) => {
|
|
4592
4688
|
disposed = true;
|
|
4593
4689
|
for (const cancel of lookupWaiters) cancel();
|
|
4594
4690
|
for (const stream of streams) {
|
|
@@ -4596,7 +4692,7 @@ retry: ${LIVE_RECONNECT_MS}
|
|
|
4596
4692
|
stream.response.destroy();
|
|
4597
4693
|
}
|
|
4598
4694
|
server.close(() => {
|
|
4599
|
-
void connectionNotifications.dispose().then(
|
|
4695
|
+
void connectionNotifications.dispose().then(resolve3);
|
|
4600
4696
|
});
|
|
4601
4697
|
server.closeAllConnections?.();
|
|
4602
4698
|
events.length = 0;
|
|
@@ -4626,7 +4722,8 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
|
|
|
4626
4722
|
const reporter = createOpenCodeReporter(client, config.logLevel);
|
|
4627
4723
|
const conversation = feedback ?? createConversationFeedback({ client, reporter });
|
|
4628
4724
|
if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error);
|
|
4629
|
-
const
|
|
4725
|
+
const bridgeConfig = config.liveBridge.owner === "tui" || config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")) ? { ...config, liveBridge: { enabled: false } } : config;
|
|
4726
|
+
const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
|
|
4630
4727
|
reporter.error("Live control unavailable", error);
|
|
4631
4728
|
return { async handle() {
|
|
4632
4729
|
}, async dispose() {
|
|
@@ -5261,30 +5358,110 @@ async function terminate(info, options, timing) {
|
|
|
5261
5358
|
await rm2(options.file ?? fallback2(), { force: true });
|
|
5262
5359
|
}
|
|
5263
5360
|
function delay(milliseconds) {
|
|
5264
|
-
return new Promise((
|
|
5361
|
+
return new Promise((resolve3) => setTimeout(resolve3, milliseconds));
|
|
5265
5362
|
}
|
|
5266
5363
|
var Service = { discover, ensure, stop, headers: headers2 };
|
|
5267
5364
|
|
|
5365
|
+
// src/opencode-v2-endpoint.js
|
|
5366
|
+
import { readdir, readFile as readFile3, readlink } from "node:fs/promises";
|
|
5367
|
+
async function ownLoopbackUrls({ platform = process.platform, fs = { readdir, readFile: readFile3, readlink } } = {}) {
|
|
5368
|
+
if (platform !== "linux") return [];
|
|
5369
|
+
const inodes = new Set((await Promise.all((await fs.readdir("/proc/self/fd")).map(async (fd) => {
|
|
5370
|
+
try {
|
|
5371
|
+
return /^socket:\[(\d+)\]$/.exec(await fs.readlink(`/proc/self/fd/${fd}`))?.[1];
|
|
5372
|
+
} catch {
|
|
5373
|
+
return void 0;
|
|
5374
|
+
}
|
|
5375
|
+
}))).filter(Boolean));
|
|
5376
|
+
const tables = await Promise.all(["tcp", "tcp6"].map(async (name) => {
|
|
5377
|
+
try {
|
|
5378
|
+
return await fs.readFile(`/proc/self/net/${name}`, "utf8");
|
|
5379
|
+
} catch (error) {
|
|
5380
|
+
if (error.code === "ENOENT") return "";
|
|
5381
|
+
throw error;
|
|
5382
|
+
}
|
|
5383
|
+
}));
|
|
5384
|
+
const urls = /* @__PURE__ */ new Set();
|
|
5385
|
+
for (const table of tables) for (const row of table.trim().split("\n").slice(1)) {
|
|
5386
|
+
const fields = row.trim().split(/\s+/);
|
|
5387
|
+
if (fields[3] !== "0A" || !inodes.has(fields[9])) continue;
|
|
5388
|
+
const [address, portHex] = fields[1].split(":");
|
|
5389
|
+
const host = address === "0100007F" ? "127.0.0.1" : address === "00000000000000000000000001000000" ? "[::1]" : void 0;
|
|
5390
|
+
const port = Number.parseInt(portHex, 16);
|
|
5391
|
+
if (host && port > 0 && port <= 65535) urls.add(`http://${host}:${port}`);
|
|
5392
|
+
}
|
|
5393
|
+
return [...urls];
|
|
5394
|
+
}
|
|
5395
|
+
async function privateEndpoint({
|
|
5396
|
+
argv = process.argv,
|
|
5397
|
+
env = process.env,
|
|
5398
|
+
pid = process.pid,
|
|
5399
|
+
platform = process.platform,
|
|
5400
|
+
initialEnvironment = () => readFile3("/proc/self/environ", "utf8"),
|
|
5401
|
+
urls = ownLoopbackUrls,
|
|
5402
|
+
request = fetch
|
|
5403
|
+
} = {}) {
|
|
5404
|
+
if (!argv.includes("--stdio")) return void 0;
|
|
5405
|
+
const flag = argv.indexOf("--port");
|
|
5406
|
+
const rawPort = flag >= 0 ? argv[flag + 1] : argv.find((arg) => arg.startsWith("--port="))?.slice(7);
|
|
5407
|
+
const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN;
|
|
5408
|
+
try {
|
|
5409
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error();
|
|
5410
|
+
const password = env.OPENCODE_PASSWORD || (platform === "linux" ? (await initialEnvironment()).split("\0").find((value) => value.startsWith("OPENCODE_PASSWORD="))?.slice(18) : void 0);
|
|
5411
|
+
if (!password) throw new Error();
|
|
5412
|
+
const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`];
|
|
5413
|
+
for (const url of candidates) {
|
|
5414
|
+
const endpoint = { url, auth: { type: "basic", username: "opencode", password } };
|
|
5415
|
+
let response;
|
|
5416
|
+
try {
|
|
5417
|
+
response = await request(`${url}/api/info`, {
|
|
5418
|
+
headers: Service.headers(endpoint),
|
|
5419
|
+
signal: AbortSignal.timeout(2e3),
|
|
5420
|
+
redirect: "error"
|
|
5421
|
+
});
|
|
5422
|
+
if (response.ok && (await response.json()).pid === pid) return endpoint;
|
|
5423
|
+
} catch {
|
|
5424
|
+
} finally {
|
|
5425
|
+
await response?.body?.cancel().catch(() => {
|
|
5426
|
+
});
|
|
5427
|
+
}
|
|
5428
|
+
}
|
|
5429
|
+
} catch {
|
|
5430
|
+
}
|
|
5431
|
+
throw Object.assign(
|
|
5432
|
+
new HttpError("The existing private OpenCode server could not be verified; keep its session running", 501),
|
|
5433
|
+
{ code: "question_private_server_unavailable" }
|
|
5434
|
+
);
|
|
5435
|
+
}
|
|
5436
|
+
|
|
5268
5437
|
// src/opencode-v2-forms.js
|
|
5269
|
-
|
|
5270
|
-
|
|
5438
|
+
function unavailable(code, message, status = 501) {
|
|
5439
|
+
return Object.assign(new HttpError(message, status), { code });
|
|
5440
|
+
}
|
|
5441
|
+
async function getFormApi(ctx, sessionID, { makeClient = exports_client.make, ownsSession, resolvePrivate = privateEndpoint } = {}) {
|
|
5442
|
+
const endpoint = await resolvePrivate() ?? await Service.discover({ version: ctx.app.version }) ?? await Service.discover();
|
|
5271
5443
|
if (!endpoint) {
|
|
5272
|
-
throw
|
|
5444
|
+
throw unavailable("question_shared_service_unavailable", "Remote questions require a compatible running OpenCode V2 shared service");
|
|
5273
5445
|
}
|
|
5274
5446
|
const client = makeClient({ baseUrl: endpoint.url, headers: Service.headers(endpoint) });
|
|
5275
5447
|
let session;
|
|
5276
5448
|
try {
|
|
5277
5449
|
session = await client.session.get({ sessionID });
|
|
5278
5450
|
} catch {
|
|
5279
|
-
throw
|
|
5451
|
+
throw unavailable("question_session_not_in_shared_service", "The question session is not owned by the shared OpenCode service");
|
|
5280
5452
|
}
|
|
5281
|
-
if (session.
|
|
5282
|
-
throw
|
|
5453
|
+
if (session.id !== void 0 && session.id !== sessionID) {
|
|
5454
|
+
throw unavailable("question_session_not_in_shared_service", "The shared service returned another question session");
|
|
5455
|
+
}
|
|
5456
|
+
const owned = ownsSession ? await ownsSession(session) : session.location?.directory === ctx.location.directory;
|
|
5457
|
+
if (!owned) {
|
|
5458
|
+
throw unavailable("question_session_location_mismatch", "The question session belongs to another OpenCode location", 403);
|
|
5283
5459
|
}
|
|
5284
5460
|
return client.session.form;
|
|
5285
5461
|
}
|
|
5286
5462
|
|
|
5287
5463
|
// src/opencode-v2-client.js
|
|
5464
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
5288
5465
|
async function nativeReply(operation) {
|
|
5289
5466
|
try {
|
|
5290
5467
|
return await operation();
|
|
@@ -5310,6 +5487,12 @@ function questionProperties(form) {
|
|
|
5310
5487
|
return {
|
|
5311
5488
|
id: form.id,
|
|
5312
5489
|
sessionID: form.sessionID,
|
|
5490
|
+
nativeRevision: createHash2("sha256").update(JSON.stringify({
|
|
5491
|
+
id: form.id,
|
|
5492
|
+
sessionID: form.sessionID,
|
|
5493
|
+
title: form.title,
|
|
5494
|
+
fields: form.fields
|
|
5495
|
+
})).digest("hex"),
|
|
5313
5496
|
questions: form.fields.map((field) => ({
|
|
5314
5497
|
header: field.title ?? form.title,
|
|
5315
5498
|
question: field.description ?? field.title ?? form.title,
|
|
@@ -5359,9 +5542,11 @@ function normalizeV2Event(event) {
|
|
|
5359
5542
|
}
|
|
5360
5543
|
return [normalized];
|
|
5361
5544
|
}
|
|
5362
|
-
function createV2Client(ctx, resolveFormApi
|
|
5545
|
+
function createV2Client(ctx, resolveFormApi) {
|
|
5546
|
+
const liveBridge = createV2LiveClient(ctx);
|
|
5547
|
+
resolveFormApi ??= (sessionID) => getFormApi(ctx, sessionID, { ownsSession: liveBridge.ownsSession });
|
|
5363
5548
|
const client = {
|
|
5364
|
-
liveBridge
|
|
5549
|
+
liveBridge,
|
|
5365
5550
|
app: {
|
|
5366
5551
|
async log({ body }) {
|
|
5367
5552
|
console.error(JSON.stringify(body));
|
|
@@ -5381,10 +5566,13 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
|
|
|
5381
5566
|
decision: body.response
|
|
5382
5567
|
})),
|
|
5383
5568
|
v2: { session: { question: {
|
|
5384
|
-
async reply({ sessionID, requestID, questionV2Reply }) {
|
|
5569
|
+
async reply({ sessionID, requestID, questionV2Reply, expectedNativeRevision }) {
|
|
5385
5570
|
const api = await resolveFormApi(sessionID);
|
|
5386
5571
|
const form = await nativeReply(() => api.get({ sessionID, formID: requestID }));
|
|
5387
5572
|
if (!questionForm(form)) throw new HttpError("Unsupported OpenCode question form", 422);
|
|
5573
|
+
if (expectedNativeRevision && questionProperties(form).nativeRevision !== expectedNativeRevision) {
|
|
5574
|
+
throw Object.assign(new HttpError("The native question changed; read it again", 409), { code: "question_changed_read_again" });
|
|
5575
|
+
}
|
|
5388
5576
|
const answers = questionV2Reply.answers;
|
|
5389
5577
|
if (!Array.isArray(answers) || answers.length !== form.fields.length) {
|
|
5390
5578
|
throw new HttpError("Question answer count does not match the form", 422);
|
|
@@ -5412,13 +5600,13 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
|
|
|
5412
5600
|
}
|
|
5413
5601
|
} } },
|
|
5414
5602
|
async ownsEvent(event) {
|
|
5415
|
-
if (event.location?.directory
|
|
5603
|
+
if (event.location?.directory === ctx.location.directory) return true;
|
|
5416
5604
|
const sessionID = event.data?.sessionID ?? event.data?.form?.sessionID;
|
|
5417
5605
|
if (!sessionID) return event.type === "installation.update-available";
|
|
5418
5606
|
if (sessionID === "global") return false;
|
|
5419
5607
|
try {
|
|
5420
|
-
const session = await
|
|
5421
|
-
return session
|
|
5608
|
+
const session = await liveBridge.get(sessionID);
|
|
5609
|
+
return liveBridge.ownsSession(session);
|
|
5422
5610
|
} catch {
|
|
5423
5611
|
return false;
|
|
5424
5612
|
}
|
|
@@ -5433,18 +5621,19 @@ function createV2Client(ctx, resolveFormApi = (sessionID) => getFormApi(ctx, ses
|
|
|
5433
5621
|
client.liveBridge.answerQuestion = (pending, answers) => client.v2.session.question.reply({
|
|
5434
5622
|
sessionID: pending.sessionID,
|
|
5435
5623
|
requestID: pending.id,
|
|
5436
|
-
questionV2Reply: { answers }
|
|
5624
|
+
questionV2Reply: { answers },
|
|
5625
|
+
expectedNativeRevision: pending.nativeRevision
|
|
5437
5626
|
});
|
|
5438
5627
|
return client;
|
|
5439
5628
|
}
|
|
5440
5629
|
|
|
5441
5630
|
// src/opencode-v2.js
|
|
5442
5631
|
function waitForReconnect(signal2, delay2) {
|
|
5443
|
-
return new Promise((
|
|
5632
|
+
return new Promise((resolve3) => {
|
|
5444
5633
|
const finish = () => {
|
|
5445
5634
|
clearTimeout(timer);
|
|
5446
5635
|
signal2.removeEventListener("abort", finish);
|
|
5447
|
-
|
|
5636
|
+
resolve3();
|
|
5448
5637
|
};
|
|
5449
5638
|
const timer = setTimeout(finish, delay2);
|
|
5450
5639
|
signal2.addEventListener("abort", finish, { once: true });
|
package/docs/live-control.md
CHANGED
|
@@ -156,4 +156,34 @@ 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:
|
|
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.
|
|
164
|
+
|
|
165
|
+
## Optional native TUI owner
|
|
166
|
+
|
|
167
|
+
The package exports a native TUI companion as `./tui`. Setting
|
|
168
|
+
`liveBridge.owner: "tui"` explicitly moves the live listener to that companion;
|
|
169
|
+
the server entrypoint then leaves the port free while preserving its other
|
|
170
|
+
integration duties. The companion uses the actual host-injected
|
|
171
|
+
`OpenCodeClient.session.form.list/get/reply/cancel` and native data events. It
|
|
172
|
+
does not discover another service, inspect terminal contents, emulate question
|
|
173
|
+
HTTP endpoints, or start an OpenCode service. Confirm
|
|
174
|
+
`capabilities.questionTransport === "injected_native_client"` after activation.
|
|
175
|
+
This mode requires the directory/npm package with both entrypoints, rather than
|
|
176
|
+
the legacy server-only single-file side load. Keep the standard server owner
|
|
177
|
+
when its existing private native API can be verified; no owner migration is
|
|
178
|
+
required in that case. Plugin teardown closes its bridge only; verify the
|
|
179
|
+
running host's reload semantics before activation, since the external CLI may
|
|
180
|
+
target a different shared service and full location reload may affect active
|
|
181
|
+
work.
|
|
182
|
+
|
|
183
|
+
## Global bridge location pin (0.8.2)
|
|
184
|
+
|
|
185
|
+
When the bridge is configured globally, set `liveBridge.projectDirectory` to the intended project's absolute directory. Only that location starts the listener; loading the plugin in an npm cache or another project cannot claim its port. This restricts the host location and preserves the existing exact-session/worktree authorization checks. It does not change an existing chat binding, stored connection, token or port. Other independent servers hosting the same project still need distinct explicitly configured ports.
|
|
186
|
+
|
|
187
|
+
OpenCode may remove `OPENCODE_PASSWORD` from runtime `process.env`. A Linux private server can reuse only its own original password from `/proc/self/environ`; it is neither logged nor copied to configuration. Native API PID, exact-session and project checks still apply. No other PID or port range is inspected.
|
|
188
|
+
|
|
189
|
+
If both a private stdio server and a shared server load that same project, `liveBridge.owner: "standalone"` restricts the listener to the private server. It detects the current process's existing `--stdio` flag rather than a stored PID or session ID. The shared server's other Backeer duties remain active. The default `owner: "server"` and optional `owner: "tui"` behavior remain unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backeer-opencode",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
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,14 @@ 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
|
-
|
|
43
|
+
const owner = values.owner ?? "server"
|
|
44
|
+
if (!["server", "standalone", "tui"].includes(owner)) throw new Error("Live control owner must be server, standalone or tui")
|
|
45
|
+
const projectDirectory = values.projectDirectory
|
|
46
|
+
if (projectDirectory !== undefined && (typeof projectDirectory !== "string" || !isAbsolute(projectDirectory))) {
|
|
47
|
+
throw new Error("Live control projectDirectory must be an absolute path")
|
|
48
|
+
}
|
|
49
|
+
return { enabled: true, token, tokenFile, port, owner,
|
|
50
|
+
...(projectDirectory === undefined ? {} : { projectDirectory }) }
|
|
44
51
|
} catch (error) {
|
|
45
52
|
// A bad opt-in must not disable the existing mobile approval integration.
|
|
46
53
|
return { enabled: false, error: error.message }
|
package/src/live-bridge.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createServer } from "node:http"
|
|
2
2
|
import { randomUUID, timingSafeEqual } from "node:crypto"
|
|
3
|
+
import { resolve } from "node:path"
|
|
3
4
|
import { createV1LiveClient } from "./live-bridge-client.js"
|
|
4
5
|
import { createLiveConnectionNotifier, LIVE_HEARTBEAT_MS, LIVE_RECONNECT_MS } from "./live-connection.js"
|
|
5
6
|
import { createBridgeSanitizer, validBridgeID } from "./live-bridge-events.js"
|
|
6
7
|
import { questionSnapshot, resolveQuestionAnswers } from "./live-questions.js"
|
|
8
|
+
import { PLUGIN_VERSION } from "./updates.js"
|
|
7
9
|
|
|
8
10
|
const LIMITS = { events: 256, bytes: 2_000_000, clients: 8, commands: 1024, commandTTL: 86_400_000, body: 32_768, requests: 16 }
|
|
9
11
|
function failure(status, code) { return Object.assign(new Error(code), { status, code }) }
|
|
@@ -26,7 +28,10 @@ async function readBody(request) {
|
|
|
26
28
|
|
|
27
29
|
export async function createLiveBridge({ config, client, directory, reporter }) {
|
|
28
30
|
const options = config.liveBridge
|
|
29
|
-
if (!options?.enabled
|
|
31
|
+
if (!options?.enabled || (options.projectDirectory !== undefined &&
|
|
32
|
+
resolve(options.projectDirectory) !== resolve(directory))) {
|
|
33
|
+
return { handle() {}, async dispose() {}, address: undefined }
|
|
34
|
+
}
|
|
30
35
|
const token = options.token
|
|
31
36
|
if (typeof token !== "string" || token.length < 32 || token === config.token) {
|
|
32
37
|
throw new Error("Live bridge requires a separate credential of at least 32 characters")
|
|
@@ -73,11 +78,15 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
73
78
|
if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error
|
|
74
79
|
throw failure(404, "session_not_found")
|
|
75
80
|
}
|
|
76
|
-
if (info?.id !== id || typeof info.time?.archived === "number"
|
|
81
|
+
if (info?.id !== id || typeof info.time?.archived === "number") {
|
|
77
82
|
throw failure(404, "session_not_found")
|
|
78
83
|
}
|
|
84
|
+
const locationDirectory = info.location?.directory ?? info.directory
|
|
85
|
+
const owned = api.ownsSession ? await api.ownsSession(info) : locationDirectory === directory
|
|
86
|
+
if (!owned) throw failure(404, "session_not_found")
|
|
79
87
|
return {
|
|
80
88
|
id, directory, title: sanitizer.text(info.title, 240),
|
|
89
|
+
...(locationDirectory !== directory ? { locationDirectory: sanitizer.text(locationDirectory, 4096), scope: "verified_git_worktree" } : {}),
|
|
81
90
|
...(validBridgeID(info.parentID) ? { parentID: info.parentID } : {}),
|
|
82
91
|
status: states.get(id) ?? "unknown",
|
|
83
92
|
}
|
|
@@ -197,12 +206,21 @@ export async function createLiveBridge({ config, client, directory, reporter })
|
|
|
197
206
|
const result = await api.list()
|
|
198
207
|
const values = Array.isArray(result) ? result : result?.data
|
|
199
208
|
if (!Array.isArray(values)) throw failure(502, "invalid_session_list")
|
|
200
|
-
const own =
|
|
201
|
-
|
|
209
|
+
const own = []
|
|
210
|
+
for (const info of values.slice(0, 100)) {
|
|
211
|
+
if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue
|
|
212
|
+
if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info)
|
|
213
|
+
}
|
|
202
214
|
return json(response, 200, { sessions: own.map((info) => ({
|
|
203
215
|
id: info.id, title: sanitizer.text(info.title, 240), status: states.get(info.id) ?? "unknown",
|
|
216
|
+
...((info.location?.directory ?? info.directory) !== directory ? {
|
|
217
|
+
locationDirectory: sanitizer.text(info.location?.directory ?? info.directory, 4096), scope: "verified_git_worktree",
|
|
218
|
+
} : {}),
|
|
204
219
|
...(validBridgeID(info.parentID) ? { parentID: info.parentID } : {}),
|
|
205
|
-
})), directory, cursor: cursor()
|
|
220
|
+
})), directory, cursor: cursor(), protocolVersion: 1, bridgeVersion: PLUGIN_VERSION,
|
|
221
|
+
capabilities: { questions: typeof api.questions === "function", questionAnswers: typeof api.answerQuestion === "function",
|
|
222
|
+
verifiedWorktreeSessions: typeof api.ownsSession === "function",
|
|
223
|
+
questionTransport: api.questionTransport ?? "server" } })
|
|
206
224
|
}
|
|
207
225
|
if (request.method === "GET" && url.pathname === "/v1/events") {
|
|
208
226
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
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)
|
package/src/live-control-mcp.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/live-managed-mcp.js
CHANGED
|
@@ -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.
|
|
20
|
+
const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.2" }, {
|
|
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
|
-
:
|
|
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
|
})
|
package/src/live-questions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
142
|
-
return session
|
|
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,60 @@
|
|
|
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
|
+
platform = process.platform, initialEnvironment = () => readFile("/proc/self/environ", "utf8"),
|
|
32
|
+
urls = ownLoopbackUrls, request = fetch } = {}) {
|
|
33
|
+
if (!argv.includes("--stdio")) return undefined
|
|
34
|
+
const flag = argv.indexOf("--port")
|
|
35
|
+
const rawPort = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith("--port="))?.slice(7)
|
|
36
|
+
const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN
|
|
37
|
+
try {
|
|
38
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error()
|
|
39
|
+
// OpenCode removes this variable at runtime. Linux retains this process's
|
|
40
|
+
// initial environment; read only our own existing password, never another PID.
|
|
41
|
+
const password = env.OPENCODE_PASSWORD || (platform === "linux"
|
|
42
|
+
? (await initialEnvironment()).split("\0").find(value => value.startsWith("OPENCODE_PASSWORD="))?.slice(18)
|
|
43
|
+
: undefined)
|
|
44
|
+
if (!password) throw new Error()
|
|
45
|
+
const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`]
|
|
46
|
+
for (const url of candidates) {
|
|
47
|
+
const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
|
|
48
|
+
let response
|
|
49
|
+
try {
|
|
50
|
+
response = await request(`${url}/api/info`, {
|
|
51
|
+
headers: Service.headers(endpoint), signal: AbortSignal.timeout(2000), redirect: "error",
|
|
52
|
+
})
|
|
53
|
+
if (response.ok && (await response.json()).pid === pid) return endpoint
|
|
54
|
+
} catch { /* Another listener owned by this process is not its native API. */ }
|
|
55
|
+
finally { await response?.body?.cancel().catch(() => {}) }
|
|
56
|
+
}
|
|
57
|
+
} catch { /* Report only a safe diagnostic, never upstream credentials or output. */ }
|
|
58
|
+
throw Object.assign(new HttpError("The existing private OpenCode server could not be verified; keep its session running", 501),
|
|
59
|
+
{ code: "question_private_server_unavailable" })
|
|
60
|
+
}
|
package/src/opencode-v2-forms.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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,10 @@ 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
|
|
51
|
+
const bridgeConfig = config.liveBridge.owner === "tui" ||
|
|
52
|
+
(config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")))
|
|
53
|
+
? { ...config, liveBridge: { enabled: false } } : config
|
|
54
|
+
const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
|
|
52
55
|
reporter.error("Live control unavailable", error)
|
|
53
56
|
return { async handle() {}, async dispose() {} }
|
|
54
57
|
})
|
package/src/updates.js
CHANGED