@matuto-ai/opencode-broker-connector 0.1.1 → 0.4.0
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 +20 -0
- package/README.md +61 -43
- package/bin/doctor.mjs +5 -0
- package/docs/candidate-verification.md +25 -0
- package/docs/integration.md +8 -6
- package/docs/legacy-connection.md +77 -0
- package/docs/message-lifecycle.md +24 -0
- package/docs/native-automation.md +155 -0
- package/docs/native-v2.md +121 -0
- package/docs/owned-run.md +4 -2
- package/docs/publishing.md +5 -5
- package/docs/reference-snapshot.sha256 +5 -5
- package/docs/sandbox-persistence.md +43 -0
- package/docs/verification.md +62 -0
- package/package.json +15 -4
- package/plugin/index.mjs +14 -2
- package/plugin/tui.mjs +17 -0
- package/plugin/v2.mjs +18 -0
- package/src/approval-helper.mjs +11 -0
- package/src/capabilities.mjs +13 -0
- package/src/config.mjs +5 -2
- package/src/cooperative-inbox.mjs +13 -8
- package/src/enrollment.mjs +52 -0
- package/src/installation.mjs +123 -0
- package/src/message-format.mjs +26 -0
- package/src/native-context.mjs +57 -0
- package/src/native-delivery.mjs +151 -0
- package/src/native-plugin.mjs +135 -13
- package/src/opencode-adapter.mjs +31 -15
- package/src/pending-resume.mjs +38 -0
- package/src/reference-pin.mjs +6 -6
- package/src/reference.mjs +1 -1
- package/src/setup-ui.mjs +100 -0
- package/src/tool-catalog.mjs +7 -2
- package/src/tool-scope.mjs +26 -0
- package/src/v2-delivery.mjs +149 -0
- package/src/v2-inbox.mjs +33 -0
- package/src/v2-lock.mjs +17 -0
- package/src/v2-plugin.mjs +192 -0
- package/src/v2-setup.mjs +133 -0
- package/v2/LICENSE +21 -0
- package/v2/README.md +17 -0
- package/v2/index.mjs +2 -0
- package/v2/package-lock.json +420 -0
- package/v2/package.json +13 -0
- package/vendor/broker/ORIGIN.md +11 -4
- package/vendor/broker/reference/client.mjs +90 -15
- package/vendor/broker/reference/inbox.mjs +55 -10
- package/vendor/broker/reference/mcp-stdio.mjs +5 -1
- package/vendor/broker/reference/tools.mjs +38 -14
- package/vendor/broker/src/protocol.mjs +19 -0
- package/vendor/enrollment/LICENSE +21 -0
- package/vendor/enrollment/ORIGIN.md +20 -0
- package/vendor/enrollment/approval.mjs +181 -0
- package/vendor/enrollment/enrollment.mjs +137 -0
package/src/opencode-adapter.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { createToolScope } from './tool-scope.mjs';
|
|
1
2
|
import { createCooperativeInbox } from './cooperative-inbox.mjs';
|
|
2
|
-
import { ACK_DEFINITION } from './tool-catalog.mjs';
|
|
3
|
+
import { ACK_DEFINITION, DIAGNOSTIC_RECEIVE_DEFINITION } from './tool-catalog.mjs';
|
|
3
4
|
import { isAbsolute, normalize } from 'node:path';
|
|
4
5
|
|
|
5
|
-
const TOOL_NAMES = ['broker_list_clients', 'broker_send', 'broker_update_metadata', 'broker_message_status', 'broker_receive'];
|
|
6
|
+
const TOOL_NAMES = ['broker_list_clients', 'broker_send', 'broker_update_metadata', 'broker_message_status', 'broker_receive', 'broker_sent_history', 'broker_message_content', 'broker_resend', 'broker_cancel'];
|
|
6
7
|
const localId = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,256}$/.test(value);
|
|
7
8
|
const absolute = value => typeof value === 'string' && isAbsolute(value) && normalize(value) === value;
|
|
8
9
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -62,13 +63,15 @@ function displayJSON(value) {
|
|
|
62
63
|
* are insufficient lifetime proof. No automatic plugin attachment is implied.
|
|
63
64
|
*/
|
|
64
65
|
export function createOpenCodeAdapter({ reference, tool, binding, currentBinding,
|
|
65
|
-
client, inbox = client?.inbox, observeMetadata = null, onDiagnostic = () => {}, cooperativeAck = false }) {
|
|
66
|
+
client, inbox = client?.inbox, observeMetadata = null, onDiagnostic = () => {}, cooperativeAck = false, operationContext = null, closeSessionOnStop = true }) {
|
|
66
67
|
if (!reference || typeof reference.AdapterRuntime !== 'function' || typeof reference.createTools !== 'function'
|
|
67
68
|
|| typeof reference.invokeTool !== 'function' || typeof reference.safeError !== 'function'
|
|
68
69
|
|| typeof reference.LocalError !== 'function' || !Array.isArray(reference.TOOL_DEFINITIONS)
|
|
69
70
|
|| typeof tool !== 'function' || !tool.schema || !client || !inbox
|
|
70
71
|
|| !binding || !localId(binding.sessionID) || !absolute(binding.directory) || !absolute(binding.worktree)
|
|
71
72
|
|| typeof binding.lifetime !== 'symbol' || !(binding.signal instanceof AbortSignal)
|
|
73
|
+
|| typeof closeSessionOnStop !== 'boolean'
|
|
74
|
+
|| operationContext !== null && typeof operationContext !== 'function'
|
|
72
75
|
|| typeof currentBinding !== 'function' || observeMetadata !== null && typeof observeMetadata !== 'function'
|
|
73
76
|
|| inbox.identity?.client_type !== 'opencode' || client.inbox !== inbox) {
|
|
74
77
|
throw new Error('Invalid adapter configuration: provide a verified exact host binding and shared broker reference objects.');
|
|
@@ -78,13 +81,17 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
|
|
|
78
81
|
|| new Set(definitions.map(definition => definition.name)).size !== TOOL_NAMES.length) {
|
|
79
82
|
throw new Error('Shared tool catalog changed; review it before enabling this adapter.');
|
|
80
83
|
}
|
|
81
|
-
const catalog = [...definitions, ...(cooperativeAck ? [ACK_DEFINITION] : [])].map(
|
|
84
|
+
const catalog = [...definitions, ...(cooperativeAck ? [ACK_DEFINITION] : [])].map(original => {
|
|
85
|
+
const definition = cooperativeAck && original.name === 'broker_receive' ? DIAGNOSTIC_RECEIVE_DEFINITION : original;
|
|
82
86
|
const args = nativeShape(tool.schema, definition.inputSchema);
|
|
83
87
|
return { definition, args, validator: tool.schema.object(args).strict() };
|
|
84
88
|
});
|
|
85
89
|
const expected = Object.freeze({ sessionID: binding.sessionID, directory: binding.directory,
|
|
86
90
|
worktree: binding.worktree, lifetime: binding.lifetime, signal: binding.signal });
|
|
87
91
|
const identity = Object.freeze({ ...inbox.identity });
|
|
92
|
+
const toolScope = createToolScope({ closedError: () => new reference.LocalError('session_mismatch', { effect: 'unknown' }), abortedError: () => new reference.LocalError('request_aborted') });
|
|
93
|
+
const recoveryAbort = new AbortController();
|
|
94
|
+
let recoveryPromise;
|
|
88
95
|
let started = false, closed = false, startPromise, runtimeStartPromise, stopPromise;
|
|
89
96
|
const diagnostic = code => { try { onDiagnostic({ code }); } catch { /* No raw exception data. */ } };
|
|
90
97
|
function sameIdentity(candidate) {
|
|
@@ -104,16 +111,19 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
|
|
|
104
111
|
onDiagnostic: entry => diagnostic(entry.code) });
|
|
105
112
|
async function stop() {
|
|
106
113
|
if (stopPromise) return stopPromise;
|
|
107
|
-
closed = true; started = false;
|
|
114
|
+
closed = true; started = false; recoveryAbort.abort();
|
|
115
|
+
const toolsClosed = toolScope.close();
|
|
108
116
|
expected.signal.removeEventListener('abort', onClose);
|
|
117
|
+
// Fence transport lifetime synchronously, before any deferred tool drain.
|
|
118
|
+
const runtimeStopped = runtime.stop({ closeSession: false });
|
|
109
119
|
stopPromise = Promise.resolve().then(async () => {
|
|
110
120
|
try {
|
|
111
|
-
await
|
|
121
|
+
await runtimeStopped; // Cancel, but do not close a stale pre-reconnect epoch.
|
|
112
122
|
await runtimeStartPromise?.catch(() => {});
|
|
113
123
|
// Registration may have committed after cancellation. Keep
|
|
114
124
|
// its journal open until a second close fences that exact late lease.
|
|
115
|
-
await runtime.stop();
|
|
116
|
-
} finally { inbox.close(); }
|
|
125
|
+
await runtime.stop({ closeSession: closeSessionOnStop });
|
|
126
|
+
} finally { await toolsClosed; await recoveryPromise?.catch(() => {}); inbox.close(); }
|
|
117
127
|
});
|
|
118
128
|
return stopPromise;
|
|
119
129
|
}
|
|
@@ -141,6 +151,11 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
|
|
|
141
151
|
runtimeStartPromise = runtime.start(metadata);
|
|
142
152
|
const result = await runtimeStartPromise;
|
|
143
153
|
await assertBound(undefined, 'unknown');
|
|
154
|
+
// No durable tool-call ID exists in this pinned ToolContext. Reconcile
|
|
155
|
+
// private operations after exact-session reattachment, never guess from text/messageID.
|
|
156
|
+
recoveryPromise = client.recoverOutgoing?.({ limit: 10, signal: AbortSignal.any([recoveryAbort.signal, expected.signal, AbortSignal.timeout(5000)]) });
|
|
157
|
+
await recoveryPromise?.catch(() => diagnostic('outgoing_recovery_incomplete'));
|
|
158
|
+
await assertBound(undefined, 'unknown');
|
|
144
159
|
started = true;
|
|
145
160
|
return result;
|
|
146
161
|
} catch (error) {
|
|
@@ -153,6 +168,7 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
|
|
|
153
168
|
const tools = Object.fromEntries(catalog.map(({ definition, args, validator }) => {
|
|
154
169
|
return [definition.name, tool({ description: definition.description, args,
|
|
155
170
|
async execute(arguments_, context) {
|
|
171
|
+
return toolScope.run(async operationSignal => {
|
|
156
172
|
try {
|
|
157
173
|
if (!started || !context) throw new reference.LocalError('session_mismatch');
|
|
158
174
|
await assertBound(context);
|
|
@@ -160,8 +176,9 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
|
|
|
160
176
|
if (!parsed.success) throw new reference.LocalError('invalid_configuration', { field: 'arguments' });
|
|
161
177
|
// Per-invocation closure, never a mutable global current tool context.
|
|
162
178
|
const scopedClient = { inbox, get attachment() { return client.attachment; },
|
|
163
|
-
prepareSend: (...args_) => client.prepareSend(...args_)
|
|
164
|
-
|
|
179
|
+
prepareSend: (...args_) => client.prepareSend(...args_),
|
|
180
|
+
prepareResend: (...args_) => client.prepareResend(...args_) };
|
|
181
|
+
for (const method of ['listClients', 'sendPrepared', 'updateMetadata', 'messageStatus', 'refreshOwnedMetadata', 'resendPrepared', 'sentHistory', 'messageContent', 'cancelMessage']) {
|
|
165
182
|
scopedClient[method] = async (...args_) => {
|
|
166
183
|
await assertBound(context);
|
|
167
184
|
return client[method](...args_);
|
|
@@ -170,20 +187,19 @@ export function createOpenCodeAdapter({ reference, tool, binding, currentBinding
|
|
|
170
187
|
const facade = reference.createTools({ client: scopedClient, inbox, observeMetadata,
|
|
171
188
|
cooperativeBoundary: async read => { await assertBound(context); return read(); } });
|
|
172
189
|
if (cooperativeInbox) Object.assign(facade, cooperativeInbox);
|
|
173
|
-
const result = await reference.invokeTool(facade, definition.name, parsed.data);
|
|
190
|
+
const result = await reference.invokeTool(facade, definition.name, parsed.data, { ...(operationContext ? operationContext(context) : {}), signal: AbortSignal.any([operationSignal, expected.signal]) });
|
|
174
191
|
// Unknown/cancelled host results never get repackaged as acceptance.
|
|
175
|
-
//
|
|
192
|
+
// The private outgoing ledger retains any uncertain operation and its key.
|
|
176
193
|
try { await assertBound(context, 'unknown'); }
|
|
177
194
|
catch (error) {
|
|
178
195
|
const failure = reference.safeError(error);
|
|
179
|
-
const sendKey = result?.send_key ?? parsed.data.send_key;
|
|
180
|
-
if (definition.name === 'broker_send' && typeof sendKey === 'string') failure.send_key = sendKey;
|
|
181
196
|
return displayJSON(failure);
|
|
182
197
|
}
|
|
183
198
|
return displayJSON(result);
|
|
184
199
|
} catch (error) { return displayJSON(reference.safeError(error)); }
|
|
200
|
+
}, context?.abort).catch(error => displayJSON(reference.safeError(error)));
|
|
185
201
|
},
|
|
186
202
|
})];
|
|
187
203
|
}));
|
|
188
|
-
return Object.freeze({ tools, start, stop, localMode: '
|
|
204
|
+
return Object.freeze({ tools, start, stop, localMode: 'diagnostic_pull' });
|
|
189
205
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { loadApprovalHelper } from './approval-helper.mjs';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { installationDirectory, readPending, updatePending, selectInstallation } from './installation.mjs';
|
|
4
|
+
/** GET-only continuation of an explicitly persisted request. Never creates credentials. */
|
|
5
|
+
export function startPendingResume({ directory = installationDirectory(), loadHelper = loadApprovalHelper,
|
|
6
|
+
pause = (signal, ms = 2000) => delay(ms, undefined, { signal }), select = selectInstallation } = {}) {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const done = (async () => {
|
|
9
|
+
let pending;
|
|
10
|
+
try { pending=readPending(directory); } catch { return; }
|
|
11
|
+
if(!pending || pending.paused)return;
|
|
12
|
+
const flowId=pending.flowId;
|
|
13
|
+
const helper=await loadHelper();
|
|
14
|
+
while(!controller.signal.aborted) {
|
|
15
|
+
let waitMs=2000;
|
|
16
|
+
try {
|
|
17
|
+
pending=readPending(directory);
|
|
18
|
+
if(!pending||pending.flowId!==flowId||pending.paused)return;
|
|
19
|
+
const options={directory,brokerUrl:pending.brokerUrl,clientType:'opencode'};
|
|
20
|
+
const saved=helper.readApprovalState(options);
|
|
21
|
+
if(!saved || pending.requestId && saved.requestId!==pending.requestId)return;
|
|
22
|
+
const result=await helper.pollApproval(options); // Deliberately no beginApproval anywhere in this module.
|
|
23
|
+
if(controller.signal.aborted)return;
|
|
24
|
+
if(result.requestId!==saved.requestId)return;
|
|
25
|
+
if(result.state==='approved') {
|
|
26
|
+
await select({directory,connectionFile:result.connectionFile,expectedRevision:pending.expectedRevision,pendingFlowId:flowId,signal:controller.signal});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if(result.state!=='pending') { updatePending({directory,flowId,paused:true});return; }
|
|
30
|
+
} catch(error) {
|
|
31
|
+
if(!error.retryable)return; // Invalid local/auth state is inert; never reset or replace it.
|
|
32
|
+
waitMs=Math.min(300000,Math.max(2000,error.retryAfterMs||2000));
|
|
33
|
+
}
|
|
34
|
+
await pause(controller.signal,waitMs);
|
|
35
|
+
}
|
|
36
|
+
})().catch(()=>{});
|
|
37
|
+
return {done,stop:()=>controller.abort()};
|
|
38
|
+
}
|
package/src/reference-pin.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
2
2
|
import { join, isAbsolute } from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
export const REFERENCE_COMMIT = '
|
|
4
|
+
export const REFERENCE_COMMIT = '2de9cc3758b9fc593f5afca4fc501fd013d416ec';
|
|
5
5
|
const HASHES = Object.freeze({
|
|
6
|
-
"reference/inbox.mjs": "
|
|
7
|
-
"reference/client.mjs": "
|
|
6
|
+
"reference/inbox.mjs": "fbf0fa6096996cb9bc3f928e55f3eca496420880a03460e993dda3417b32ad88",
|
|
7
|
+
"reference/client.mjs": "c4c313e937d1003f28928a672072b926022aa0e9fd65825bc5db1078eb90afa9",
|
|
8
8
|
"reference/runtime.mjs": "8733076b73a42ab8f12fe20ba9908acdd754d89734ffd335e84b49aee9d737ca",
|
|
9
|
-
"reference/tools.mjs": "
|
|
10
|
-
"reference/mcp-stdio.mjs": "
|
|
9
|
+
"reference/tools.mjs": "5d3b16212a048912395d74b95a8b590d278b8b0915bb0bd7a0c3d80daf353839",
|
|
10
|
+
"reference/mcp-stdio.mjs": "f34b0cdcda9c2d607815e4ef99f1ba80fbd8936ef8c2893a858b6d408bc0ff09",
|
|
11
11
|
"reference/errors.mjs": "e33231d94f514b5a1b59fdd4184f5881dd3712bde725a1cf53352c26293452ac",
|
|
12
|
-
"src/protocol.mjs": "
|
|
12
|
+
"src/protocol.mjs": "2d88829971643d47430d6696d0a6ac61589ea4f9001c860c5c196ecc5ac21a6f"
|
|
13
13
|
});
|
|
14
14
|
/** Verify reviewed source bytes before importing any trusted local module. */
|
|
15
15
|
export function verifyReference(directory) {
|
package/src/reference.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { verifyReference } from './reference-pin.mjs';
|
|
|
2
2
|
import { isAbsolute, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
4
|
|
|
5
|
-
export const BROKER_REFERENCE_COMMIT = '
|
|
5
|
+
export const BROKER_REFERENCE_COMMIT = '2de9cc3758b9fc593f5afca4fc501fd013d416ec';
|
|
6
6
|
|
|
7
7
|
/** Trusted operator path to a pinned broker checkout; never a model argument. */
|
|
8
8
|
export async function loadBrokerReference(directory = fileURLToPath(new URL('../vendor/broker', import.meta.url))) {
|
package/src/setup-ui.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { loadApprovalHelper } from './approval-helper.mjs';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { installationDirectory, readSelection, selectInstallation, savePending, updatePending } from './installation.mjs';
|
|
4
|
+
|
|
5
|
+
/** Host-owned form, not a model tool or a conversation command. */
|
|
6
|
+
export async function installSetupUI(api, { directory = installationDirectory(), loadHelper = loadApprovalHelper,
|
|
7
|
+
select = selectInstallation, pause = (signal, ms = 2000) => delay(ms, undefined, { signal }) } = {}) {
|
|
8
|
+
let current, pending;
|
|
9
|
+
const cancel = (pause = true) => {
|
|
10
|
+
current?.abort(); current = undefined;
|
|
11
|
+
if (pause && pending) { try { updatePending({ directory, flowId: pending.flowId, paused: true }); } catch {} }
|
|
12
|
+
pending = undefined;
|
|
13
|
+
};
|
|
14
|
+
api.lifecycle.onDispose(() => cancel(false));
|
|
15
|
+
function toast(message, variant = 'info') { api.ui.toast({ title: 'Psst', message, variant, duration: 6000 }); }
|
|
16
|
+
function start() {
|
|
17
|
+
cancel();
|
|
18
|
+
const controller = new AbortController(); current = controller;
|
|
19
|
+
const alive = () => current === controller && !controller.signal.aborted && !api.lifecycle.signal.aborted;
|
|
20
|
+
let submitted = false, transitioning = false;
|
|
21
|
+
const close = () => { if (!transitioning && current === controller) cancel(); };
|
|
22
|
+
function replace(render) { transitioning = true; try { api.ui.dialog.replace(render, close); } finally { transitioning = false; } }
|
|
23
|
+
replace(() => api.ui.DialogPrompt({
|
|
24
|
+
title: 'Psst setup — broker URL', placeholder: 'https://broker.example',
|
|
25
|
+
onCancel: close,
|
|
26
|
+
onConfirm: async value => {
|
|
27
|
+
if (!alive() || submitted) return;
|
|
28
|
+
submitted = true;
|
|
29
|
+
try {
|
|
30
|
+
const brokerUrl = new URL(value.trim());
|
|
31
|
+
if (brokerUrl.protocol !== 'https:' || brokerUrl.username || brokerUrl.password || brokerUrl.search || brokerUrl.hash || brokerUrl.pathname !== '/') throw new Error();
|
|
32
|
+
const helper = await loadHelper();
|
|
33
|
+
if (!alive()) return;
|
|
34
|
+
const selected = readSelection(directory);
|
|
35
|
+
const expectedRevision = selected?.revision ?? null;
|
|
36
|
+
const options = { directory, brokerUrl: brokerUrl.href, clientType: 'opencode' };
|
|
37
|
+
const prior = helper.readApprovalState(options);
|
|
38
|
+
async function enroll(replaceRequestId) {
|
|
39
|
+
if (!alive()) return;
|
|
40
|
+
pending = savePending({ directory, brokerUrl: brokerUrl.href, expectedRevision, requestId: replaceRequestId ? null : prior?.requestId ?? null });
|
|
41
|
+
const flowId = pending.flowId;
|
|
42
|
+
replace(() => api.ui.DialogAlert({ title: 'Psst approval pending',
|
|
43
|
+
message: 'Preparing the durable request. Close to pause; setup resumes the same request.',
|
|
44
|
+
onConfirm: close }));
|
|
45
|
+
async function retry(operation) {
|
|
46
|
+
while (alive()) {
|
|
47
|
+
try { return await operation(); }
|
|
48
|
+
catch (error) { if (!error.retryable) throw error; await pause(controller.signal, Math.min(300000, Math.max(2000, error.retryAfterMs || 2000))); }
|
|
49
|
+
}
|
|
50
|
+
throw new Error('psst_setup_cancelled');
|
|
51
|
+
}
|
|
52
|
+
let result = await retry(() => helper.beginApproval({ ...options, ...(replaceRequestId ? { replaceRequestId } : {}) }));
|
|
53
|
+
const requestId = result.requestId;
|
|
54
|
+
if (!replaceRequestId && prior?.state === 'pending' && requestId !== prior.requestId) throw new Error('psst_request_changed');
|
|
55
|
+
if (alive()) {
|
|
56
|
+
updatePending({ directory, flowId, requestId });
|
|
57
|
+
if (result.state === 'pending') replace(() => api.ui.DialogAlert({
|
|
58
|
+
title: 'Psst approval pending',
|
|
59
|
+
message: `Open ${new URL('/admin', brokerUrl).href}
|
|
60
|
+
Request ID: ${requestId}
|
|
61
|
+
Approve only the matching OpenCode request. Close to pause; setup resumes this same request.`,
|
|
62
|
+
onConfirm: close }));
|
|
63
|
+
}
|
|
64
|
+
while (alive() && result.state === 'pending') {
|
|
65
|
+
await pause(controller.signal);
|
|
66
|
+
if (alive()) {
|
|
67
|
+
result = await retry(() => helper.pollApproval(options));
|
|
68
|
+
if (result.requestId !== requestId) throw new Error('psst_request_changed');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!alive()) return;
|
|
72
|
+
if (result.state !== 'approved') { toast(`Request ${result.state}. Run setup explicitly to request replacement.`, 'warning'); close(); api.ui.dialog.clear(); return; }
|
|
73
|
+
await select({ directory, connectionFile: result.connectionFile, expectedRevision, signal: controller.signal, pendingFlowId: flowId });
|
|
74
|
+
if (!alive()) return;
|
|
75
|
+
close(); api.ui.dialog.clear();
|
|
76
|
+
toast(process.env.PSST_CONFIG_FILE
|
|
77
|
+
? 'Approved installation saved. The explicit PSST_CONFIG_FILE override still takes precedence; restart without it to use the saved approval. Existing connections are unchanged.'
|
|
78
|
+
: 'Approved installation saved; transport can reconnect. Automatic messaging is blocked on this classic host; /psst-status explains the missing capability. Manual receive is diagnostic/test-only.', 'warning');
|
|
79
|
+
}
|
|
80
|
+
if (prior && prior.state !== 'pending' && (prior.state !== 'approved' || selected?.connectionFile === prior.connectionFile)) {
|
|
81
|
+
replace(() => api.ui.DialogConfirm({ title: 'Replace Psst authorization?',
|
|
82
|
+
message: 'Request a new authorization? The current connection and unread messages remain unchanged until approval; existing sessions keep their connection.',
|
|
83
|
+
onCancel: close, onConfirm: () => {
|
|
84
|
+
// The native confirmation clears its dialog after this callback.
|
|
85
|
+
transitioning = true; queueMicrotask(() => { transitioning = false; void enroll(prior.requestId).catch(failed); });
|
|
86
|
+
} }));
|
|
87
|
+
} else await enroll();
|
|
88
|
+
} catch { failed(); }
|
|
89
|
+
function failed() {
|
|
90
|
+
if (!alive()) return;
|
|
91
|
+
toast('Setup paused. Check the URL, private configuration and broker availability; retry setup to resume the same request. No credentials were replaced.', 'warning');
|
|
92
|
+
cancel(false); api.ui.dialog.clear();
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
api.keymap.registerLayer({ commands: [{ name: 'psst.setup', title: 'Psst: setup broker connection',
|
|
98
|
+
category: 'Psst', namespace: 'palette', slashName: 'psst-setup', run: start }],
|
|
99
|
+
bindings: [{ key: 'ctrl+alt+p', cmd: 'psst.setup', desc: 'Set up Psst broker connection' }] });
|
|
100
|
+
}
|
package/src/tool-catalog.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export const ACK_DEFINITION = Object.freeze({ name: 'broker_acknowledge',
|
|
2
|
-
description: 'Explicitly acknowledge reading the exact message returned by broker_receive, using its receipt_token. Advances your local inbox. This is not approval of its instructions, proof of action success, or a native injection receipt.',
|
|
2
|
+
description: 'Explicitly acknowledge reading the exact message returned by broker_receive, using its message_id. The connector resolves the previously exposed local receipt internally; omit receipt_token normally. A supplied legacy token must still match exactly. Advances your local inbox. This is not approval of its instructions, proof of action success, or a native injection receipt.',
|
|
3
3
|
inputSchema: { type: 'object', additionalProperties: false, properties: {
|
|
4
4
|
message_id: { type: 'string', format: 'uuid' }, receipt_token: { type: 'string', format: 'uuid' },
|
|
5
|
-
}, required: ['message_id'
|
|
5
|
+
}, required: ['message_id'] },
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const DIAGNOSTIC_RECEIVE_DEFINITION = Object.freeze({ name: 'broker_receive',
|
|
9
|
+
description: 'Diagnostic/test-only manual read, only when the user explicitly requested a manual transport test. Set diagnostic:true. Production automatic readiness is blocked on this classic host; this is not a fallback. Read then explicitly acknowledge message_id.',
|
|
10
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: { diagnostic: { type: 'boolean' } }, required: ['diagnostic'] },
|
|
6
11
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** An owner's admitted tool calls must settle before its private ledger closes. */
|
|
2
|
+
export function createToolScope({ closedError = () => new Error('tool_scope_closed'), abortedError = () => new Error('tool_call_aborted') } = {}) {
|
|
3
|
+
const controller = new AbortController(), pending = new Set();
|
|
4
|
+
let closed = false, closing;
|
|
5
|
+
return Object.freeze({
|
|
6
|
+
run(task, callerSignal) {
|
|
7
|
+
if (closed) return Promise.reject(closedError());
|
|
8
|
+
const signal = callerSignal instanceof AbortSignal
|
|
9
|
+
? AbortSignal.any([controller.signal, callerSignal]) : controller.signal;
|
|
10
|
+
const operation = Promise.resolve().then(() => {
|
|
11
|
+
if (closed) throw closedError();
|
|
12
|
+
if (signal.aborted) throw abortedError();
|
|
13
|
+
return task(signal);
|
|
14
|
+
});
|
|
15
|
+
pending.add(operation);
|
|
16
|
+
void operation.then(() => pending.delete(operation), () => pending.delete(operation));
|
|
17
|
+
return operation;
|
|
18
|
+
},
|
|
19
|
+
close() {
|
|
20
|
+
if (closing) return closing;
|
|
21
|
+
closed = true; controller.abort();
|
|
22
|
+
closing = Promise.allSettled([...pending]).then(() => {});
|
|
23
|
+
return closing;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
3
|
+
import { nativeMessage, NATIVE_FORMAT } from './message-format.mjs';
|
|
4
|
+
|
|
5
|
+
export const V2_HOST_VERSION = '2.2.5';
|
|
6
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
|
|
7
|
+
export function v2SessionBinding(info, hostID) {
|
|
8
|
+
if (!id(info?.id) || !id(hostID) || !id(info?.projectID)
|
|
9
|
+
|| !Number.isSafeInteger(info?.time?.created) || info.time.created <= 0
|
|
10
|
+
|| typeof info?.location?.directory !== 'string' || !info.location.directory.startsWith('/')) throw new Error('v2_session_binding_unavailable');
|
|
11
|
+
return { hostID, sessionID: info.id, created: info.time.created, projectID: info.projectID,
|
|
12
|
+
location: { directory: info.location.directory, ...(info.location.workspaceID ? { workspaceID: info.location.workspaceID } : {}) } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Native V2 queue admission, never classic promptAsync or a human-role prompt.
|
|
16
|
+
* A successful receipt means durable native admission, NOT model consumption.
|
|
17
|
+
* No mutation retry: native cancellation removes its idempotency lookup row.
|
|
18
|
+
*/
|
|
19
|
+
export function createV2Delivery({ session, inbox, binding, signal, verify, interval = 250,
|
|
20
|
+
requestTimeout = 2000, onDiagnostic = () => {} }) {
|
|
21
|
+
if (!session || ['synthetic', 'context'].some(key => typeof session[key] !== 'function')
|
|
22
|
+
|| !(signal instanceof AbortSignal) || typeof verify !== 'function' || !id(binding?.sessionID)
|
|
23
|
+
|| !Number.isSafeInteger(interval) || interval < 10 || interval > 60000
|
|
24
|
+
|| !Number.isSafeInteger(requestTimeout) || requestTimeout < 10 || requestTimeout > 30000) throw new Error('v2_capability_unavailable');
|
|
25
|
+
inbox.transaction(() => inbox.db.exec('CREATE TABLE IF NOT EXISTS v2_state(key TEXT PRIMARY KEY, value TEXT NOT NULL)'));
|
|
26
|
+
const getState = key => { inbox.assertUsable(); const row = inbox.db.prepare('SELECT value FROM v2_state WHERE key=?').get(key); return row ? JSON.parse(row.value) : null; };
|
|
27
|
+
const setState = (key, value) => inbox.transaction(() => inbox.db.prepare('INSERT INTO v2_state(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, canonical(value)));
|
|
28
|
+
const persisted = getState('v2_binding');
|
|
29
|
+
if (persisted && canonical(persisted) !== canonical(binding)) throw new Error('v2_session_mismatch');
|
|
30
|
+
if (!persisted) setState('v2_binding', binding);
|
|
31
|
+
inbox.transaction(() => inbox.db.exec(`CREATE TABLE IF NOT EXISTS v2_submissions (
|
|
32
|
+
broker_message_id TEXT PRIMARY KEY REFERENCES inbox(message_id) ON DELETE CASCADE,
|
|
33
|
+
native_id TEXT NOT NULL UNIQUE, injection_id TEXT NOT NULL, payload TEXT NOT NULL,
|
|
34
|
+
promoted INTEGER NOT NULL DEFAULT 0)`));
|
|
35
|
+
let stopped = false, running, timer, epoch = 0;
|
|
36
|
+
const active = () => !stopped && !signal.aborted;
|
|
37
|
+
const diagnostic = code => { try { onDiagnostic({ code }); } catch {} };
|
|
38
|
+
async function call(method, input, permitted = active) {
|
|
39
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(requestTimeout)]);
|
|
40
|
+
let listener;
|
|
41
|
+
try {
|
|
42
|
+
return await Promise.race([
|
|
43
|
+
Promise.resolve().then(() => { if (bounded.aborted || !permitted()) throw new Error('v2_request_aborted'); return method(input, { signal: bounded }); }),
|
|
44
|
+
new Promise((_, reject) => { listener = () => reject(new Error('v2_request_incomplete'));
|
|
45
|
+
if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); }),
|
|
46
|
+
]);
|
|
47
|
+
} finally { bounded.removeEventListener('abort', listener); }
|
|
48
|
+
}
|
|
49
|
+
async function current() {
|
|
50
|
+
if (!active()) return null;
|
|
51
|
+
const info = await verify();
|
|
52
|
+
if (!active() || !info || canonical(v2SessionBinding(info, binding.hostID)) !== canonical(binding)
|
|
53
|
+
|| info.revert || info.time?.archived) return null;
|
|
54
|
+
return info;
|
|
55
|
+
}
|
|
56
|
+
function exactPayload(candidate, target) {
|
|
57
|
+
return candidate && canonical(candidate) === target.payload;
|
|
58
|
+
}
|
|
59
|
+
function exactAdmission(receipt, target) {
|
|
60
|
+
return receipt?.id === target.native_id && receipt.sessionID === binding.sessionID
|
|
61
|
+
&& receipt.type === 'synthetic' && receipt.delivery === 'queue' && exactPayload(receipt.payload, target);
|
|
62
|
+
}
|
|
63
|
+
function exactContext(message, target) {
|
|
64
|
+
return message?.id === target.native_id && message.type === 'synthetic'
|
|
65
|
+
&& exactPayload({ text: message.text, description: message.description, metadata: message.metadata }, target);
|
|
66
|
+
}
|
|
67
|
+
async function contextProof(target) {
|
|
68
|
+
const messages = await call(session.context.bind(session), { sessionID: binding.sessionID });
|
|
69
|
+
if (!Array.isArray(messages)) return false;
|
|
70
|
+
return messages.some(message => exactContext(message, target));
|
|
71
|
+
}
|
|
72
|
+
function accept(head, target) {
|
|
73
|
+
inbox.recordNativeAcceptance(head.message_id, target.injection_id, {
|
|
74
|
+
accepted: true, receipt_id: `${binding.sessionID}/${target.native_id}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function deliver() {
|
|
78
|
+
const observedEpoch = epoch;
|
|
79
|
+
const info = await current();
|
|
80
|
+
if (!info) return;
|
|
81
|
+
// Interrupt/failure is durable across reload. Never wake a cancelled or
|
|
82
|
+
// failed session just because the broker has another pending envelope.
|
|
83
|
+
const idle = info.time.idle ?? 0, busy = info.time.busy ?? 0;
|
|
84
|
+
if (['interrupted', 'failed'].includes(info.outcome) && idle >= busy || getState('v2_paused')) return;
|
|
85
|
+
const head = inbox.head();
|
|
86
|
+
if (!head) return;
|
|
87
|
+
if (head.state !== 'pending') {
|
|
88
|
+
const target = inbox.db.prepare('SELECT * FROM v2_submissions WHERE broker_message_id=?').get(head.message_id);
|
|
89
|
+
if (!target || target.injection_id !== head.injection_id) return;
|
|
90
|
+
if (await contextProof(target) && active() && observedEpoch === epoch && await current()) {
|
|
91
|
+
accept(head, target);
|
|
92
|
+
inbox.transaction(() => inbox.db.prepare('UPDATE v2_submissions SET promoted=1 WHERE broker_message_id=?').run(head.message_id));
|
|
93
|
+
diagnostic('native_admission_reconciled');
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Bound native backlog to one outstanding item. A lost delivered event is
|
|
98
|
+
// recovered by exact read-only context inspection, never another submit.
|
|
99
|
+
const outstanding = inbox.db.prepare('SELECT * FROM v2_submissions WHERE promoted=0 ORDER BY rowid LIMIT 1').get();
|
|
100
|
+
if (outstanding) {
|
|
101
|
+
if (!await contextProof(outstanding)) return;
|
|
102
|
+
if (!active() || observedEpoch !== epoch || !await current()) return;
|
|
103
|
+
inbox.transaction(() => inbox.db.prepare('UPDATE v2_submissions SET promoted=1 WHERE broker_message_id=?').run(outstanding.broker_message_id));
|
|
104
|
+
}
|
|
105
|
+
if (!active() || observedEpoch !== epoch || !await current()) return;
|
|
106
|
+
const payload = { text: nativeMessage(head.envelope), description: 'Psst agent message', metadata: {
|
|
107
|
+
source: 'psst', broker_message_id: head.message_id, sender_client_id: head.envelope.sender.client_id, format: NATIVE_FORMAT,
|
|
108
|
+
} };
|
|
109
|
+
const target = { native_id: 'msg_psst_' + randomUUID().replaceAll('-', ''), injection_id: randomUUID(), payload: canonical(payload) };
|
|
110
|
+
// State and exact immutable payload are committed atomically before effect.
|
|
111
|
+
inbox.transaction(() => {
|
|
112
|
+
const now = inbox.head();
|
|
113
|
+
if (now?.message_id !== head.message_id || now.state !== 'pending') throw new Error('v2_inbox_changed');
|
|
114
|
+
inbox.db.prepare('INSERT INTO v2_submissions(broker_message_id,native_id,injection_id,payload) VALUES(?,?,?,?)')
|
|
115
|
+
.run(head.message_id, target.native_id, target.injection_id, target.payload);
|
|
116
|
+
inbox.db.prepare("UPDATE inbox SET state='injecting',injection_id=? WHERE message_id=?").run(target.injection_id, head.message_id);
|
|
117
|
+
}, { growth: Buffer.byteLength(target.payload) * 2 + 8192 });
|
|
118
|
+
// Even an aborted request may have reached the server. Leave its journal
|
|
119
|
+
// pinned, including on timeout, exception, cancellation or payload mismatch.
|
|
120
|
+
if (!active() || observedEpoch !== epoch) return;
|
|
121
|
+
try {
|
|
122
|
+
const receipt = await call(session.synthetic.bind(session), { sessionID: binding.sessionID,
|
|
123
|
+
id: target.native_id, ...payload, delivery: 'queue', resume: true }, () => active() && observedEpoch === epoch);
|
|
124
|
+
if (!active() || observedEpoch !== epoch || !await current()) return;
|
|
125
|
+
if (!exactAdmission(receipt, target)) { diagnostic('native_receipt_mismatch'); return; }
|
|
126
|
+
accept(head, target);
|
|
127
|
+
diagnostic('native_admission_confirmed');
|
|
128
|
+
} catch { diagnostic('native_admission_uncertain'); }
|
|
129
|
+
}
|
|
130
|
+
function tick() {
|
|
131
|
+
if (!active() || running) return running;
|
|
132
|
+
running = Promise.resolve().then(deliver).catch(() => diagnostic('native_delivery_paused')).finally(() => { running = null; });
|
|
133
|
+
return running;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
tick,
|
|
137
|
+
start() { if (!timer && active()) { timer = setInterval(tick, interval); timer.unref?.(); } },
|
|
138
|
+
event(event) {
|
|
139
|
+
if (!active() || event?.data?.sessionID !== binding.sessionID) return;
|
|
140
|
+
if (['session.execution.interrupted', 'session.execution.failed', 'session.inbox.cancelled', 'session.revert.staged'].includes(event.type)) {
|
|
141
|
+
epoch++; setState('v2_paused', event.type); diagnostic('native_delivery_paused');
|
|
142
|
+
}
|
|
143
|
+
// Only host-started work, not an idle poll, can reopen cancellation.
|
|
144
|
+
if (event.type === 'session.execution.started') setState('v2_paused', null);
|
|
145
|
+
if (['session.deleted', 'session.moved', 'session.archived'].includes(event.type)) { epoch++; stopped = true; clearInterval(timer); }
|
|
146
|
+
},
|
|
147
|
+
async stop() { stopped = true; epoch++; clearInterval(timer); await running; },
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/v2-inbox.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { openSync, closeSync, readFileSync, writeFileSync, fsyncSync, fstatSync, constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
5
|
+
import { privateDirectory } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
/** Reopen only this installation + native server store + exact native session.
|
|
8
|
+
* Exclusive Inbox ownership protects concurrent owners. Never reset a partial,
|
|
9
|
+
* foreign or unreadable manifest: its broker/native effects may be unknown.
|
|
10
|
+
*/
|
|
11
|
+
export function openV2Inbox({ reference, settings, binding }) {
|
|
12
|
+
const key = createHash('sha256').update(canonical({ binding, broker: settings.broker.url })).digest('hex');
|
|
13
|
+
const directory = privateDirectory(join(privateDirectory(settings.stateDirectory), 'opencode-v2-' + key));
|
|
14
|
+
const path = join(directory, 'binding.json');
|
|
15
|
+
let fd;
|
|
16
|
+
try {
|
|
17
|
+
fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
18
|
+
writeFileSync(fd, canonical({ version: 1, binding, broker: settings.broker.url, identity: reference.newSessionIdentity('opencode') }) + '\n');
|
|
19
|
+
fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
20
|
+
const parent = openSync(directory, constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
21
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
22
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
23
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
24
|
+
let manifest;
|
|
25
|
+
try {
|
|
26
|
+
const stat = fstatSync(fd);
|
|
27
|
+
if (!stat.isFile() || stat.mode & 0o077 || stat.size > 8192 || process.getuid && stat.uid !== process.getuid()) throw new Error('v2_binding_unavailable');
|
|
28
|
+
manifest = JSON.parse(readFileSync(fd, 'utf8'));
|
|
29
|
+
} finally { closeSync(fd); }
|
|
30
|
+
if (manifest.version !== 1 || manifest.broker !== settings.broker.url || canonical(manifest.binding) !== canonical(binding)
|
|
31
|
+
|| Object.keys(manifest).sort().join(',') !== 'binding,broker,identity,version') throw new Error('v2_binding_mismatch');
|
|
32
|
+
return new reference.Inbox({ directory, identity: manifest.identity });
|
|
33
|
+
}
|
package/src/v2-lock.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { openSync, closeSync, lstatSync, constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { privateDirectory } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
/** Cross-location/process ownership, released by the OS on crash. No stale reset. */
|
|
7
|
+
export async function withV2Lock(directory, name, action) {
|
|
8
|
+
if (!['host-anchor','setup'].includes(name)) throw new Error('v2_lock_invalid');
|
|
9
|
+
const path=join(privateDirectory(directory),`v2-${name}-owner.sqlite`);
|
|
10
|
+
try { const fd=openSync(path,constants.O_CREAT|constants.O_EXCL|constants.O_WRONLY|constants.O_NOFOLLOW,0o600);closeSync(fd); }
|
|
11
|
+
catch(error){if(error.code!=='EEXIST')throw error;}
|
|
12
|
+
const stat=lstatSync(path);
|
|
13
|
+
if(!stat.isFile()||stat.isSymbolicLink()||stat.mode&0o077||process.getuid&&stat.uid!==process.getuid())throw new Error('v2_lock_invalid');
|
|
14
|
+
let db;
|
|
15
|
+
try{db=new DatabaseSync(path);db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner(id INTEGER)');return await action();}
|
|
16
|
+
finally{db?.close();}
|
|
17
|
+
}
|