opencode-collaboration 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +194 -0
- package/README.md +224 -0
- package/README.zh-CN.md +224 -0
- package/commands/list-agents.md +8 -0
- package/commands/peers-inbox.md +8 -0
- package/commands/peers-name.md +8 -0
- package/commands/peers-outbox.md +8 -0
- package/commands/peers.md +8 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.js +95 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +50 -0
- package/dist/delivery.d.ts +42 -0
- package/dist/delivery.js +177 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +40 -0
- package/dist/format.d.ts +32 -0
- package/dist/format.js +107 -0
- package/dist/gating.d.ts +4 -0
- package/dist/gating.js +16 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +410 -0
- package/dist/listener.d.ts +37 -0
- package/dist/listener.js +335 -0
- package/dist/outbox.d.ts +12 -0
- package/dist/outbox.js +110 -0
- package/dist/permissions.d.ts +47 -0
- package/dist/permissions.js +194 -0
- package/dist/queue.d.ts +89 -0
- package/dist/queue.js +824 -0
- package/dist/registry.d.ts +70 -0
- package/dist/registry.js +308 -0
- package/dist/sender.d.ts +27 -0
- package/dist/sender.js +139 -0
- package/dist/session-runtime.d.ts +40 -0
- package/dist/session-runtime.js +355 -0
- package/dist/session-tracker.d.ts +16 -0
- package/dist/session-tracker.js +39 -0
- package/dist/tools/peers-tools.d.ts +26 -0
- package/dist/tools/peers-tools.js +173 -0
- package/dist/transport.d.ts +20 -0
- package/dist/transport.js +46 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +228 -0
- package/dist/types.d.ts +162 -0
- package/dist/types.js +1 -0
- package/package.json +93 -0
package/dist/listener.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance inbox HTTP listener (127.0.0.1 only, bearer-token auth).
|
|
3
|
+
* Peers POST messages here instead of touching the opencode server directly,
|
|
4
|
+
* so inbound gating (accept/hold/refuse) and queueing stay under our control.
|
|
5
|
+
*/
|
|
6
|
+
import { chmod, lstat, mkdir, rm } from "node:fs/promises";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { connect } from "node:net";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
const MAX_HOPS = 4;
|
|
13
|
+
const peerFromSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
instanceId: z.string().min(1),
|
|
16
|
+
name: z.string().min(1),
|
|
17
|
+
directory: z.string().min(1),
|
|
18
|
+
})
|
|
19
|
+
.passthrough();
|
|
20
|
+
const inboundMessageV1Schema = z
|
|
21
|
+
.object({
|
|
22
|
+
id: z.string().min(1),
|
|
23
|
+
from: peerFromSchema,
|
|
24
|
+
text: z.string(),
|
|
25
|
+
via: z.array(z.string()).max(MAX_HOPS),
|
|
26
|
+
sentAt: z.number().finite(),
|
|
27
|
+
})
|
|
28
|
+
.passthrough();
|
|
29
|
+
const inboundMessageV2Schema = z
|
|
30
|
+
.object({
|
|
31
|
+
version: z.literal(2),
|
|
32
|
+
messageId: z.string().min(1),
|
|
33
|
+
fromEndpointId: z.string().min(1),
|
|
34
|
+
toEndpointId: z.string().min(1),
|
|
35
|
+
from: peerFromSchema,
|
|
36
|
+
text: z.string(),
|
|
37
|
+
via: z.array(z.string()).max(MAX_HOPS),
|
|
38
|
+
sentAt: z.number().finite(),
|
|
39
|
+
})
|
|
40
|
+
.passthrough();
|
|
41
|
+
const acknowledgementV2Schema = z.object({
|
|
42
|
+
version: z.literal(2),
|
|
43
|
+
messageId: z.string().min(1),
|
|
44
|
+
fromEndpointId: z.string().min(1),
|
|
45
|
+
toEndpointId: z.string().min(1),
|
|
46
|
+
status: z.enum(["delivered", "refused", "expired", "dropped", "duplicate"]),
|
|
47
|
+
acknowledgedAt: z.number().finite(),
|
|
48
|
+
}).passthrough();
|
|
49
|
+
function statusToHttp(status) {
|
|
50
|
+
switch (status) {
|
|
51
|
+
case "refused":
|
|
52
|
+
return 403;
|
|
53
|
+
case "full":
|
|
54
|
+
return 429;
|
|
55
|
+
default:
|
|
56
|
+
return 202;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function parseMessage(body) {
|
|
60
|
+
if (typeof body === "object" && body !== null && "version" in body && body.version === 2) {
|
|
61
|
+
const parsed = inboundMessageV2Schema.safeParse(body);
|
|
62
|
+
if (!parsed.success)
|
|
63
|
+
return null;
|
|
64
|
+
const message = parsed.data;
|
|
65
|
+
return {
|
|
66
|
+
message: {
|
|
67
|
+
id: message.messageId,
|
|
68
|
+
from: {
|
|
69
|
+
instanceId: message.fromEndpointId,
|
|
70
|
+
name: message.from.name,
|
|
71
|
+
directory: message.from.directory,
|
|
72
|
+
},
|
|
73
|
+
text: message.text,
|
|
74
|
+
via: message.via,
|
|
75
|
+
sentAt: message.sentAt,
|
|
76
|
+
},
|
|
77
|
+
route: { version: 2, toEndpointId: message.toEndpointId },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const parsed = inboundMessageV1Schema.safeParse(body);
|
|
81
|
+
return parsed.success ? { message: parsed.data, route: { version: 1 } } : null;
|
|
82
|
+
}
|
|
83
|
+
export function defaultRuntimeDirectory(env = process.env, uid = typeof process.getuid === "function" ? process.getuid() : 0) {
|
|
84
|
+
return env.XDG_RUNTIME_DIR
|
|
85
|
+
? join(env.XDG_RUNTIME_DIR, "opencode-collaboration")
|
|
86
|
+
: join(tmpdir(), `ocp-${uid}`);
|
|
87
|
+
}
|
|
88
|
+
export function InboxListener(opts) {
|
|
89
|
+
let primaryServer = null;
|
|
90
|
+
let compatibilityServer = null;
|
|
91
|
+
let ownedSocketPath = null;
|
|
92
|
+
let lifecycle = "new";
|
|
93
|
+
function authorized(authHeader) {
|
|
94
|
+
return authHeader === `Bearer ${opts.token}`;
|
|
95
|
+
}
|
|
96
|
+
async function handle(req, res) {
|
|
97
|
+
const send = (code, body) => {
|
|
98
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
99
|
+
res.end(JSON.stringify(body));
|
|
100
|
+
};
|
|
101
|
+
if (!authorized(req.headers.authorization)) {
|
|
102
|
+
send(401, { error: "unauthorized" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
106
|
+
send(200, { ok: true });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (req.method !== "POST" || (req.url !== "/message" && req.url !== "/ack")) {
|
|
110
|
+
send(404, { error: "not found" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let size = 0;
|
|
115
|
+
for await (const chunk of req) {
|
|
116
|
+
size += chunk.length;
|
|
117
|
+
if (size > opts.maxBodyBytes) {
|
|
118
|
+
send(413, { error: "message too large" });
|
|
119
|
+
req.destroy();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
chunks.push(chunk);
|
|
123
|
+
}
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
send(400, { error: "invalid json" });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (req.url === "/ack") {
|
|
133
|
+
const ack = acknowledgementV2Schema.safeParse(parsed);
|
|
134
|
+
if (!ack.success) {
|
|
135
|
+
send(400, { error: "invalid acknowledgement shape" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await opts.onAcknowledgement?.(ack.data);
|
|
139
|
+
send(202, { status: "accepted" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const envelope = parseMessage(parsed);
|
|
143
|
+
if (!envelope) {
|
|
144
|
+
send(400, { error: "invalid message shape" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const msg = envelope.message;
|
|
148
|
+
if (Buffer.byteLength(msg.text, "utf8") > (opts.maxMessageBytes ?? 8192)) {
|
|
149
|
+
send(413, { error: "message too large" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (Math.abs(Date.now() - msg.sentAt) > (opts.maxMessageAgeMs ?? 300_000)) {
|
|
153
|
+
send(400, { error: "stale message" });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const endpointId = await opts.resolveEndpoint?.(envelope.route);
|
|
158
|
+
if (opts.resolveEndpoint && !endpointId) {
|
|
159
|
+
send(404, { error: "endpoint not found" });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const status = await opts.onMessage(msg, endpointId ?? envelope.route.toEndpointId);
|
|
163
|
+
send(statusToHttp(status), { status });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
await opts.logger("error", "onMessage handler failed", { error: String(err) });
|
|
167
|
+
send(500, { error: "internal error" });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function listenerServer() {
|
|
171
|
+
return createServer((req, res) => {
|
|
172
|
+
handle(req, res).catch((err) => {
|
|
173
|
+
void opts.logger("error", "listener error", { error: String(err) });
|
|
174
|
+
if (!res.headersSent)
|
|
175
|
+
res.writeHead(500).end();
|
|
176
|
+
else
|
|
177
|
+
res.end();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function listenUnix(server, socketPath) {
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const onError = (err) => {
|
|
184
|
+
server.off("listening", onListening);
|
|
185
|
+
reject(err);
|
|
186
|
+
};
|
|
187
|
+
const onListening = () => {
|
|
188
|
+
server.off("error", onError);
|
|
189
|
+
resolve();
|
|
190
|
+
};
|
|
191
|
+
server.once("error", onError);
|
|
192
|
+
server.once("listening", onListening);
|
|
193
|
+
server.listen(socketPath);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function listenTcp(server) {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const onError = (err) => {
|
|
199
|
+
server.off("listening", onListening);
|
|
200
|
+
reject(err);
|
|
201
|
+
};
|
|
202
|
+
const onListening = () => {
|
|
203
|
+
server.off("error", onError);
|
|
204
|
+
resolve(server.address().port);
|
|
205
|
+
};
|
|
206
|
+
server.once("error", onError);
|
|
207
|
+
server.once("listening", onListening);
|
|
208
|
+
server.listen(0, "127.0.0.1");
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function closeServer(server) {
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
if (!server?.listening) {
|
|
214
|
+
resolve();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
server.close((err) => err ? reject(err) : resolve());
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function liveUnixSocket(socketPath) {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const socket = connect({ path: socketPath });
|
|
223
|
+
let settled = false;
|
|
224
|
+
const finish = (result, err) => {
|
|
225
|
+
if (settled)
|
|
226
|
+
return;
|
|
227
|
+
settled = true;
|
|
228
|
+
socket.destroy();
|
|
229
|
+
if (err)
|
|
230
|
+
reject(err);
|
|
231
|
+
else
|
|
232
|
+
resolve(result);
|
|
233
|
+
};
|
|
234
|
+
socket.once("connect", () => finish(true));
|
|
235
|
+
socket.once("error", (err) => {
|
|
236
|
+
if (err.code === "ECONNREFUSED" || err.code === "ENOENT")
|
|
237
|
+
finish(false);
|
|
238
|
+
else
|
|
239
|
+
finish(false, err);
|
|
240
|
+
});
|
|
241
|
+
socket.setTimeout(500, () => finish(true));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async function prepareUnixSocket(socketPath) {
|
|
245
|
+
try {
|
|
246
|
+
const info = await lstat(socketPath);
|
|
247
|
+
if (!info.isSocket())
|
|
248
|
+
throw new Error(`runtime socket path collision: ${socketPath}`);
|
|
249
|
+
if (await liveUnixSocket(socketPath))
|
|
250
|
+
throw new Error(`runtime socket already in use: ${socketPath}`);
|
|
251
|
+
await rm(socketPath);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (err.code !== "ENOENT")
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
async start() {
|
|
260
|
+
if (lifecycle !== "new")
|
|
261
|
+
throw new Error(`listener cannot start while ${lifecycle}`);
|
|
262
|
+
lifecycle = "starting";
|
|
263
|
+
const platform = opts.platform ?? (opts.processId ? process.platform : "win32");
|
|
264
|
+
if (platform === "win32") {
|
|
265
|
+
primaryServer = listenerServer();
|
|
266
|
+
try {
|
|
267
|
+
const port = await listenTcp(primaryServer);
|
|
268
|
+
lifecycle = "running";
|
|
269
|
+
const url = `http://127.0.0.1:${port}`;
|
|
270
|
+
return {
|
|
271
|
+
port,
|
|
272
|
+
url,
|
|
273
|
+
compatibilityUrl: url,
|
|
274
|
+
address: { type: "tcp", host: "127.0.0.1", port },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
await closeServer(primaryServer).catch(() => { });
|
|
279
|
+
primaryServer = null;
|
|
280
|
+
lifecycle = "stopped";
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const directory = opts.runtimeDir ?? defaultRuntimeDirectory();
|
|
285
|
+
const socketPath = join(directory, `${opts.processId ?? process.pid}.sock`);
|
|
286
|
+
let boundUnixSocket = false;
|
|
287
|
+
try {
|
|
288
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
289
|
+
await chmod(directory, 0o700);
|
|
290
|
+
await prepareUnixSocket(socketPath);
|
|
291
|
+
primaryServer = listenerServer();
|
|
292
|
+
await listenUnix(primaryServer, socketPath);
|
|
293
|
+
boundUnixSocket = true;
|
|
294
|
+
ownedSocketPath = socketPath;
|
|
295
|
+
await chmod(socketPath, 0o600);
|
|
296
|
+
compatibilityServer = listenerServer();
|
|
297
|
+
const compatibilityPort = await listenTcp(compatibilityServer);
|
|
298
|
+
lifecycle = "running";
|
|
299
|
+
return {
|
|
300
|
+
port: compatibilityPort,
|
|
301
|
+
url: `http+unix://${encodeURIComponent(socketPath)}`,
|
|
302
|
+
compatibilityUrl: `http://127.0.0.1:${compatibilityPort}`,
|
|
303
|
+
address: { type: "unix", path: socketPath },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
await Promise.all([
|
|
308
|
+
closeServer(compatibilityServer).catch(() => { }),
|
|
309
|
+
closeServer(primaryServer).catch(() => { }),
|
|
310
|
+
]);
|
|
311
|
+
compatibilityServer = null;
|
|
312
|
+
primaryServer = null;
|
|
313
|
+
if (boundUnixSocket)
|
|
314
|
+
await rm(socketPath, { force: true }).catch(() => { });
|
|
315
|
+
ownedSocketPath = null;
|
|
316
|
+
lifecycle = "stopped";
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
async stop() {
|
|
321
|
+
if (lifecycle === "stopped")
|
|
322
|
+
return;
|
|
323
|
+
lifecycle = "stopping";
|
|
324
|
+
const unixPath = ownedSocketPath;
|
|
325
|
+
const servers = [compatibilityServer, primaryServer];
|
|
326
|
+
compatibilityServer = null;
|
|
327
|
+
primaryServer = null;
|
|
328
|
+
ownedSocketPath = null;
|
|
329
|
+
await Promise.all(servers.map((server) => closeServer(server)));
|
|
330
|
+
if (unixPath)
|
|
331
|
+
await rm(unixPath, { force: true });
|
|
332
|
+
lifecycle = "stopped";
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
package/dist/outbox.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OutboxRecord, PeerAcknowledgementV2, PeerMessageV2, ReceiveStatus } from "./types.js";
|
|
2
|
+
export interface OutboxInstance {
|
|
3
|
+
recordPending: (message: PeerMessageV2, toName: string) => Promise<OutboxRecord>;
|
|
4
|
+
recordReceipt: (messageId: string, fromEndpointId: string, status: ReceiveStatus) => Promise<OutboxRecord | null>;
|
|
5
|
+
recordFailure: (messageId: string, fromEndpointId: string, error: string) => Promise<OutboxRecord | null>;
|
|
6
|
+
applyAcknowledgement: (ack: PeerAcknowledgementV2) => Promise<boolean>;
|
|
7
|
+
get: (fromEndpointId: string, messageId: string) => OutboxRecord | null;
|
|
8
|
+
list: (fromEndpointId: string) => OutboxRecord[];
|
|
9
|
+
}
|
|
10
|
+
export declare function Outbox(opts: {
|
|
11
|
+
storageDir: string;
|
|
12
|
+
}): OutboxInstance;
|
package/dist/outbox.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
function safe(value) {
|
|
5
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
6
|
+
}
|
|
7
|
+
export function Outbox(opts) {
|
|
8
|
+
const root = join(opts.storageDir, "outbox");
|
|
9
|
+
function ensure(directory) {
|
|
10
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
11
|
+
chmodSync(directory, 0o700);
|
|
12
|
+
}
|
|
13
|
+
function pathFor(endpointId, messageId) {
|
|
14
|
+
return join(root, safe(endpointId), `${safe(messageId)}.json`);
|
|
15
|
+
}
|
|
16
|
+
function read(endpointId, messageId) {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(readFileSync(pathFor(endpointId, messageId), "utf8"));
|
|
19
|
+
return value.version === 1 && value.messageId === messageId && value.fromEndpointId === endpointId ? value : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function persist(record) {
|
|
26
|
+
const target = pathFor(record.fromEndpointId, record.messageId);
|
|
27
|
+
const directory = dirname(target);
|
|
28
|
+
ensure(root);
|
|
29
|
+
ensure(directory);
|
|
30
|
+
const temp = join(directory, `.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
31
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
32
|
+
try {
|
|
33
|
+
writeSync(fd, JSON.stringify(record));
|
|
34
|
+
fsyncSync(fd);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
chmodSync(temp, 0o600);
|
|
40
|
+
renameSync(temp, target);
|
|
41
|
+
if (process.platform !== "win32") {
|
|
42
|
+
const dirFd = openSync(directory, "r");
|
|
43
|
+
try {
|
|
44
|
+
fsyncSync(dirFd);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
closeSync(dirFd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function update(endpointId, messageId, values) {
|
|
52
|
+
const current = read(endpointId, messageId);
|
|
53
|
+
if (!current)
|
|
54
|
+
return null;
|
|
55
|
+
const next = { ...current, ...values, updatedAt: Date.now() };
|
|
56
|
+
persist(next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
async recordPending(message, toName) {
|
|
61
|
+
const existing = read(message.fromEndpointId, message.messageId);
|
|
62
|
+
if (existing)
|
|
63
|
+
return existing;
|
|
64
|
+
const record = {
|
|
65
|
+
version: 1,
|
|
66
|
+
messageId: message.messageId,
|
|
67
|
+
fromEndpointId: message.fromEndpointId,
|
|
68
|
+
toEndpointId: message.toEndpointId,
|
|
69
|
+
toName,
|
|
70
|
+
text: message.text,
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
updatedAt: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
persist(record);
|
|
75
|
+
return record;
|
|
76
|
+
},
|
|
77
|
+
async recordReceipt(messageId, fromEndpointId, status) {
|
|
78
|
+
return update(fromEndpointId, messageId, { receiptStatus: status, error: undefined });
|
|
79
|
+
},
|
|
80
|
+
async recordFailure(messageId, fromEndpointId, error) {
|
|
81
|
+
return update(fromEndpointId, messageId, { error });
|
|
82
|
+
},
|
|
83
|
+
async applyAcknowledgement(ack) {
|
|
84
|
+
const record = read(ack.fromEndpointId, ack.messageId);
|
|
85
|
+
if (!record || record.toEndpointId !== ack.toEndpointId)
|
|
86
|
+
return false;
|
|
87
|
+
update(ack.fromEndpointId, ack.messageId, {
|
|
88
|
+
finalStatus: ack.status,
|
|
89
|
+
acknowledgedAt: ack.acknowledgedAt,
|
|
90
|
+
error: undefined,
|
|
91
|
+
});
|
|
92
|
+
return true;
|
|
93
|
+
},
|
|
94
|
+
get: read,
|
|
95
|
+
list(fromEndpointId) {
|
|
96
|
+
const directory = join(root, safe(fromEndpointId));
|
|
97
|
+
if (!existsSync(directory))
|
|
98
|
+
return [];
|
|
99
|
+
return readdirSync(directory).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
100
|
+
try {
|
|
101
|
+
const record = JSON.parse(readFileSync(join(directory, file), "utf8"));
|
|
102
|
+
return record.version === 1 && record.fromEndpointId === fromEndpointId ? [record] : [];
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}).sort((a, b) => b.createdAt - a.createdAt || b.messageId.localeCompare(a.messageId));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-resolve permission requests that originate from a peer-triggered
|
|
3
|
+
* turn, modeled after Claude Code's per-source permission modes.
|
|
4
|
+
*
|
|
5
|
+
* A turn counts as peer-triggered when the user message that started it was
|
|
6
|
+
* injected by this plugin — detectable via the `peerMessage: true` metadata
|
|
7
|
+
* stamped on the injected part (see delivery.ts). Permission requests point
|
|
8
|
+
* at the *assistant* message holding the tool call, so the lookup walks up
|
|
9
|
+
* via parentID to the originating user message before checking its parts.
|
|
10
|
+
*
|
|
11
|
+
* Mechanism: opencode 1.18 does not invoke the plugin SDK's `permission.ask`
|
|
12
|
+
* hook, but it publishes permission request events on the bus and exposes a
|
|
13
|
+
* reply endpoint — the same pair the TUI uses. The plugin listens for
|
|
14
|
+
* `permission.v2.asked` (and the legacy `permission.asked`) and replies
|
|
15
|
+
* "once" (allow) or "reject" (deny) when the requesting turn is
|
|
16
|
+
* peer-triggered. Local user turns get no reply and fall through to
|
|
17
|
+
* opencode's normal prompt flow untouched.
|
|
18
|
+
*/
|
|
19
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
20
|
+
import type { Logger, PeerPermissionMode } from "./types.js";
|
|
21
|
+
type Client = PluginInput["client"];
|
|
22
|
+
export interface PeerPermissionsOptions {
|
|
23
|
+
client: Client;
|
|
24
|
+
/** Read dynamically so a future config reload could change behavior. */
|
|
25
|
+
mode: () => PeerPermissionMode;
|
|
26
|
+
directory: string;
|
|
27
|
+
logger: Logger;
|
|
28
|
+
}
|
|
29
|
+
export interface PeerPermissionsInstance {
|
|
30
|
+
/** Feed every bus event here; only permission requests are acted on. */
|
|
31
|
+
handleEvent: (event: {
|
|
32
|
+
type?: string;
|
|
33
|
+
properties?: Record<string, unknown>;
|
|
34
|
+
}) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Requests in these categories always remain under OpenCode's native policy/UI.
|
|
38
|
+
*
|
|
39
|
+
* This is a best-effort denylist, not a security boundary: it matches on the
|
|
40
|
+
* flattened event text, so a determined peer message can phrase a request to
|
|
41
|
+
* avoid these patterns (e.g. `npm config set` never names `.npmrc`). Treat
|
|
42
|
+
* `peerPermissions: "allow"` as fully trusting your peers; use "ask" for
|
|
43
|
+
* anything sensitive.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isProtectedPermission(props: Record<string, unknown>): boolean;
|
|
46
|
+
export declare function PeerPermissions(opts: PeerPermissionsOptions): PeerPermissionsInstance;
|
|
47
|
+
export {};
|