@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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
4
|
+
import { createOpenCodeAdapter } from './opencode-adapter.mjs';
|
|
5
|
+
import { createV2Delivery, v2SessionBinding, V2_HOST_VERSION } from './v2-delivery.mjs';
|
|
6
|
+
import { openV2Inbox } from './v2-inbox.mjs';
|
|
7
|
+
import { withV2Lock } from './v2-lock.mjs';
|
|
8
|
+
import { installationDirectory } from './installation.mjs';
|
|
9
|
+
import { createToolScope } from './tool-scope.mjs';
|
|
10
|
+
import { createV2Setup, V2_SETUP_TOOLS } from './v2-setup.mjs';
|
|
11
|
+
|
|
12
|
+
const validID = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
|
|
13
|
+
const display = value => JSON.stringify(value).replace(/[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu,
|
|
14
|
+
char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`);
|
|
15
|
+
|
|
16
|
+
/** Optional V2-only setup. Never migrates, attaches to, or upgrades a classic host.
|
|
17
|
+
* No receive/ack fallback is registered: incoming delivery is native automatic.
|
|
18
|
+
*/
|
|
19
|
+
export async function setupV2Plugin({ ctx, reference, tool, loadSettings, clientFactory,
|
|
20
|
+
setupOptions = {}, setupEnabled = true, deliveryFactory = createV2Delivery, adapterFactory = createOpenCodeAdapter, onDiagnostic = () => {} }) {
|
|
21
|
+
if (ctx?.app?.version !== V2_HOST_VERSION || ['get', 'synthetic', 'context', 'hook'].some(k => typeof ctx?.session?.[k] !== 'function')
|
|
22
|
+
|| typeof ctx?.storage?.get !== 'function' || typeof ctx?.storage?.set !== 'function' || typeof ctx?.storage?.scan !== 'function'
|
|
23
|
+
|| typeof ctx?.tool?.transform !== 'function' || typeof ctx?.event?.subscribe !== 'function') throw new Error('psst_v2_host_capability_unavailable');
|
|
24
|
+
const directory = realpathSync(ctx.location.directory);
|
|
25
|
+
const hostID = await withV2Lock(setupOptions.directory ?? installationDirectory(), 'host-anchor', async () => {
|
|
26
|
+
let value = await ctx.storage.get('psst-v2-host-id');
|
|
27
|
+
if (value === undefined) { value = randomUUID(); await ctx.storage.set('psst-v2-host-id', value); }
|
|
28
|
+
return value;
|
|
29
|
+
});
|
|
30
|
+
const registryPrefix = 'psst-v2-session:' + createHash('sha256').update(canonical({ directory, workspaceID: ctx.location.workspaceID ?? null })).digest('hex') + ':';
|
|
31
|
+
if (!validID(hostID)) throw new Error('psst_v2_host_binding_unavailable');
|
|
32
|
+
const sessions = new Map(), blocked = new Set(), controller = new AbortController(), registrations = [], toolScope = createToolScope();
|
|
33
|
+
let disposed = false, events, setup;
|
|
34
|
+
const diagnostic = code => { try { onDiagnostic({ code }); } catch {} };
|
|
35
|
+
async function getInfo(sessionID, signal = controller.signal) {
|
|
36
|
+
if (!validID(sessionID) || signal.aborted || disposed) throw new Error('v2_session_mismatch');
|
|
37
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
|
38
|
+
let listener;
|
|
39
|
+
const info = await Promise.race([
|
|
40
|
+
ctx.session.get({ sessionID }, { signal: bounded }),
|
|
41
|
+
new Promise((_, reject) => { listener = () => reject(new Error('v2_session_unavailable'));
|
|
42
|
+
if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); }),
|
|
43
|
+
]).finally(() => bounded.removeEventListener('abort', listener));
|
|
44
|
+
if (bounded.aborted || disposed || info?.id !== sessionID || info.parentID || info.time?.archived
|
|
45
|
+
|| realpathSync(info.location.directory) !== directory || info.location.workspaceID !== ctx.location.workspaceID) throw new Error('v2_session_mismatch');
|
|
46
|
+
return info;
|
|
47
|
+
}
|
|
48
|
+
async function close(sessionID) {
|
|
49
|
+
const state = sessions.get(sessionID);
|
|
50
|
+
if (!state) return;
|
|
51
|
+
if (state.closing) return state.closing;
|
|
52
|
+
state.controller.abort();
|
|
53
|
+
state.closing = (async () => {
|
|
54
|
+
await state.starting?.catch(() => {});
|
|
55
|
+
await state.delivery?.stop();
|
|
56
|
+
await state.adapter?.stop();
|
|
57
|
+
if (!state.adapter) state.inbox?.close();
|
|
58
|
+
if (sessions.get(sessionID) === state) sessions.delete(sessionID);
|
|
59
|
+
})();
|
|
60
|
+
return state.closing;
|
|
61
|
+
}
|
|
62
|
+
async function connect(sessionID) {
|
|
63
|
+
if (disposed || blocked.has(sessionID)) throw new Error('v2_plugin_closed');
|
|
64
|
+
let state = sessions.get(sessionID);
|
|
65
|
+
if (state) { await state.starting; if (state.closing) throw new Error('v2_session_closing'); return state; }
|
|
66
|
+
if (sessions.size >= 64) throw new Error('v2_session_limit');
|
|
67
|
+
state = { controller: new AbortController(), lifetime: Symbol('v2 host session') };
|
|
68
|
+
sessions.set(sessionID, state);
|
|
69
|
+
state.starting = (async () => {
|
|
70
|
+
const info = await getInfo(sessionID, state.controller.signal);
|
|
71
|
+
const nativeBinding = v2SessionBinding(info, hostID);
|
|
72
|
+
const settings = await loadSettings();
|
|
73
|
+
if (!settings || disposed || state.controller.signal.aborted) throw new Error('psst_connect_required');
|
|
74
|
+
const valid = async () => {
|
|
75
|
+
if (disposed || state.controller.signal.aborted || sessions.get(sessionID) !== state
|
|
76
|
+
|| settings.enrollment && Date.parse(settings.enrollment.expiresAt) <= Date.now()) return null;
|
|
77
|
+
try {
|
|
78
|
+
const now = await getInfo(sessionID, state.controller.signal);
|
|
79
|
+
return canonical(v2SessionBinding(now, hostID)) === canonical(nativeBinding) ? now : null;
|
|
80
|
+
} catch { return null; }
|
|
81
|
+
};
|
|
82
|
+
state.inbox = openV2Inbox({ reference, settings, binding: nativeBinding });
|
|
83
|
+
await ctx.storage.set(registryPrefix + sessionID, { sessionID, binding: nativeBinding, enabled: true });
|
|
84
|
+
if (!await valid()) throw new Error('v2_session_mismatch');
|
|
85
|
+
state.client = clientFactory ? clientFactory(state.inbox) : new reference.BrokerClient({ ...settings.broker, inbox: state.inbox });
|
|
86
|
+
const binding = { sessionID, directory, worktree: directory, lifetime: state.lifetime, signal: state.controller.signal };
|
|
87
|
+
state.adapter = adapterFactory({ reference, tool, client: state.client, binding,
|
|
88
|
+
currentBinding: async () => await valid() ? binding : null, closeSessionOnStop: false,
|
|
89
|
+
operationContext: context => ({ operationId: canonical({ hostID, sessionID, messageID: context.messageID, callID: context.callID }), operationStartedAt: context.operationStartedAt }),
|
|
90
|
+
onDiagnostic: entry => {
|
|
91
|
+
diagnostic(entry.code);
|
|
92
|
+
if (['unauthenticated', 'forbidden'].includes(entry.code)) { blocked.add(sessionID); queueMicrotask(() => void close(sessionID).catch(() => {})); }
|
|
93
|
+
} });
|
|
94
|
+
await state.adapter.start({ name: settings.name ?? 'OpenCode V2', model: { provider: null, id: null, source: 'unknown' }, project: null });
|
|
95
|
+
if (!await valid()) throw new Error('v2_session_mismatch');
|
|
96
|
+
state.delivery = deliveryFactory({ session: ctx.session, inbox: state.inbox, binding: nativeBinding,
|
|
97
|
+
signal: state.controller.signal, verify: valid, onDiagnostic: entry => { state.deliveryStatus = entry.code; diagnostic(entry.code); } });
|
|
98
|
+
state.delivery.start();
|
|
99
|
+
return state;
|
|
100
|
+
})();
|
|
101
|
+
try { return await state.starting; }
|
|
102
|
+
catch (error) {
|
|
103
|
+
state.controller.abort(); await state.delivery?.stop(); await state.adapter?.stop();
|
|
104
|
+
if (!state.adapter) state.inbox?.close();
|
|
105
|
+
if (sessions.get(sessionID) === state) sessions.delete(sessionID);
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const catalog = reference.TOOL_DEFINITIONS.filter(def => def.name !== 'broker_receive');
|
|
110
|
+
async function dispose() {
|
|
111
|
+
if (disposed) return;
|
|
112
|
+
disposed = true; controller.abort();
|
|
113
|
+
for (const state of sessions.values()) state.controller.abort();
|
|
114
|
+
await setup?.stop();
|
|
115
|
+
await toolScope.close();
|
|
116
|
+
await Promise.all([...sessions.keys()].map(close));
|
|
117
|
+
await Promise.all(registrations.map(item => item.dispose()));
|
|
118
|
+
await events?.catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
const status = sessionID => { const state = sessions.get(sessionID); return { host_version: V2_HOST_VERSION, mode: 'native_automatic', connected: !!state?.delivery, client_id: state?.client?.inbox?.identity?.client_id ?? null, last_delivery_status: state?.deliveryStatus ?? null, receipt_semantics: 'durable_native_admission_not_model_read' }; };
|
|
121
|
+
if (setupEnabled) setup = createV2Setup({ storage: ctx.storage, verify: async id => { try { return await getInfo(id); } catch { return null; } }, connect, status, signal: controller.signal, ...setupOptions });
|
|
122
|
+
try {
|
|
123
|
+
registrations.push(await ctx.tool.transform(editor => {
|
|
124
|
+
// Preflight the entire catalog before mutating the host editor.
|
|
125
|
+
for (const definition of [...(setup ? V2_SETUP_TOOLS : []), ...catalog]) if (editor.get(definition.name)) throw new Error('psst_v2_tool_conflict');
|
|
126
|
+
for (const definition of setup ? V2_SETUP_TOOLS : []) {
|
|
127
|
+
if (editor.get(definition.name)) throw new Error('psst_v2_tool_conflict');
|
|
128
|
+
editor.add({ ...definition, async execute(args, context) {
|
|
129
|
+
try { return { content: display(await setup.invoke(definition.name, args, context)) }; }
|
|
130
|
+
catch { return { content: display({ state: 'setup_blocked', next_step: 'Check setup status; do not paste credentials or automatically replace the request.' }) }; }
|
|
131
|
+
} });
|
|
132
|
+
}
|
|
133
|
+
for (const definition of catalog) {
|
|
134
|
+
if (editor.get(definition.name)) throw new Error('psst_v2_tool_conflict');
|
|
135
|
+
editor.add({ name: definition.name, description: definition.description, input: definition.inputSchema,
|
|
136
|
+
async execute(args, context) {
|
|
137
|
+
return toolScope.run(async operationSignal => {
|
|
138
|
+
try {
|
|
139
|
+
if (!(context?.signal instanceof AbortSignal) || context.signal.aborted || !validID(context.messageID) || !validID(context.id)) throw new Error('v2_session_mismatch');
|
|
140
|
+
const state = await connect(context.sessionID);
|
|
141
|
+
let operationStartedAt;
|
|
142
|
+
if (['broker_send', 'broker_resend'].includes(definition.name)) {
|
|
143
|
+
const bounded = AbortSignal.any([context.signal, operationSignal, controller.signal, AbortSignal.timeout(2000)]);
|
|
144
|
+
let listener;
|
|
145
|
+
const messages = await Promise.race([ctx.session.context({ sessionID: context.sessionID }, { signal: bounded }),
|
|
146
|
+
new Promise((_, reject) => { listener = () => reject(new Error('v2_context_unavailable')); if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); })
|
|
147
|
+
]).finally(() => bounded.removeEventListener('abort', listener));
|
|
148
|
+
const message = messages.find(item => item.id === context.messageID && item.type === 'assistant');
|
|
149
|
+
const call = message?.content?.find(item => item.type === 'tool' && item.id === context.id && item.name === definition.name);
|
|
150
|
+
operationStartedAt = call?.time?.created;
|
|
151
|
+
if (!Number.isSafeInteger(operationStartedAt) || operationStartedAt <= 0) throw new Error('v2_original_tool_timestamp_unavailable');
|
|
152
|
+
}
|
|
153
|
+
const content = await state.adapter.tools[definition.name].execute(args, { sessionID: context.sessionID,
|
|
154
|
+
messageID: context.messageID, callID: context.id, ...(operationStartedAt ? { operationStartedAt } : {}), directory, worktree: directory, abort: AbortSignal.any([context.signal, operationSignal, controller.signal]) });
|
|
155
|
+
return { content };
|
|
156
|
+
} catch { return { content: display({ error: { code: 'session_mismatch', effect: 'unknown' } }) }; }
|
|
157
|
+
}, context?.signal).catch(() => ({ content: display({ error: { code: 'session_mismatch', effect: 'unknown' } }) }));
|
|
158
|
+
} });
|
|
159
|
+
}
|
|
160
|
+
}));
|
|
161
|
+
// Prompt observation only, no mutation of human draft, selection or prompt.
|
|
162
|
+
registrations.push(await ctx.session.hook('prompt', event => { void connect(event.sessionID).catch(() => diagnostic('v2_connection_unavailable')); }));
|
|
163
|
+
events = (async () => {
|
|
164
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
165
|
+
if (disposed) break;
|
|
166
|
+
if (event.type === 'location.shutdown' && event.location?.directory === directory) { void dispose(); break; }
|
|
167
|
+
const sessionID = event.data?.sessionID;
|
|
168
|
+
setup?.event(event);
|
|
169
|
+
const state = sessions.get(sessionID);
|
|
170
|
+
state?.delivery?.event(event);
|
|
171
|
+
if (['session.deleted', 'session.archived', 'session.moved'].includes(event.type)) {
|
|
172
|
+
blocked.add(sessionID);
|
|
173
|
+
if (validID(sessionID)) await ctx.storage.set(registryPrefix + sessionID, { sessionID, enabled: false });
|
|
174
|
+
await close(sessionID);
|
|
175
|
+
}
|
|
176
|
+
if (event.type === 'session.created') void connect(sessionID).catch(() => diagnostic('v2_connection_unavailable'));
|
|
177
|
+
}
|
|
178
|
+
})().catch(() => { if (!disposed) { diagnostic('v2_event_stream_unavailable'); void dispose(); } });
|
|
179
|
+
let cursor, restoreCount = 0;
|
|
180
|
+
do {
|
|
181
|
+
const saved = await ctx.storage.scan({ prefix: registryPrefix, limit: 64, ...(cursor ? { after: cursor } : {}) });
|
|
182
|
+
for (const entry of saved.entries) {
|
|
183
|
+
const record = entry.value;
|
|
184
|
+
if (record?.enabled === true && validID(record.sessionID) && restoreCount < 64) { restoreCount++; void connect(record.sessionID).catch(() => diagnostic('v2_restore_unavailable')); }
|
|
185
|
+
else if (validID(record?.sessionID) && record?.enabled === false) blocked.add(record.sessionID);
|
|
186
|
+
}
|
|
187
|
+
cursor = saved.next;
|
|
188
|
+
} while (cursor && restoreCount < 64 && !disposed);
|
|
189
|
+
void setup?.resume().catch(() => diagnostic('v2_setup_restore_unavailable'));
|
|
190
|
+
return { dispose, connect, status };
|
|
191
|
+
} catch (error) { await dispose(); throw error; }
|
|
192
|
+
}
|
package/src/v2-setup.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
2
|
+
import { loadApprovalHelper } from './approval-helper.mjs';
|
|
3
|
+
import { installationDirectory, readSelection, readPending, savePending, updatePending, selectInstallation } from './installation.mjs';
|
|
4
|
+
import { withV2Lock } from './v2-lock.mjs';
|
|
5
|
+
import { createToolScope } from './tool-scope.mjs';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
|
|
8
|
+
const publicState = value => ({ state: value?.state ?? 'setup_needed',
|
|
9
|
+
...(value?.requestId ? { request_id: value.requestId } : {}),
|
|
10
|
+
...(value?.approvalUrl ? { approval_url: value.approvalUrl } : {}),
|
|
11
|
+
...(value?.requestExpiresAt ? { request_expires_at: value.requestExpiresAt } : {}) });
|
|
12
|
+
export const V2_SETUP_TOOLS = [
|
|
13
|
+
{ name: 'broker_setup', description: 'Explicit user-requested URL-only Psst setup. Provide only the HTTPS broker URL. Creates or resumes one private request for human approval in the broker admin UI. Never call on startup or replace an existing denied/expired request automatically.',
|
|
14
|
+
input: { type: 'object', properties: { broker_url: { type: 'string', maxLength: 2048 } }, required: ['broker_url'], additionalProperties: false } },
|
|
15
|
+
{ name: 'broker_setup_status', description: 'Read public Psst setup/connection status. Does not create approval requests or disclose credentials/private files.',
|
|
16
|
+
input: { type: 'object', properties: {}, required: [], additionalProperties: false } },
|
|
17
|
+
{ name: 'broker_cancel_setup', description: 'Pause this session\'s local pending Psst setup. Preserves the prior authorization and durable request; does not revoke credentials.',
|
|
18
|
+
input: { type: 'object', properties: {}, required: [], additionalProperties: false } },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** One explicit URL flow per installation; subsequent continuation is GET-only.
|
|
22
|
+
* Helper writes are awaited/drained even after host cancellation: the durable
|
|
23
|
+
* original request must be reconciled rather than recreated.
|
|
24
|
+
*/
|
|
25
|
+
export function createV2Setup({ storage, verify, connect, status, signal, directory = installationDirectory(),
|
|
26
|
+
loadHelper = loadApprovalHelper, select = selectInstallation, pollMs = 2000 }) {
|
|
27
|
+
if (!Number.isSafeInteger(pollMs) || pollMs < 10) throw new Error('setup_interval_invalid');
|
|
28
|
+
const scope = createToolScope(), lifetime = new AbortController();
|
|
29
|
+
let running, owner, ownerController = new AbortController(), beginning=false, last={state:'setup_needed'}, stopped=false;
|
|
30
|
+
const alive = () => !stopped && !signal.aborted && !lifetime.signal.aborted;
|
|
31
|
+
const binding = info => canonical({ id: info.id, projectID: info.projectID, location: info.location, created: info.time.created });
|
|
32
|
+
const bound = async id => { if (!alive() || owner?.sessionID === id && ownerController.signal.aborted) return false; const info = await verify(id); return !!info && (!owner?.binding || owner.sessionID !== id || binding(info) === owner.binding); };
|
|
33
|
+
async function pauseOwned() {
|
|
34
|
+
if (!owner) return;
|
|
35
|
+
const pending=readPending(directory);
|
|
36
|
+
if (pending?.flowId===owner.flowId) updatePending({directory,flowId:owner.flowId,paused:true});
|
|
37
|
+
}
|
|
38
|
+
async function finish(result, pending, sessionID, operationSignal) {
|
|
39
|
+
if (!await bound(sessionID)) return;
|
|
40
|
+
if (operationSignal?.aborted) { await pauseOwned(); return; }
|
|
41
|
+
const activePending = readPending(directory);
|
|
42
|
+
if (!activePending || activePending.paused || activePending.flowId !== pending.flowId) return;
|
|
43
|
+
if (result.requestId!==pending.requestId) throw new Error('approval_request_mismatch');
|
|
44
|
+
last=publicState(result);
|
|
45
|
+
if (result.state==='approved') {
|
|
46
|
+
try { await select({directory,connectionFile:result.connectionFile,expectedRevision:pending.expectedRevision,pendingFlowId:pending.flowId,verify:()=>bound(sessionID),signal:AbortSignal.any([signal,lifetime.signal,ownerController.signal,...(operationSignal ? [operationSignal] : [])])}); }
|
|
47
|
+
finally { if (operationSignal?.aborted || ownerController.signal.aborted) await pauseOwned(); }
|
|
48
|
+
if (!operationSignal?.aborted && await bound(sessionID)) await connect(sessionID);
|
|
49
|
+
} else if (result.state!=='pending') updatePending({directory,flowId:pending.flowId,paused:true});
|
|
50
|
+
}
|
|
51
|
+
function watch(pending, sessionID) {
|
|
52
|
+
if (running) return;
|
|
53
|
+
running=(async()=>{
|
|
54
|
+
const helper=await loadHelper();
|
|
55
|
+
while(await bound(sessionID)) {
|
|
56
|
+
const current=readPending(directory);
|
|
57
|
+
if(!current||current.flowId!==pending.flowId||current.paused)return;
|
|
58
|
+
const options={directory,brokerUrl:current.brokerUrl,clientType:'opencode'};
|
|
59
|
+
let waitMs = pollMs;
|
|
60
|
+
try {
|
|
61
|
+
const result=await helper.pollApproval(options); // never begin/create here
|
|
62
|
+
if(!await bound(sessionID))return;
|
|
63
|
+
await finish(result,current,sessionID);
|
|
64
|
+
if(result.state!=='pending')return;
|
|
65
|
+
} catch(error) { if(!error.retryable){last={state:'setup_blocked'};return;} waitMs=Math.min(300000,Math.max(pollMs,error.retryAfterMs||pollMs)); }
|
|
66
|
+
await delay(waitMs,undefined,{signal:AbortSignal.any([signal,lifetime.signal])}).catch(()=>{});
|
|
67
|
+
}
|
|
68
|
+
})().catch(()=>{last={state:'setup_blocked'};}).finally(()=>{running=null;});
|
|
69
|
+
}
|
|
70
|
+
async function invoke(name,args,context) {
|
|
71
|
+
return scope.run(async operationSignal=>{
|
|
72
|
+
if(!(context?.signal instanceof AbortSignal)||context.signal.aborted||!await bound(context.sessionID))throw new Error('session_mismatch');
|
|
73
|
+
if(name==='broker_cancel_setup') {
|
|
74
|
+
if(owner?.sessionID===context.sessionID) { await pauseOwned(); last={...last,state:'paused'}; }
|
|
75
|
+
return {...last,...status(context.sessionID)};
|
|
76
|
+
}
|
|
77
|
+
if(name==='broker_setup_status') return {...last,...status(context.sessionID)};
|
|
78
|
+
if(name!=='broker_setup'||!args||Object.keys(args).join(',')!=='broker_url')throw new Error('invalid_configuration');
|
|
79
|
+
const url=new URL(args.broker_url);
|
|
80
|
+
if(typeof args.broker_url!=='string'||args.broker_url.length>2048||/[\x00-\x20\x7f]/.test(args.broker_url)
|
|
81
|
+
||url.protocol!=='https:'||url.username||url.password||url.search||url.hash||url.pathname!=='/')throw new Error('https_origin_required');
|
|
82
|
+
if(beginning||running||owner&&owner.sessionID!==context.sessionID)throw new Error('setup_owner_conflict');
|
|
83
|
+
beginning=true;
|
|
84
|
+
try { return await withV2Lock(directory, 'setup', async () => {
|
|
85
|
+
const priorOwner = await storage.get('psst-v2-setup-owner');
|
|
86
|
+
const activePending = readPending(directory);
|
|
87
|
+
if (activePending && !activePending.paused && priorOwner?.flowId === activePending.flowId && priorOwner.sessionID !== context.sessionID) throw new Error('setup_owner_conflict');
|
|
88
|
+
const helper=await loadHelper();
|
|
89
|
+
const options={directory,brokerUrl:url.href,clientType:'opencode'};
|
|
90
|
+
const previous=helper.readApprovalState(options);
|
|
91
|
+
if(previous&&previous.state!=='pending')return {...publicState(previous),...status(context.sessionID),replacement_requires_explicit_setup_repair:true};
|
|
92
|
+
const selected=readSelection(directory);
|
|
93
|
+
const pending=savePending({directory,brokerUrl:url.href,expectedRevision:selected?.revision??null,requestId:previous?.requestId??null});
|
|
94
|
+
owner={flowId:pending.flowId,sessionID:context.sessionID,binding:binding(await verify(context.sessionID))};
|
|
95
|
+
ownerController = new AbortController();
|
|
96
|
+
await storage.set('psst-v2-setup-owner',owner);
|
|
97
|
+
// Abort before begin means definitely no host request. After begin may be
|
|
98
|
+
// ambiguous and must retain the helper's private ID and candidate token.
|
|
99
|
+
if(operationSignal.aborted||!await bound(context.sessionID)){await pauseOwned();throw new Error('setup_cancelled');}
|
|
100
|
+
let result;
|
|
101
|
+
try { result=await helper.beginApproval(options); }
|
|
102
|
+
catch(error) {
|
|
103
|
+
const saved=helper.readApprovalState(options);
|
|
104
|
+
if(saved?.requestId) { updatePending({directory,flowId:pending.flowId,requestId:saved.requestId}); last=publicState(saved);
|
|
105
|
+
if(error.retryable && !operationSignal.aborted && await bound(context.sessionID)) watch({...pending,requestId:saved.requestId},context.sessionID);
|
|
106
|
+
else if(operationSignal.aborted || !await bound(context.sessionID)) await pauseOwned();
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
if(previous?.requestId&&result.requestId!==previous.requestId)throw new Error('approval_request_mismatch');
|
|
111
|
+
updatePending({directory,flowId:pending.flowId,requestId:result.requestId});
|
|
112
|
+
const current={...pending,requestId:result.requestId};
|
|
113
|
+
if(operationSignal.aborted||!await bound(context.sessionID)){await pauseOwned();return{state:'paused',request_id:result.requestId};}
|
|
114
|
+
await finish(result,current,context.sessionID,operationSignal);
|
|
115
|
+
if(result.state==='pending')watch(current,context.sessionID);
|
|
116
|
+
return {...publicState(result),...status(context.sessionID)};
|
|
117
|
+
}); } finally { beginning=false; }
|
|
118
|
+
},context.signal);
|
|
119
|
+
}
|
|
120
|
+
async function resume() {
|
|
121
|
+
// Explicit durable requests only; never infer a request from an empty install.
|
|
122
|
+
const saved=await storage.get('psst-v2-setup-owner');
|
|
123
|
+
if(!saved||typeof saved.sessionID!=='string')return;
|
|
124
|
+
const pending=readPending(directory);
|
|
125
|
+
if(!pending||pending.paused||pending.flowId!==saved.flowId||!pending.requestId)return;
|
|
126
|
+
owner=saved;if(await bound(saved.sessionID))watch(pending,saved.sessionID);
|
|
127
|
+
}
|
|
128
|
+
return {invoke,resume,event(event){
|
|
129
|
+
if (owner?.sessionID === event?.data?.sessionID && ['session.deleted','session.archived','session.moved'].includes(event.type)) {
|
|
130
|
+
ownerController.abort(); void pauseOwned().catch(()=>{});
|
|
131
|
+
}
|
|
132
|
+
},async stop(){stopped=true;lifetime.abort();await scope.close();await running;}};
|
|
133
|
+
}
|
package/v2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bruno Orsolon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/v2/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Psst for native OpenCode V2 2.2.5
|
|
2
|
+
|
|
3
|
+
This server-only candidate is installed with OpenCode's native plugin manager,
|
|
4
|
+
using this repository's `v2` subdirectory at an immutable commit. It declares an
|
|
5
|
+
immutable dependency on the reviewed connector runtime; it does not rely on
|
|
6
|
+
files outside this package, install OpenCode, or load the classic TUI extension.
|
|
7
|
+
The root dependency's `./v2` export is distinct from this wrapper: no dependency
|
|
8
|
+
cycle or classic default export is involved.
|
|
9
|
+
|
|
10
|
+
After installation, ask the native agent to run `broker_setup` with only your
|
|
11
|
+
broker HTTPS URL. Approve the exact request in the broker admin page. Startup
|
|
12
|
+
reuses approved configuration; it never creates enrollment requests. No token or
|
|
13
|
+
connection-file handoff is required.
|
|
14
|
+
|
|
15
|
+
Supported native host: **2.2.5 only**. This is not a migration of classic sessions,
|
|
16
|
+
a registry release, authenticated-model proof, or complete issue #8 acceptance.
|
|
17
|
+
See the repository's `docs/native-v2.md` for the installation command and evidence.
|
package/v2/index.mjs
ADDED