backeer-opencode 0.8.1 → 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 +6 -0
- package/dist/backeer-permission.js +38 -21
- package/docs/live-control.md +26 -0
- package/package.json +1 -1
- package/src/live-bridge-config.js +7 -2
- package/src/live-bridge.js +5 -1
- package/src/live-managed-mcp.js +1 -1
- package/src/opencode-v2-endpoint.js +8 -2
- package/src/plugin.js +2 -1
- package/src/updates.js +1 -1
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.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
|
+
|
|
5
11
|
## 0.8.1 - 2026-11-03
|
|
6
12
|
|
|
7
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.
|
|
@@ -1967,8 +1967,19 @@ function loadLiveBridgeConfig(option, { configDirectory, ingestToken } = {}) {
|
|
|
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", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui");
|
|
1971
|
-
|
|
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
|
+
};
|
|
1972
1983
|
} catch (error) {
|
|
1973
1984
|
return { enabled: false, error: error.message };
|
|
1974
1985
|
}
|
|
@@ -2143,16 +2154,16 @@ function isAbortError(error) {
|
|
|
2143
2154
|
return error instanceof Error && error.name === "AbortError";
|
|
2144
2155
|
}
|
|
2145
2156
|
function sleep(milliseconds, signal2) {
|
|
2146
|
-
return new Promise((
|
|
2157
|
+
return new Promise((resolve3) => {
|
|
2147
2158
|
if (signal2?.aborted) {
|
|
2148
|
-
|
|
2159
|
+
resolve3();
|
|
2149
2160
|
return;
|
|
2150
2161
|
}
|
|
2151
2162
|
const timeout = setTimeout(done, milliseconds);
|
|
2152
2163
|
function done() {
|
|
2153
2164
|
clearTimeout(timeout);
|
|
2154
2165
|
signal2?.removeEventListener("abort", done);
|
|
2155
|
-
|
|
2166
|
+
resolve3();
|
|
2156
2167
|
}
|
|
2157
2168
|
signal2?.addEventListener("abort", done, { once: true });
|
|
2158
2169
|
});
|
|
@@ -2397,7 +2408,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2397
2408
|
import { statSync } from "node:fs";
|
|
2398
2409
|
|
|
2399
2410
|
// src/updates.js
|
|
2400
|
-
var PLUGIN_VERSION = "0.8.
|
|
2411
|
+
var PLUGIN_VERSION = "0.8.2";
|
|
2401
2412
|
var PACKAGE_NAME = "backeer-opencode";
|
|
2402
2413
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
2403
2414
|
var CHECK_TIMEOUT_MS = 3e3;
|
|
@@ -3418,6 +3429,7 @@ function createLifecycleController({
|
|
|
3418
3429
|
// src/live-bridge.js
|
|
3419
3430
|
import { createServer } from "node:http";
|
|
3420
3431
|
import { randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
|
|
3432
|
+
import { resolve as resolve2 } from "node:path";
|
|
3421
3433
|
|
|
3422
3434
|
// src/permission-command.js
|
|
3423
3435
|
var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
|
|
@@ -4316,9 +4328,11 @@ async function readBody(request) {
|
|
|
4316
4328
|
}
|
|
4317
4329
|
async function createLiveBridge({ config, client, directory, reporter }) {
|
|
4318
4330
|
const options = config.liveBridge;
|
|
4319
|
-
if (!options?.enabled
|
|
4320
|
-
|
|
4321
|
-
|
|
4331
|
+
if (!options?.enabled || options.projectDirectory !== void 0 && resolve2(options.projectDirectory) !== resolve2(directory)) {
|
|
4332
|
+
return { handle() {
|
|
4333
|
+
}, async dispose() {
|
|
4334
|
+
}, address: void 0 };
|
|
4335
|
+
}
|
|
4322
4336
|
const token = options.token;
|
|
4323
4337
|
if (typeof token !== "string" || token.length < 32 || token === config.token) {
|
|
4324
4338
|
throw new Error("Live bridge requires a separate credential of at least 32 characters");
|
|
@@ -4336,7 +4350,7 @@ async function createLiveBridge({ config, client, directory, reporter }) {
|
|
|
4336
4350
|
if (disposed) return Promise.reject(failure(503, "bridge_closed"));
|
|
4337
4351
|
if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"));
|
|
4338
4352
|
lookups++;
|
|
4339
|
-
return new Promise((
|
|
4353
|
+
return new Promise((resolve3, reject) => {
|
|
4340
4354
|
let finished = false;
|
|
4341
4355
|
const finish = (complete, value) => {
|
|
4342
4356
|
if (finished) return;
|
|
@@ -4351,7 +4365,7 @@ async function createLiveBridge({ config, client, directory, reporter }) {
|
|
|
4351
4365
|
Promise.resolve().then(() => api.get(id)).then(
|
|
4352
4366
|
(value) => {
|
|
4353
4367
|
lookups--;
|
|
4354
|
-
finish(
|
|
4368
|
+
finish(resolve3, value);
|
|
4355
4369
|
},
|
|
4356
4370
|
(error) => {
|
|
4357
4371
|
lookups--;
|
|
@@ -4657,11 +4671,11 @@ retry: ${LIVE_RECONNECT_MS}
|
|
|
4657
4671
|
server.requestTimeout = 15e3;
|
|
4658
4672
|
server.headersTimeout = 1e4;
|
|
4659
4673
|
server.keepAliveTimeout = 5e3;
|
|
4660
|
-
await new Promise((
|
|
4674
|
+
await new Promise((resolve3, reject) => {
|
|
4661
4675
|
server.once("error", reject);
|
|
4662
4676
|
server.listen(options.port ?? 43871, "127.0.0.1", () => {
|
|
4663
4677
|
server.off("error", reject);
|
|
4664
|
-
|
|
4678
|
+
resolve3();
|
|
4665
4679
|
});
|
|
4666
4680
|
});
|
|
4667
4681
|
server.on("error", () => {
|
|
@@ -4670,7 +4684,7 @@ retry: ${LIVE_RECONNECT_MS}
|
|
|
4670
4684
|
handle,
|
|
4671
4685
|
address: server.address(),
|
|
4672
4686
|
dispose() {
|
|
4673
|
-
disposeTask ??= new Promise((
|
|
4687
|
+
disposeTask ??= new Promise((resolve3) => {
|
|
4674
4688
|
disposed = true;
|
|
4675
4689
|
for (const cancel of lookupWaiters) cancel();
|
|
4676
4690
|
for (const stream of streams) {
|
|
@@ -4678,7 +4692,7 @@ retry: ${LIVE_RECONNECT_MS}
|
|
|
4678
4692
|
stream.response.destroy();
|
|
4679
4693
|
}
|
|
4680
4694
|
server.close(() => {
|
|
4681
|
-
void connectionNotifications.dispose().then(
|
|
4695
|
+
void connectionNotifications.dispose().then(resolve3);
|
|
4682
4696
|
});
|
|
4683
4697
|
server.closeAllConnections?.();
|
|
4684
4698
|
events.length = 0;
|
|
@@ -4708,7 +4722,7 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
|
|
|
4708
4722
|
const reporter = createOpenCodeReporter(client, config.logLevel);
|
|
4709
4723
|
const conversation = feedback ?? createConversationFeedback({ client, reporter });
|
|
4710
4724
|
if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error);
|
|
4711
|
-
const bridgeConfig = config.liveBridge.owner === "tui" ? { ...config, liveBridge: { enabled: false } } : config;
|
|
4725
|
+
const bridgeConfig = config.liveBridge.owner === "tui" || config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")) ? { ...config, liveBridge: { enabled: false } } : config;
|
|
4712
4726
|
const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
|
|
4713
4727
|
reporter.error("Live control unavailable", error);
|
|
4714
4728
|
return { async handle() {
|
|
@@ -5344,7 +5358,7 @@ async function terminate(info, options, timing) {
|
|
|
5344
5358
|
await rm2(options.file ?? fallback2(), { force: true });
|
|
5345
5359
|
}
|
|
5346
5360
|
function delay(milliseconds) {
|
|
5347
|
-
return new Promise((
|
|
5361
|
+
return new Promise((resolve3) => setTimeout(resolve3, milliseconds));
|
|
5348
5362
|
}
|
|
5349
5363
|
var Service = { discover, ensure, stop, headers: headers2 };
|
|
5350
5364
|
|
|
@@ -5382,6 +5396,8 @@ async function privateEndpoint({
|
|
|
5382
5396
|
argv = process.argv,
|
|
5383
5397
|
env = process.env,
|
|
5384
5398
|
pid = process.pid,
|
|
5399
|
+
platform = process.platform,
|
|
5400
|
+
initialEnvironment = () => readFile3("/proc/self/environ", "utf8"),
|
|
5385
5401
|
urls = ownLoopbackUrls,
|
|
5386
5402
|
request = fetch
|
|
5387
5403
|
} = {}) {
|
|
@@ -5389,9 +5405,10 @@ async function privateEndpoint({
|
|
|
5389
5405
|
const flag = argv.indexOf("--port");
|
|
5390
5406
|
const rawPort = flag >= 0 ? argv[flag + 1] : argv.find((arg) => arg.startsWith("--port="))?.slice(7);
|
|
5391
5407
|
const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN;
|
|
5392
|
-
const password = env.OPENCODE_PASSWORD;
|
|
5393
5408
|
try {
|
|
5394
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535
|
|
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();
|
|
5395
5412
|
const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`];
|
|
5396
5413
|
for (const url of candidates) {
|
|
5397
5414
|
const endpoint = { url, auth: { type: "basic", username: "opencode", password } };
|
|
@@ -5612,11 +5629,11 @@ function createV2Client(ctx, resolveFormApi) {
|
|
|
5612
5629
|
|
|
5613
5630
|
// src/opencode-v2.js
|
|
5614
5631
|
function waitForReconnect(signal2, delay2) {
|
|
5615
|
-
return new Promise((
|
|
5632
|
+
return new Promise((resolve3) => {
|
|
5616
5633
|
const finish = () => {
|
|
5617
5634
|
clearTimeout(timer);
|
|
5618
5635
|
signal2.removeEventListener("abort", finish);
|
|
5619
|
-
|
|
5636
|
+
resolve3();
|
|
5620
5637
|
};
|
|
5621
5638
|
const timer = setTimeout(finish, delay2);
|
|
5622
5639
|
signal2.addEventListener("abort", finish, { once: true });
|
package/docs/live-control.md
CHANGED
|
@@ -161,3 +161,29 @@ A local-only update is insufficient: load the updated remote bridge in the inten
|
|
|
161
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
162
|
|
|
163
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
|
@@ -41,8 +41,13 @@ export function loadLiveBridgeConfig(option, { configDirectory, ingestToken } =
|
|
|
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", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui")
|
|
45
|
-
|
|
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 }) }
|
|
46
51
|
} catch (error) {
|
|
47
52
|
// A bad opt-in must not disable the existing mobile approval integration.
|
|
48
53
|
return { enabled: false, error: error.message }
|
package/src/live-bridge.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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"
|
|
@@ -27,7 +28,10 @@ async function readBody(request) {
|
|
|
27
28
|
|
|
28
29
|
export async function createLiveBridge({ config, client, directory, reporter }) {
|
|
29
30
|
const options = config.liveBridge
|
|
30
|
-
if (!options?.enabled
|
|
31
|
+
if (!options?.enabled || (options.projectDirectory !== undefined &&
|
|
32
|
+
resolve(options.projectDirectory) !== resolve(directory))) {
|
|
33
|
+
return { handle() {}, async dispose() {}, address: undefined }
|
|
34
|
+
}
|
|
31
35
|
const token = options.token
|
|
32
36
|
if (typeof token !== "string" || token.length < 32 || token === config.token) {
|
|
33
37
|
throw new Error("Live bridge requires a separate credential of at least 32 characters")
|
package/src/live-managed-mcp.js
CHANGED
|
@@ -17,7 +17,7 @@ const read = { ...binding,
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function createManagedLiveMcp(manager) {
|
|
20
|
-
const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.
|
|
20
|
+
const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.2" }, {
|
|
21
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.",
|
|
22
22
|
})
|
|
23
23
|
const tool = (name, description, inputSchema, annotations, run) => server.registerTool(name, {
|
|
@@ -28,14 +28,20 @@ export async function ownLoopbackUrls({ platform = process.platform, fs = { read
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function privateEndpoint({ argv = process.argv, env = process.env, pid = process.pid,
|
|
31
|
+
platform = process.platform, initialEnvironment = () => readFile("/proc/self/environ", "utf8"),
|
|
31
32
|
urls = ownLoopbackUrls, request = fetch } = {}) {
|
|
32
33
|
if (!argv.includes("--stdio")) return undefined
|
|
33
34
|
const flag = argv.indexOf("--port")
|
|
34
35
|
const rawPort = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith("--port="))?.slice(7)
|
|
35
36
|
const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN
|
|
36
|
-
const password = env.OPENCODE_PASSWORD
|
|
37
37
|
try {
|
|
38
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535
|
|
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()
|
|
39
45
|
const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`]
|
|
40
46
|
for (const url of candidates) {
|
|
41
47
|
const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
|
package/src/plugin.js
CHANGED
|
@@ -48,7 +48,8 @@ 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 bridgeConfig = config.liveBridge.owner === "tui"
|
|
51
|
+
const bridgeConfig = config.liveBridge.owner === "tui" ||
|
|
52
|
+
(config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")))
|
|
52
53
|
? { ...config, liveBridge: { enabled: false } } : config
|
|
53
54
|
const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
|
|
54
55
|
reporter.error("Live control unavailable", error)
|
package/src/updates.js
CHANGED