@matuto-ai/opencode-psst-connector 0.0.0-stage → 0.6.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/docs/active-conversation.md +68 -0
- package/docs/message-lifecycle.md +31 -0
- package/docs/migration-0.6.0.md +21 -0
- package/docs/native-automation.md +37 -0
- package/docs/presence-notices.md +21 -0
- package/docs/publishing.md +19 -0
- package/docs/reference-snapshot.sha256 +7 -0
- package/docs/setup-acceptance.md +96 -0
- package/docs/verification.md +39 -0
- package/package.json +42 -4
- package/plugin/index.mjs +18 -0
- package/plugin/tui.mjs +160 -0
- package/src/active-presence.mjs +97 -0
- package/src/approval-helper.mjs +11 -0
- package/src/config.mjs +50 -0
- package/src/enrollment.mjs +59 -0
- package/src/identity-recovery.mjs +94 -0
- package/src/installation.mjs +127 -0
- package/src/message-format.mjs +26 -0
- package/src/message-preview.mjs +29 -0
- package/src/native-delivery.mjs +169 -0
- package/src/native-inbox.mjs +37 -0
- package/src/native-incarnation.mjs +67 -0
- package/src/native-lock.mjs +17 -0
- package/src/native-metadata.mjs +15 -0
- package/src/native-plugin.mjs +335 -0
- package/src/native-setup.mjs +208 -0
- package/src/opencode-adapter.mjs +223 -0
- package/src/presence-notices.mjs +90 -0
- package/src/project-metadata.mjs +21 -0
- package/src/reference-pin.mjs +21 -0
- package/src/reference.mjs +20 -0
- package/src/setup-rpc.mjs +16 -0
- package/src/tool-schema.mjs +5 -0
- package/src/tool-scope.mjs +26 -0
- package/vendor/broker/LICENSE +21 -0
- package/vendor/broker/ORIGIN.md +12 -0
- package/vendor/broker/reference/client.mjs +439 -0
- package/vendor/broker/reference/errors.mjs +43 -0
- package/vendor/broker/reference/inbox.mjs +334 -0
- package/vendor/broker/reference/mcp-stdio.mjs +160 -0
- package/vendor/broker/reference/runtime.mjs +329 -0
- package/vendor/broker/reference/tools.mjs +88 -0
- package/vendor/broker/src/protocol.mjs +325 -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,26 @@
|
|
|
1
|
+
import { canonical, validateEnvelope } from '../vendor/broker/src/protocol.mjs';
|
|
2
|
+
import { provenance as legacyProvenance } from '../vendor/broker/reference/runtime.mjs';
|
|
3
|
+
import { LocalError } from '../vendor/broker/reference/errors.mjs';
|
|
4
|
+
|
|
5
|
+
export const NATIVE_FORMAT = 'compact-v1';
|
|
6
|
+
|
|
7
|
+
// Versioned presentation only. Wire validation/authenticated origin and native
|
|
8
|
+
// custom-message role are unchanged. Do not change compact-v1 after publication:
|
|
9
|
+
// persisted handoffs must reconcile against the exact format originally sent.
|
|
10
|
+
export function nativeMessage(rawEnvelope, format = NATIVE_FORMAT) {
|
|
11
|
+
if (format === null || format === 'legacy-v1') return legacyProvenance(rawEnvelope);
|
|
12
|
+
if (format !== NATIVE_FORMAT) throw new LocalError('invalid_configuration');
|
|
13
|
+
const envelope = validateEnvelope(rawEnvelope);
|
|
14
|
+
const sender = { name: envelope.sender.name, client_type: envelope.sender.client_type };
|
|
15
|
+
if (envelope.sender.project?.trim()) sender.project = envelope.sender.project;
|
|
16
|
+
const model = {};
|
|
17
|
+
for (const key of ['provider', 'id']) if (envelope.sender.model[key]?.trim()) model[key] = envelope.sender.model[key];
|
|
18
|
+
if (Object.keys(model).length) sender.model = { ...model, source: envelope.sender.model.source };
|
|
19
|
+
const data = { message_id: envelope.message_id, sender, text: envelope.content.text };
|
|
20
|
+
if (envelope.correlation_id) data.correlation_id = envelope.correlation_id;
|
|
21
|
+
if (envelope.reply_to_message_id) data.reply_to_message_id = envelope.reply_to_message_id;
|
|
22
|
+
// ASCII controls are JSON escaped; escape terminal/bidi controls as well.
|
|
23
|
+
const escaped = canonical(data).replace(/[\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu,
|
|
24
|
+
char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`);
|
|
25
|
+
return `Broker message from AI agent ${envelope.sender.client_id}\n${escaped}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { validateEnvelope } from '../vendor/broker/src/protocol.mjs';
|
|
2
|
+
|
|
3
|
+
export const PREVIEW_TEXT_LIMIT = 160;
|
|
4
|
+
export const PREVIEW_SENDER_LIMIT = 40;
|
|
5
|
+
// This is a presentation-only preview, not a replacement for the full compact-v1
|
|
6
|
+
// body. Persist it with each new admission; never recompute a stored payload.
|
|
7
|
+
const unsafe = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u200b\u2060\ufeff]|\p{Bidi_Control}/u;
|
|
8
|
+
function literal(value, limit) {
|
|
9
|
+
const tokens = []; let length = 0;
|
|
10
|
+
for (const char of value) {
|
|
11
|
+
const token = char === '\\' ? '\\\\' : unsafe.test(char)
|
|
12
|
+
? `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}` : char;
|
|
13
|
+
const size = [...token].length;
|
|
14
|
+
if (length + size > limit) {
|
|
15
|
+
while (length >= limit) length -= [...tokens.pop()].length;
|
|
16
|
+
return tokens.join('') + '…';
|
|
17
|
+
}
|
|
18
|
+
tokens.push(token); length += size;
|
|
19
|
+
}
|
|
20
|
+
return tokens.join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Literal external-agent notice for the native history; no semantic summary. */
|
|
24
|
+
export function nativeDescription(rawEnvelope) {
|
|
25
|
+
const envelope = validateEnvelope(rawEnvelope);
|
|
26
|
+
const sender = literal(envelope.sender.name, PREVIEW_SENDER_LIMIT);
|
|
27
|
+
const preview = literal(envelope.content.text, PREVIEW_TEXT_LIMIT);
|
|
28
|
+
return `Psst · new message · From: ${sender} · ${envelope.sender.client_type} · ${preview}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createPresenceNotices } from './presence-notices.mjs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { canonical } from '../vendor/broker/src/protocol.mjs';
|
|
4
|
+
import { nativeMessage, NATIVE_FORMAT } from './message-format.mjs';
|
|
5
|
+
import { nativeDescription } from './message-preview.mjs';
|
|
6
|
+
|
|
7
|
+
export const HOST_VERSION = '2.2.5';
|
|
8
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
|
|
9
|
+
export function nativeSessionBinding(info, hostID) {
|
|
10
|
+
if (!id(info?.id) || !id(hostID) || !id(info?.projectID)
|
|
11
|
+
|| !Number.isSafeInteger(info?.time?.created) || info.time.created <= 0
|
|
12
|
+
|| typeof info?.location?.directory !== 'string' || !info.location.directory.startsWith('/')) throw new Error('native_session_binding_unavailable');
|
|
13
|
+
return { hostID, sessionID: info.id, created: info.time.created, projectID: info.projectID,
|
|
14
|
+
location: { directory: info.location.directory, ...(info.location.workspaceID ? { workspaceID: info.location.workspaceID } : {}) } };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Native OpenCode queue admission, never classic promptAsync or a human-role prompt.
|
|
18
|
+
* A successful receipt means durable native admission, NOT model consumption.
|
|
19
|
+
* No mutation retry: native cancellation removes its idempotency lookup row.
|
|
20
|
+
*/
|
|
21
|
+
export function createNativeDelivery({ session, inbox, binding, signal, verify, client, noticeNow, available = () => true, interval = 250,
|
|
22
|
+
requestTimeout = 2000, onDiagnostic = () => {} }) {
|
|
23
|
+
if (!session || ['synthetic', 'context'].some(key => typeof session[key] !== 'function')
|
|
24
|
+
|| !(signal instanceof AbortSignal) || typeof verify !== 'function' || typeof available !== 'function' || !id(binding?.sessionID)
|
|
25
|
+
|| !Number.isSafeInteger(interval) || interval < 10 || interval > 60000
|
|
26
|
+
|| !Number.isSafeInteger(requestTimeout) || requestTimeout < 10 || requestTimeout > 30000) throw new Error('native_capability_unavailable');
|
|
27
|
+
inbox.transaction(() => inbox.db.exec('CREATE TABLE IF NOT EXISTS v2_state(key TEXT PRIMARY KEY, value TEXT NOT NULL)'));
|
|
28
|
+
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; };
|
|
29
|
+
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)));
|
|
30
|
+
const persisted = getState('v2_binding');
|
|
31
|
+
if (persisted && canonical(persisted) !== canonical(binding)) throw new Error('native_session_mismatch');
|
|
32
|
+
if (!persisted) setState('v2_binding', binding);
|
|
33
|
+
inbox.transaction(() => inbox.db.exec(`CREATE TABLE IF NOT EXISTS v2_submissions (
|
|
34
|
+
broker_message_id TEXT PRIMARY KEY REFERENCES inbox(message_id) ON DELETE CASCADE,
|
|
35
|
+
native_id TEXT NOT NULL UNIQUE, injection_id TEXT NOT NULL, payload TEXT NOT NULL,
|
|
36
|
+
promoted INTEGER NOT NULL DEFAULT 0)`));
|
|
37
|
+
let stopped = false, running, timer, epoch = 0;
|
|
38
|
+
const active = () => !stopped && !signal.aborted && available();
|
|
39
|
+
const diagnostic = code => { try { onDiagnostic({ code }); } catch {} };
|
|
40
|
+
async function call(method, input, permitted = active) {
|
|
41
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(requestTimeout)]);
|
|
42
|
+
let listener;
|
|
43
|
+
try {
|
|
44
|
+
return await Promise.race([
|
|
45
|
+
Promise.resolve().then(() => { if (bounded.aborted || !permitted()) throw new Error('native_request_aborted'); return method(input, { signal: bounded }); }),
|
|
46
|
+
new Promise((_, reject) => { listener = () => reject(new Error('native_request_incomplete'));
|
|
47
|
+
if (bounded.aborted) listener(); else bounded.addEventListener('abort', listener, { once: true }); }),
|
|
48
|
+
]);
|
|
49
|
+
} finally { bounded.removeEventListener('abort', listener); }
|
|
50
|
+
}
|
|
51
|
+
async function current() {
|
|
52
|
+
if (!active()) return null;
|
|
53
|
+
const info = await verify();
|
|
54
|
+
if (!active() || !info || canonical(nativeSessionBinding(info, binding.hostID)) !== canonical(binding)
|
|
55
|
+
|| info.revert || info.time?.archived) return null;
|
|
56
|
+
return info;
|
|
57
|
+
}
|
|
58
|
+
function exactPayload(candidate, target) {
|
|
59
|
+
return candidate && canonical(candidate) === target.payload;
|
|
60
|
+
}
|
|
61
|
+
function exactAdmission(receipt, target) {
|
|
62
|
+
return receipt?.id === target.native_id && receipt.sessionID === binding.sessionID
|
|
63
|
+
&& receipt.type === 'synthetic' && receipt.delivery === 'queue' && exactPayload(receipt.payload, target);
|
|
64
|
+
}
|
|
65
|
+
function exactContext(message, target) {
|
|
66
|
+
return message?.id === target.native_id && message.type === 'synthetic'
|
|
67
|
+
&& exactPayload({ text: message.text, description: message.description, metadata: message.metadata }, target);
|
|
68
|
+
}
|
|
69
|
+
async function contextProof(target) {
|
|
70
|
+
const messages = await call(session.context.bind(session), { sessionID: binding.sessionID });
|
|
71
|
+
if (!Array.isArray(messages)) return false;
|
|
72
|
+
return messages.some(message => exactContext(message, target));
|
|
73
|
+
}
|
|
74
|
+
function accept(head, target) {
|
|
75
|
+
inbox.recordNativeAcceptance(head.message_id, target.injection_id, {
|
|
76
|
+
accepted: true, receipt_id: `${binding.sessionID}/${target.native_id}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function deliver() {
|
|
80
|
+
const observedEpoch = epoch;
|
|
81
|
+
const info = await current();
|
|
82
|
+
if (!info) return;
|
|
83
|
+
// Interrupt/failure is durable across reload. Never wake a cancelled or
|
|
84
|
+
// failed session just because the broker has another pending envelope.
|
|
85
|
+
const idle = info.time.idle ?? 0, busy = info.time.busy ?? 0;
|
|
86
|
+
if (['interrupted', 'failed'].includes(info.outcome) && idle >= busy || getState('v2_paused')) return;
|
|
87
|
+
const head = inbox.head();
|
|
88
|
+
if (!head) return;
|
|
89
|
+
if (head.state !== 'pending') {
|
|
90
|
+
const target = inbox.db.prepare('SELECT * FROM v2_submissions WHERE broker_message_id=?').get(head.message_id);
|
|
91
|
+
if (!target || target.injection_id !== head.injection_id) return;
|
|
92
|
+
if (await contextProof(target) && active() && observedEpoch === epoch && await current()) {
|
|
93
|
+
accept(head, target);
|
|
94
|
+
inbox.transaction(() => inbox.db.prepare('UPDATE v2_submissions SET promoted=1 WHERE broker_message_id=?').run(head.message_id));
|
|
95
|
+
diagnostic('native_admission_reconciled');
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Bound native backlog to one outstanding item. A lost delivered event is
|
|
100
|
+
// recovered by exact read-only context inspection, never another submit.
|
|
101
|
+
const outstanding = inbox.db.prepare('SELECT * FROM v2_submissions WHERE promoted=0 ORDER BY rowid LIMIT 1').get();
|
|
102
|
+
if (outstanding) {
|
|
103
|
+
if (!await contextProof(outstanding)) return;
|
|
104
|
+
if (!active() || observedEpoch !== epoch || !await current()) return;
|
|
105
|
+
inbox.transaction(() => inbox.db.prepare('UPDATE v2_submissions SET promoted=1 WHERE broker_message_id=?').run(outstanding.broker_message_id));
|
|
106
|
+
}
|
|
107
|
+
if (!active() || observedEpoch !== epoch || !await current()) return;
|
|
108
|
+
const payload = { text: nativeMessage(head.envelope), description: nativeDescription(head.envelope), metadata: {
|
|
109
|
+
source: 'psst', broker_message_id: head.message_id, sender_client_id: head.envelope.sender.client_id, format: NATIVE_FORMAT,
|
|
110
|
+
} };
|
|
111
|
+
const target = { native_id: 'msg_psst_' + randomUUID().replaceAll('-', ''), injection_id: randomUUID(), payload: canonical(payload) };
|
|
112
|
+
// State and exact immutable payload are committed atomically before effect.
|
|
113
|
+
inbox.transaction(() => {
|
|
114
|
+
const now = inbox.head();
|
|
115
|
+
if (now?.message_id !== head.message_id || now.state !== 'pending') throw new Error('native_inbox_changed');
|
|
116
|
+
inbox.db.prepare('INSERT INTO v2_submissions(broker_message_id,native_id,injection_id,payload) VALUES(?,?,?,?)')
|
|
117
|
+
.run(head.message_id, target.native_id, target.injection_id, target.payload);
|
|
118
|
+
inbox.db.prepare("UPDATE inbox SET state='injecting',injection_id=? WHERE message_id=?").run(target.injection_id, head.message_id);
|
|
119
|
+
}, { growth: Buffer.byteLength(target.payload) * 2 + 8192 });
|
|
120
|
+
// Even an aborted request may have reached the server. Leave its journal
|
|
121
|
+
// pinned, including on timeout, exception, cancellation or payload mismatch.
|
|
122
|
+
let invoked = false;
|
|
123
|
+
const notInvoked = () => inbox.transaction(() => {
|
|
124
|
+
const row = inbox.head();
|
|
125
|
+
if (row?.message_id !== head.message_id || row.state !== 'injecting' || row.injection_id !== target.injection_id) throw new Error('native_inbox_changed');
|
|
126
|
+
inbox.db.prepare("UPDATE inbox SET state='pending',injection_id=NULL WHERE message_id=?").run(head.message_id);
|
|
127
|
+
inbox.db.prepare('DELETE FROM v2_submissions WHERE broker_message_id=? AND injection_id=?').run(head.message_id, target.injection_id);
|
|
128
|
+
});
|
|
129
|
+
if (!active() || observedEpoch !== epoch) { notInvoked(); return; }
|
|
130
|
+
try {
|
|
131
|
+
const receipt = await call((...args) => { invoked = true; return session.synthetic(...args); }, { sessionID: binding.sessionID,
|
|
132
|
+
id: target.native_id, ...payload, delivery: 'queue', resume: true }, () => active() && observedEpoch === epoch);
|
|
133
|
+
if (!active() || observedEpoch !== epoch || !await current()) return;
|
|
134
|
+
if (!exactAdmission(receipt, target)) { diagnostic('native_receipt_mismatch'); return; }
|
|
135
|
+
accept(head, target);
|
|
136
|
+
diagnostic('native_admission_confirmed');
|
|
137
|
+
} catch {
|
|
138
|
+
if (!invoked) { notInvoked(); diagnostic('native_admission_deferred'); }
|
|
139
|
+
else diagnostic('native_admission_uncertain');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const notices = client ? createPresenceNotices({ client, inbox, session, binding, diagnostic, now: noticeNow,
|
|
143
|
+
call: (method, input) => { const observedEpoch = epoch;
|
|
144
|
+
return call(method, input, () => active() && epoch === observedEpoch && !getState('v2_paused')); },
|
|
145
|
+
permitted: async () => {
|
|
146
|
+
const info = await current();
|
|
147
|
+
return !!info && !getState('v2_paused') && !(['interrupted', 'failed'].includes(info.outcome)
|
|
148
|
+
&& (info.time.idle ?? 0) >= (info.time.busy ?? 0));
|
|
149
|
+
} }) : null;
|
|
150
|
+
function tick() {
|
|
151
|
+
if (!active() || running) return running;
|
|
152
|
+
running = Promise.resolve().then(deliver).then(() => notices?.tick()).catch(() => diagnostic('native_delivery_paused')).finally(() => { running = null; });
|
|
153
|
+
return running;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
tick,
|
|
157
|
+
start() { if (!timer && active()) { timer = setInterval(tick, interval); timer.unref?.(); } },
|
|
158
|
+
event(event) {
|
|
159
|
+
if (!active() || event?.data?.sessionID !== binding.sessionID) return;
|
|
160
|
+
if (['session.execution.interrupted', 'session.execution.failed', 'session.inbox.cancelled', 'session.revert.staged'].includes(event.type)) {
|
|
161
|
+
epoch++; setState('v2_paused', event.type); diagnostic('native_delivery_paused');
|
|
162
|
+
}
|
|
163
|
+
// Only host-started work, not an idle poll, can reopen cancellation.
|
|
164
|
+
if (event.type === 'session.execution.started') setState('v2_paused', null);
|
|
165
|
+
if (['session.deleted', 'session.moved', 'session.archived'].includes(event.type)) { epoch++; stopped = true; clearInterval(timer); }
|
|
166
|
+
},
|
|
167
|
+
async stop() { stopped = true; epoch++; clearInterval(timer); await running; },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { openSync, closeSync, readFileSync, writeFileSync, fsyncSync, fstatSync, constants, lstatSync } 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 openNativeInbox({ reference, settings, binding, incarnationOwner = false }) {
|
|
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
|
+
if (!incarnationOwner) {
|
|
15
|
+
try { lstatSync(join(directory, 'current-incarnation.json')); throw new Error('native_incarnation_required'); }
|
|
16
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
17
|
+
}
|
|
18
|
+
const path = join(directory, 'binding.json');
|
|
19
|
+
let fd;
|
|
20
|
+
try {
|
|
21
|
+
fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
22
|
+
writeFileSync(fd, canonical({ version: 1, binding, broker: settings.broker.url, identity: reference.newSessionIdentity('opencode') }) + '\n');
|
|
23
|
+
fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
24
|
+
const parent = openSync(directory, constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
|
|
25
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
26
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
27
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
let manifest;
|
|
29
|
+
try {
|
|
30
|
+
const stat = fstatSync(fd);
|
|
31
|
+
if (!stat.isFile() || stat.mode & 0o077 || stat.size > 8192 || process.getuid && stat.uid !== process.getuid()) throw new Error('native_binding_unavailable');
|
|
32
|
+
manifest = JSON.parse(readFileSync(fd, 'utf8'));
|
|
33
|
+
} finally { closeSync(fd); }
|
|
34
|
+
if (manifest.version !== 1 || manifest.broker !== settings.broker.url || canonical(manifest.binding) !== canonical(binding)
|
|
35
|
+
|| Object.keys(manifest).sort().join(',') !== 'binding,broker,identity,version') throw new Error('native_binding_mismatch');
|
|
36
|
+
return new reference.Inbox({ directory, identity: manifest.identity });
|
|
37
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, lstatSync, constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { canonical, validateUuid } from '../vendor/broker/src/protocol.mjs';
|
|
5
|
+
import { openNativeInbox } from './native-inbox.mjs';
|
|
6
|
+
|
|
7
|
+
/** Retain the ORIGINAL Inbox owner.sqlite lock throughout every incarnation.
|
|
8
|
+
* Old workers therefore cannot reopen the old root while a v2 child is active.
|
|
9
|
+
* Only a proven self-session404 permits replacement; native pending work stays
|
|
10
|
+
* in its original journal. Pointer uncertainty is terminal for this owner.
|
|
11
|
+
*/
|
|
12
|
+
export function openNativeIncarnation({reference, settings, binding}) {
|
|
13
|
+
if(settings.broker.registrationVersion!==2 || typeof reference.BrokerClient?.prototype?.createReplacementInbox !== 'function') throw new Error('native_incarnation_required');
|
|
14
|
+
const root=openNativeInbox({reference,settings,binding,incarnationOwner:true});
|
|
15
|
+
const closeRoot=root.close.bind(root);root.close=()=>{};
|
|
16
|
+
const closers=new WeakMap([[root,closeRoot]]);
|
|
17
|
+
const retain=inbox=>{closers.set(inbox,inbox.close.bind(inbox));inbox.close=()=>{};return inbox;};
|
|
18
|
+
let current=root, stopped=false, uncertain=false, replacing=false;
|
|
19
|
+
const path=join(root.directory,'current-incarnation.json');
|
|
20
|
+
try {
|
|
21
|
+
let pointer;
|
|
22
|
+
try { const fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW);
|
|
23
|
+
try { const s=fstatSync(fd);if(!s.isFile()||s.mode&0o077||s.size>8192||process.getuid&&s.uid!==process.getuid())throw new Error('native_incarnation_invalid');pointer=JSON.parse(readFileSync(fd,'utf8')); }finally{closeSync(fd);}
|
|
24
|
+
} catch(error) {if(error.code!=='ENOENT')throw error;}
|
|
25
|
+
if(pointer) {
|
|
26
|
+
if(pointer.version!==1||Object.keys(pointer).sort().join(',')!=='generation,identity,version'||Object.keys(pointer.identity??{}).sort().join(',')!=='client_id,client_type,session_instance_id'||pointer.identity.client_type!=='opencode')throw new Error('native_incarnation_invalid');
|
|
27
|
+
validateUuid(pointer.generation);validateUuid(pointer.identity.client_id);validateUuid(pointer.identity.session_instance_id);
|
|
28
|
+
const directory=join(root.directory,'incarnation-'+pointer.generation);
|
|
29
|
+
const dir=lstatSync(directory), db=lstatSync(join(directory,'inbox.sqlite'));
|
|
30
|
+
if(!dir.isDirectory()||dir.isSymbolicLink()||!db.isFile()||db.isSymbolicLink())throw new Error('native_incarnation_invalid');
|
|
31
|
+
current=retain(new reference.Inbox({directory,identity:pointer.identity}));
|
|
32
|
+
}
|
|
33
|
+
}catch(error){closeRoot();throw error;}
|
|
34
|
+
return {
|
|
35
|
+
get inbox(){if(stopped||uncertain)throw new Error('native_incarnation_unavailable');return current;},
|
|
36
|
+
async replace({client,verifySession,signal}) {
|
|
37
|
+
if(stopped||uncertain||replacing||client.inbox!==current||client.registrationVersion!==2||!client.expiredIdentityConfirmed)throw new Error('native_incarnation_unavailable');
|
|
38
|
+
replacing=true;
|
|
39
|
+
try {
|
|
40
|
+
const generation=randomUUID();
|
|
41
|
+
let next;
|
|
42
|
+
try { next=await client.createReplacementInbox({directory:join(root.directory,'incarnation-'+generation),verifySession,signal}); }
|
|
43
|
+
catch(error) {
|
|
44
|
+
// Native mismatch is rejected before mkdir by the reviewed helper. Other
|
|
45
|
+
// failures may leave a partial directory; require deliberate recovery.
|
|
46
|
+
if(error.code==='session_mismatch')throw error;
|
|
47
|
+
uncertain=true;throw Object.assign(new Error('native_incarnation_uncertain',{cause:error}),{code:'native_incarnation_uncertain'});
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if(stopped||signal?.aborted||await verifySession(client.identity)!==true)throw new Error('native_session_mismatch');
|
|
51
|
+
uncertain=true;
|
|
52
|
+
const child=openSync(next.directory,constants.O_RDONLY);try{fsyncSync(child);}finally{closeSync(child);}
|
|
53
|
+
const temporary=join(root.directory,`current-incarnation-${randomUUID()}.tmp`);
|
|
54
|
+
// From the first pointer write onward, uncertainty forbids all further
|
|
55
|
+
// allocations/registration in this owner. Reopen reads the durable pointer.
|
|
56
|
+
const fd=openSync(temporary,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);
|
|
57
|
+
try{writeFileSync(fd,canonical({version:1,generation,identity:next.identity})+'\n');fsyncSync(fd);}finally{closeSync(fd);}
|
|
58
|
+
renameSync(temporary,path);
|
|
59
|
+
const parent=openSync(root.directory,constants.O_RDONLY);try{fsyncSync(parent);}finally{closeSync(parent);}
|
|
60
|
+
const previous=current;current=retain(next);uncertain=false;if(previous!==root)closers.get(previous)();
|
|
61
|
+
return next;
|
|
62
|
+
}catch(error){next.close();if(uncertain)throw Object.assign(new Error('native_incarnation_uncertain',{cause:error}),{code:'native_incarnation_uncertain'});throw error;}
|
|
63
|
+
}finally{replacing=false;}
|
|
64
|
+
},
|
|
65
|
+
close(){if(stopped)return;stopped=true;try{if(current!==root)closers.get(current)();}finally{closeRoot();}}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -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 withNativeLock(directory, name, action) {
|
|
8
|
+
if (!['host-anchor','setup'].includes(name)) throw new Error('native_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('native_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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { projectMetadata } from './project-metadata.mjs';
|
|
2
|
+
const label = (value, max = 256) => typeof value === 'string' && value.trim() && [...value].length <= max
|
|
3
|
+
&& value.isWellFormed() && !/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/u.test(value) ? value.trim() : null;
|
|
4
|
+
export function nativeMetadata(info, config = {}, env = process.env) {
|
|
5
|
+
const project = projectMetadata(config, env, info.location.directory);
|
|
6
|
+
const provider = label(info.model?.providerID, 128), id = label(info.model?.id);
|
|
7
|
+
let model = provider && id ? { provider, id, source: 'observed' } : { provider: null, id: null, source: 'unknown' };
|
|
8
|
+
if (model.source === 'unknown') {
|
|
9
|
+
const fallback = label(env.OPENCODE_MANAGED_MODEL, 385), slash = fallback?.indexOf('/') ?? -1;
|
|
10
|
+
const configuredProvider = slash > 0 ? label(fallback.slice(0, slash), 128) : null;
|
|
11
|
+
const configuredID = slash > 0 ? label(fallback.slice(slash + 1)) : null;
|
|
12
|
+
if (configuredProvider && configuredID) model = { provider: configuredProvider, id: configuredID, source: 'configured' };
|
|
13
|
+
}
|
|
14
|
+
return { name: label(config.name, 128) ?? 'OpenCode', project, model };
|
|
15
|
+
}
|