@matuto-ai/opencode-broker-connector 0.1.1 → 0.3.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 +34 -38
- package/docs/legacy-connection.md +77 -0
- package/docs/native-automation.md +95 -0
- package/docs/publishing.md +5 -5
- package/docs/sandbox-persistence.md +34 -0
- package/docs/verification.md +60 -0
- package/package.json +9 -3
- package/plugin/index.mjs +14 -2
- package/plugin/tui.mjs +14 -0
- package/src/approval-helper.mjs +11 -0
- package/src/config.mjs +5 -2
- package/src/enrollment.mjs +52 -0
- package/src/installation.mjs +122 -0
- package/src/native-context.mjs +57 -0
- package/src/native-delivery.mjs +151 -0
- package/src/native-plugin.mjs +131 -11
- package/src/pending-resume.mjs +38 -0
- package/src/setup-ui.mjs +100 -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/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. New sessions connect automatically; existing sessions retain their authorization. Receive remains cooperative.', 'success');
|
|
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
|
+
}
|
|
@@ -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.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Shared enrollment helper
|
|
2
|
+
|
|
3
|
+
Unmodified `reference/enrollment.mjs` from `matuto-ai/psst` commit `3e908e79966d886a02d372c103e31dec6e900c1a`, independently compared byte-for-byte with the remote committed source. Upstream PR #18 is pending review/merge; this commit pin is not a deployment or release claim.
|
|
4
|
+
|
|
5
|
+
SHA-256: `afeab00d7c2d6768d3537bd7c350e249768897bf81eff9b4ae9796f7c3f9dbe9`.
|
|
6
|
+
|
|
7
|
+
Runtime hash checked by `src/enrollment.mjs` before import. MIT license copied alongside. The existing pinned broker transport/runtime remains unchanged at its separate origin.
|
|
8
|
+
|
|
9
|
+
Source: https://github.com/matuto-ai/psst/blob/3e908e79966d886a02d372c103e31dec6e900c1a/reference/enrollment.mjs
|
|
10
|
+
Operator guide at that commit: https://github.com/matuto-ai/psst/blob/3e908e79966d886a02d372c103e31dec6e900c1a/docs/pairing-operator.md
|
|
11
|
+
|
|
12
|
+
## URL-only approval helper
|
|
13
|
+
|
|
14
|
+
`approval.mjs` is vendored byte-for-byte from `matuto-ai/psst` commit
|
|
15
|
+
`72f5d2d1b834e02cfb6b2f7dc11c6f1a55729344`, path `reference/approval.mjs`,
|
|
16
|
+
Git blob `8e6128c22835e66c0ab50aa15c27ff16d33e1169`, SHA-256
|
|
17
|
+
`a60c3a0ccd7deb50556586cd67841bb89a300ea3c7f1af3a91d437f856b7a2d5`.
|
|
18
|
+
This is the explicit broker PR #22 dependency, not a claim of a deployed release.
|
|
19
|
+
The connector checks the digest before importing it. The old enrollment helper
|
|
20
|
+
remains unchanged for legacy bundles/receipts.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Single-file helper deliberately vendorable by native connectors. Never emits secrets.
|
|
2
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
3
|
+
import { openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync, mkdirSync, lstatSync, realpathSync, readdirSync, constants } from 'node:fs';
|
|
4
|
+
import { resolve, dirname, join, isAbsolute } from 'node:path';
|
|
5
|
+
import https from 'node:https';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
|
|
8
|
+
const TYPES = ['pi','claude-code','opencode','antigravity-cli'];
|
|
9
|
+
const fail = (code, effect = 'not_applied', retryable=false, retryAfterMs=2000) => { const e = new Error(code); Object.assign(e,{code,effect,retryable,...(retryable?{retryAfterMs:Math.max(1000,Math.min(300000,Math.ceil(retryAfterMs)))}:{})}); throw e; };
|
|
10
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
11
|
+
function privateDir(path) {
|
|
12
|
+
if (!isAbsolute(path)) fail('private_directory_required');
|
|
13
|
+
mkdirSync(path, {recursive:true, mode:0o700});
|
|
14
|
+
const s = lstatSync(path);
|
|
15
|
+
if (!s.isDirectory() || s.isSymbolicLink() || realpathSync(path) !== resolve(path) || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_directory_required');
|
|
16
|
+
}
|
|
17
|
+
function privateRead(path) {
|
|
18
|
+
if (!isAbsolute(path) || realpathSync(dirname(path)) !== resolve(dirname(path))) fail('private_file_required');
|
|
19
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
20
|
+
try {
|
|
21
|
+
const s = fstatSync(fd);
|
|
22
|
+
if (!s.isFile() || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_file_required');
|
|
23
|
+
return readFileSync(fd,'utf8');
|
|
24
|
+
} finally { closeSync(fd); }
|
|
25
|
+
}
|
|
26
|
+
function save(path, text) {
|
|
27
|
+
// Reject unsafe existing destinations rather than silently repairing their mode.
|
|
28
|
+
try { privateRead(path); } catch(e) { if(e.code !== 'ENOENT') throw e; }
|
|
29
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
30
|
+
const fd = openSync(temp, constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);
|
|
31
|
+
try { writeFileSync(fd,text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
32
|
+
renameSync(temp,path);
|
|
33
|
+
const d = openSync(dirname(path),constants.O_RDONLY);try { fsyncSync(d); } finally { closeSync(d); }
|
|
34
|
+
}
|
|
35
|
+
function origin(value) {
|
|
36
|
+
let u; try { u = new URL(value); } catch { fail('https_origin_required'); }
|
|
37
|
+
if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/u.test(value) || u.protocol !== 'https:' || u.username || u.password || u.pathname !== '/' || u.search || u.hash) fail('https_origin_required');
|
|
38
|
+
return u.origin;
|
|
39
|
+
}
|
|
40
|
+
const stateNames=['pending','approved','denied','expired','revoked'];
|
|
41
|
+
const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
42
|
+
function locations({brokerUrl,directory,clientType}) {
|
|
43
|
+
if(!TYPES.includes(clientType))fail('invalid_client_type');
|
|
44
|
+
if(!isAbsolute(directory))fail('private_directory_required');
|
|
45
|
+
const broker=origin(brokerUrl),root=join(directory,'approvals',hash(broker+'\0'+clientType));
|
|
46
|
+
return {broker,root,current:join(root,'current.json'),clientType};
|
|
47
|
+
}
|
|
48
|
+
function load(loc) {
|
|
49
|
+
let pointer;try{pointer=JSON.parse(privateRead(loc.current));}catch(e){if(e.code==='ENOENT')return null;throw e;}
|
|
50
|
+
if(!pointer||!uuid.test(pointer.requestId))fail('invalid_approval_state');
|
|
51
|
+
const dir=join(loc.root,pointer.requestId),state=JSON.parse(privateRead(join(dir,'attempt.json')));
|
|
52
|
+
if(state.requestId!==pointer.requestId||state.brokerUrl!==loc.broker||state.clientType!==loc.clientType
|
|
53
|
+
||!/^[A-Za-z0-9_-]{43}$/.test(state.token)||!stateNames.includes(state.state)
|
|
54
|
+
||typeof state.label!=='string'||state.label.length>80)fail('invalid_approval_state');
|
|
55
|
+
return {state,dir};
|
|
56
|
+
}
|
|
57
|
+
function visible({state,dir}) {
|
|
58
|
+
const result={requestId:state.requestId,approvalUrl:state.brokerUrl+'/admin',state:state.state,requestExpiresAt:state.requestExpiresAt??null};
|
|
59
|
+
if(state.state==='approved'){
|
|
60
|
+
if(!state.credential||Date.parse(state.credential.expires_at)<=Date.now())return {...result,state:'expired'};
|
|
61
|
+
result.connectionFile=join(dir,'connection.json');
|
|
62
|
+
Object.assign(result,readEnrollmentReceipt({connectionFile:result.connectionFile,clientType:state.clientType}));
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function sanitized(e) {
|
|
67
|
+
if(e.effect&&/^[a-z_]+$/.test(e.code))throw e;
|
|
68
|
+
fail('approval_local_error','unknown');
|
|
69
|
+
}
|
|
70
|
+
export function readApprovalState(options) {
|
|
71
|
+
try{const item=load(locations(options));return item?visible(item):null;}catch(e){sanitized(e);}
|
|
72
|
+
}
|
|
73
|
+
function request(broker,method,path,token,input) {
|
|
74
|
+
return new Promise((resolvePromise,reject)=>{
|
|
75
|
+
const body=input===undefined?undefined:JSON.stringify(input);
|
|
76
|
+
const transportError=()=>Object.assign(new Error('approval_transport_error'),{code:'approval_transport_error',effect:'unknown',retryable:true,retryAfterMs:2000});
|
|
77
|
+
const req=https.request(new URL(path,broker),{method,headers:{Authorization:`Enrollment ${token}`,...(body?{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}:{})}},res=>{
|
|
78
|
+
let size=0;const chunks=[];
|
|
79
|
+
res.on('data',chunk=>{size+=chunk.length;if(size>16384)res.destroy();else chunks.push(chunk);});
|
|
80
|
+
res.on('error',()=>reject(transportError()));
|
|
81
|
+
res.on('end',()=>{try{resolvePromise({status:res.statusCode,retryAfter:res.headers['retry-after'],body:JSON.parse(Buffer.concat(chunks).toString('utf8'))});}catch{reject(Object.assign(new Error('invalid_response'),{code:'invalid_response',effect:'unknown',retryable:false}));}});
|
|
82
|
+
});
|
|
83
|
+
const timer=setTimeout(()=>req.destroy(),10000);req.on('close',()=>clearTimeout(timer));
|
|
84
|
+
req.on('error',()=>reject(transportError()));req.end(body);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function publish(item,result,broker,clientType){
|
|
88
|
+
const {state,dir}=item;
|
|
89
|
+
if(!result||result.request_id!==state.requestId||!stateNames.includes(result.state)
|
|
90
|
+
||!Number.isFinite(Date.parse(result.request_expires_at)))fail('invalid_response','unknown');
|
|
91
|
+
if(result.state==='approved'){
|
|
92
|
+
const c=result.credential;
|
|
93
|
+
if(!c||c.protocol_version!=='1'||c.client_type!==clientType||!/^enrolled-[0-9a-f-]{36}$/.test(c.credential_id)
|
|
94
|
+
||!/^installation-[0-9a-f-]{36}$/.test(c.adapter_id)||!Array.isArray(c.scopes)||!c.scopes.length||c.scopes.length>32
|
|
95
|
+
||c.scopes.some(s=>typeof s!=='string'||!/^[-a-zA-Z0-9_.]{1,128}$/.test(s))||!Number.isFinite(Date.parse(c.expires_at)))fail('invalid_response','unknown');
|
|
96
|
+
if(Date.parse(c.expires_at)<=Date.now())fail('credential_expired');
|
|
97
|
+
save(join(dir,'token'),state.token+'\n');
|
|
98
|
+
save(join(dir,'enrollment.json'),JSON.stringify({format:'psst-enrollment-receipt-v1',broker_url:broker,...c,token_sha256:hash(state.token)})+'\n');
|
|
99
|
+
save(join(dir,'connection.json'),JSON.stringify({format:'broker-client-bundle-v1',protocol_version:'1',broker_url:broker,token_file:'token',enrollment_receipt_file:'enrollment.json',file_paths_relative_to:'connection_file_directory',authorization_header:'Authorization',authorization_scheme:'Bearer',insecure_development:false,allowed_client_types:[clientType],scope:c.scopes[0]})+'\n');
|
|
100
|
+
state.credential=c;
|
|
101
|
+
}
|
|
102
|
+
state.state=result.state;state.requestExpiresAt=result.request_expires_at;
|
|
103
|
+
save(join(dir,'attempt.json'),JSON.stringify(state)+'\n');return visible(item);
|
|
104
|
+
}
|
|
105
|
+
async function operate(options,begin,{transport=request,pollIntervalMs=2000}={}) {
|
|
106
|
+
let lock;
|
|
107
|
+
try {
|
|
108
|
+
const loc=locations(options);
|
|
109
|
+
if(!begin&&!load(loc))fail('approval_setup_required');
|
|
110
|
+
privateDir(options.directory);privateDir(join(options.directory,'approvals'));privateDir(loc.root);
|
|
111
|
+
const lockPath=join(loc.root,'.owner.sqlite');
|
|
112
|
+
try{privateRead(lockPath);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
113
|
+
const fd=openSync(lockPath,constants.O_WRONLY|constants.O_CREAT|constants.O_NOFOLLOW,0o600);closeSync(fd);
|
|
114
|
+
try{lock=new DatabaseSync(lockPath);lock.exec('PRAGMA journal_mode=DELETE; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner(id INTEGER);');}
|
|
115
|
+
catch{fail('approval_busy','not_applied',true);}
|
|
116
|
+
let item=load(loc);
|
|
117
|
+
if(begin){
|
|
118
|
+
const replacement=options.replaceRequestId;
|
|
119
|
+
if(replacement!==undefined&&(!uuid.test(replacement)||!item))fail('selection_conflict');
|
|
120
|
+
if(replacement!==undefined&&item.state.requestId!==replacement&&item.state.parentRequestId!==replacement)fail('selection_conflict');
|
|
121
|
+
if(replacement!==undefined&&item.state.requestId===replacement&&item.state.state==='pending')fail('selection_conflict');
|
|
122
|
+
if(!item||(replacement!==undefined&&item.state.requestId===replacement)){
|
|
123
|
+
if(readdirSync(loc.root).length>=130)fail('approval_capacity');
|
|
124
|
+
const label=options.label??options.clientType;
|
|
125
|
+
if(typeof label!=='string'||label.length>80||/[\u0000-\u001f\u007f]/u.test(label))fail('invalid_request');
|
|
126
|
+
const state={requestId:randomUUID(),parentRequestId:replacement??null,brokerUrl:loc.broker,clientType:options.clientType,label,token:randomBytes(32).toString('base64url'),state:'pending'};
|
|
127
|
+
const dir=join(loc.root,state.requestId);privateDir(dir);
|
|
128
|
+
save(join(dir,'attempt.json'),JSON.stringify(state)+'\n');
|
|
129
|
+
save(loc.current,JSON.stringify({requestId:state.requestId})+'\n');item={state,dir};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if(!item)fail('approval_setup_required');
|
|
133
|
+
if(item.state.state!=='pending')return visible(item);
|
|
134
|
+
const s=item.state;
|
|
135
|
+
if(Number.isFinite(s.retryAt)&&s.retryAt>Date.now()&&s.retryAt-Date.now()<=300000)fail(s.retryCode??'rate_limited',s.retryEffect??'unknown',true,s.retryAt-Date.now());
|
|
136
|
+
if(!begin){
|
|
137
|
+
const now=Date.now();
|
|
138
|
+
if(Number.isFinite(s.lastPollAt)&&now>=s.lastPollAt&&now-s.lastPollAt<pollIntervalMs)return visible(item);
|
|
139
|
+
s.lastPollAt=now;save(join(item.dir,'attempt.json'),JSON.stringify(s)+'\n');
|
|
140
|
+
}
|
|
141
|
+
const path=begin?'/v1/enrollment-requests':`/v1/enrollment-requests/${s.requestId}`;
|
|
142
|
+
const response=await transport(loc.broker,begin?'POST':'GET',path,s.token,begin?{request_id:s.requestId,client_type:s.clientType,label:s.label}:undefined);
|
|
143
|
+
if(![200,201].includes(response.status)){
|
|
144
|
+
const code=response.body?.error?.code;
|
|
145
|
+
let retryAfterMs=response.status===429?60000:5000;
|
|
146
|
+
if(typeof response.retryAfter==='string'){
|
|
147
|
+
const seconds=/^[0-9]{1,6}$/.test(response.retryAfter)?Number(response.retryAfter):NaN;
|
|
148
|
+
const parsed=Number.isFinite(seconds)?seconds*1000:Date.parse(response.retryAfter)-Date.now();
|
|
149
|
+
if(Number.isFinite(parsed))retryAfterMs=Math.max(1000,Math.min(300000,Math.ceil(parsed)));
|
|
150
|
+
}
|
|
151
|
+
if([429,503].includes(response.status)){s.retryAt=Date.now()+retryAfterMs;s.retryCode=code==='not_ready'||code==='storage_unavailable'?code:'rate_limited';s.retryEffect=response.body?.error?.effect==='not_applied'?'not_applied':'unknown';save(join(item.dir,'attempt.json'),JSON.stringify(s)+'\n');}
|
|
152
|
+
fail(['unauthenticated','forbidden','invalid_request','idempotency_conflict','rate_limited','not_ready','storage_unavailable'].includes(code)?code:'invalid_response',response.body?.error?.effect==='not_applied'?'not_applied':'unknown',[429,503].includes(response.status),retryAfterMs);
|
|
153
|
+
}
|
|
154
|
+
delete s.retryAt;delete s.retryCode;delete s.retryEffect;
|
|
155
|
+
return publish(item,response.body,loc.broker,options.clientType);
|
|
156
|
+
}catch(e){sanitized(e);}finally{try{lock?.close();}catch{}}
|
|
157
|
+
}
|
|
158
|
+
export const beginApproval=(options,testOptions)=>operate(options,true,testOptions);
|
|
159
|
+
export const pollApproval=(options,testOptions)=>operate(options,false,testOptions);
|
|
160
|
+
|
|
161
|
+
/** Read only for a helper-enrolled bundle. Missing/corrupt receipt is not a legacy fallback. */
|
|
162
|
+
export function readEnrollmentReceipt({connectionFile,clientType}) {
|
|
163
|
+
try {
|
|
164
|
+
if(!TYPES.includes(clientType))fail('invalid_client_type');
|
|
165
|
+
const bundle=JSON.parse(privateRead(connectionFile));
|
|
166
|
+
const receipt=JSON.parse(privateRead(join(dirname(connectionFile),'enrollment.json')));
|
|
167
|
+
const token=privateRead(join(dirname(connectionFile),'token')).trim();
|
|
168
|
+
if(bundle?.format!=='broker-client-bundle-v1'||bundle.protocol_version!=='1'||bundle.token_file!=='token'||bundle.enrollment_receipt_file!=='enrollment.json'
|
|
169
|
+
||bundle.authorization_scheme!=='Bearer'||bundle.authorization_header!=='Authorization'||bundle.insecure_development!==false
|
|
170
|
+
||!Array.isArray(bundle.allowed_client_types)||bundle.allowed_client_types.length!==1||bundle.allowed_client_types[0]!==clientType
|
|
171
|
+
||receipt?.format!=='psst-enrollment-receipt-v1'||receipt.protocol_version!=='1'||receipt.client_type!==clientType
|
|
172
|
+
||receipt.broker_url!==origin(bundle.broker_url)||!/^[A-Za-z0-9_-]{43}$/.test(token)||receipt.token_sha256!==hash(token)
|
|
173
|
+
||!/^enrolled-[0-9a-f-]{36}$/.test(receipt.credential_id)||!/^installation-[0-9a-f-]{36}$/.test(receipt.adapter_id)
|
|
174
|
+
||!Array.isArray(receipt.scopes)||!receipt.scopes.length||receipt.scopes[0]!==bundle.scope||!Number.isFinite(Date.parse(receipt.expires_at)))fail('invalid_enrollment_receipt');
|
|
175
|
+
if(Date.parse(receipt.expires_at)<=Date.now())fail('credential_expired');
|
|
176
|
+
return {credentialId:receipt.credential_id,expiresAt:receipt.expires_at};
|
|
177
|
+
} catch(e) {
|
|
178
|
+
if(e.effect&&/^[a-z_]+$/.test(e.code))throw e;
|
|
179
|
+
fail('invalid_enrollment_receipt');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Single-file helper deliberately vendorable by native connectors. Never emits secrets.
|
|
2
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
3
|
+
import { openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync, mkdirSync, lstatSync, realpathSync, readdirSync, constants } from 'node:fs';
|
|
4
|
+
import { resolve, dirname, join, isAbsolute } from 'node:path';
|
|
5
|
+
import https from 'node:https';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
|
|
8
|
+
const TYPES = ['pi','claude-code','opencode','antigravity-cli'];
|
|
9
|
+
const fail = (code, effect = 'not_applied') => { const e = new Error(code); e.code = code; e.effect = effect; throw e; };
|
|
10
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
11
|
+
function privateDir(path) {
|
|
12
|
+
if (!isAbsolute(path)) fail('private_directory_required');
|
|
13
|
+
mkdirSync(path, {recursive:true, mode:0o700});
|
|
14
|
+
const s = lstatSync(path);
|
|
15
|
+
if (!s.isDirectory() || s.isSymbolicLink() || realpathSync(path) !== resolve(path) || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_directory_required');
|
|
16
|
+
}
|
|
17
|
+
function privateRead(path) {
|
|
18
|
+
if (!isAbsolute(path) || realpathSync(dirname(path)) !== resolve(dirname(path))) fail('private_file_required');
|
|
19
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
20
|
+
try {
|
|
21
|
+
const s = fstatSync(fd);
|
|
22
|
+
if (!s.isFile() || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_file_required');
|
|
23
|
+
return readFileSync(fd,'utf8');
|
|
24
|
+
} finally { closeSync(fd); }
|
|
25
|
+
}
|
|
26
|
+
function save(path, text) {
|
|
27
|
+
// Reject unsafe existing destinations rather than silently repairing their mode.
|
|
28
|
+
try { privateRead(path); } catch(e) { if(e.code !== 'ENOENT') throw e; }
|
|
29
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
30
|
+
const fd = openSync(temp, constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);
|
|
31
|
+
try { writeFileSync(fd,text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
32
|
+
renameSync(temp,path);
|
|
33
|
+
const d = openSync(dirname(path),constants.O_RDONLY);try { fsyncSync(d); } finally { closeSync(d); }
|
|
34
|
+
}
|
|
35
|
+
function origin(value) {
|
|
36
|
+
let u; try { u = new URL(value); } catch { fail('https_origin_required'); }
|
|
37
|
+
if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/u.test(value) || u.protocol !== 'https:' || u.username || u.password || u.pathname !== '/' || u.search || u.hash) fail('https_origin_required');
|
|
38
|
+
return u.origin;
|
|
39
|
+
}
|
|
40
|
+
function post(url, secret, body) {
|
|
41
|
+
return new Promise((resolvePromise,reject) => {
|
|
42
|
+
const request = https.request(new URL('/v1/enrollments',url), {method:'POST',headers:{Authorization:`Pairing ${secret}`,'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}, timeout:10000}, response => {
|
|
43
|
+
let size=0;const chunks=[];
|
|
44
|
+
response.on('data',chunk=>{size+=chunk.length;if(size>16384)response.destroy(new Error('invalid_response'));else chunks.push(chunk);});
|
|
45
|
+
response.on('error',()=>reject(Object.assign(new Error('enrollment_transport_error'),{code:'enrollment_transport_error',effect:'unknown'})));
|
|
46
|
+
response.on('end',()=>{
|
|
47
|
+
try { resolvePromise({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks).toString('utf8'))}); }
|
|
48
|
+
catch { reject(Object.assign(new Error('invalid_response'),{code:'invalid_response',effect:'unknown'})); }
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
const deadline=setTimeout(()=>request.destroy(),10000);
|
|
52
|
+
request.on('close',()=>clearTimeout(deadline));
|
|
53
|
+
request.on('timeout',()=>request.destroy());
|
|
54
|
+
request.on('error',()=>reject(Object.assign(new Error('enrollment_transport_error'),{code:'enrollment_transport_error',effect:'unknown'})));
|
|
55
|
+
request.end(body);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Explicit connect only. The optional transport is a test seam, never a user configuration field. */
|
|
59
|
+
export async function connectPairingFile({pairingFile,directory,clientType,brokerUrl}, {transport = post} = {}) {
|
|
60
|
+
let lock, lockPath;
|
|
61
|
+
try {
|
|
62
|
+
if (!TYPES.includes(clientType)) fail('invalid_client_type');
|
|
63
|
+
const pair = JSON.parse(privateRead(pairingFile));
|
|
64
|
+
if (!pair || pair.format !== 'psst-pairing-v1' || pair.client_type !== clientType || typeof pair.pairing_secret !== 'string'
|
|
65
|
+
|| !/^[A-Za-z0-9_-]{43}$/.test(pair.pairing_secret) || !Number.isFinite(Date.parse(pair.expires_at))) fail('invalid_pairing_file');
|
|
66
|
+
const broker = origin(pair.broker_url);
|
|
67
|
+
if (brokerUrl !== undefined && origin(brokerUrl) !== broker) fail('broker_origin_mismatch');
|
|
68
|
+
privateDir(directory);
|
|
69
|
+
lockPath = join(directory,'.enrollment-owner.sqlite');
|
|
70
|
+
// Kernel-held SQLite lock survives no process crash; no stale PID or lock deletion.
|
|
71
|
+
try { privateRead(lockPath); } catch(e) { if(e.code!=='ENOENT')throw e; }
|
|
72
|
+
const ownerFd=openSync(lockPath,constants.O_WRONLY|constants.O_CREAT|constants.O_NOFOLLOW,0o600);closeSync(ownerFd);
|
|
73
|
+
try {
|
|
74
|
+
lock = new DatabaseSync(lockPath);
|
|
75
|
+
lock.exec('PRAGMA journal_mode=DELETE; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner (id INTEGER);');
|
|
76
|
+
} catch { try{lock?.close();}catch{}lock=undefined;fail('enrollment_busy'); }
|
|
77
|
+
// Per-grant attempts preserve old credentials for uncertain recovery and explicit replacement.
|
|
78
|
+
const attempts = join(directory,'enrollments');privateDir(attempts);
|
|
79
|
+
const key = hash(pair.pairing_secret), attemptDir = join(attempts,key);
|
|
80
|
+
if (!readdirSync(attempts).includes(key) && readdirSync(attempts).length >= 64) fail('enrollment_capacity');
|
|
81
|
+
privateDir(attemptDir);
|
|
82
|
+
const stateFile = join(attemptDir,'attempt.json');
|
|
83
|
+
let state;
|
|
84
|
+
try { state = JSON.parse(privateRead(stateFile)); }
|
|
85
|
+
catch(e) {
|
|
86
|
+
if(e.code !== 'ENOENT')throw e;
|
|
87
|
+
state = {broker_url:broker,client_type:clientType,enrollment_id:randomUUID(),token:randomBytes(32).toString('base64url')};
|
|
88
|
+
save(stateFile,JSON.stringify(state)+'\n'); // fsync before any issuance can happen
|
|
89
|
+
}
|
|
90
|
+
if (state.broker_url !== broker || state.client_type !== clientType || !/^[A-Za-z0-9_-]{43}$/.test(state.token)
|
|
91
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(state.enrollment_id)) fail('invalid_enrollment_state');
|
|
92
|
+
const response = await transport(broker,pair.pairing_secret,JSON.stringify({enrollment_id:state.enrollment_id,client_type:clientType,token_sha256:hash(state.token)}));
|
|
93
|
+
if (![200,201].includes(response.status)) {
|
|
94
|
+
const code = response.body?.error?.code;
|
|
95
|
+
fail(['unauthenticated','forbidden','invalid_request','idempotency_conflict','rate_limited','not_ready','storage_unavailable'].includes(code)?code:'invalid_response',response.body?.error?.effect==='not_applied'?'not_applied':'unknown');
|
|
96
|
+
}
|
|
97
|
+
const result = response.body;
|
|
98
|
+
if (!result || result.protocol_version !== '1' || result.client_type !== clientType || !/^enrolled-[0-9a-f-]{36}$/.test(result.credential_id)
|
|
99
|
+
|| !/^installation-[0-9a-f-]{36}$/.test(result.adapter_id) || !Array.isArray(result.scopes) || !result.scopes.length || result.scopes.length>32
|
|
100
|
+
|| result.scopes.some(s=>typeof s!=='string'||!/^[-a-zA-Z0-9_.]{1,128}$/.test(s)) || !Number.isFinite(Date.parse(result.expires_at))) fail('invalid_response','unknown');
|
|
101
|
+
if(Date.parse(result.expires_at)<=Date.now())fail('credential_expired','unknown');
|
|
102
|
+
save(join(attemptDir,'token'),state.token+'\n');
|
|
103
|
+
const bundle = {format:'broker-client-bundle-v1',protocol_version:'1',broker_url:broker,token_file:'token',enrollment_receipt_file:'enrollment.json',file_paths_relative_to:'connection_file_directory',authorization_header:'Authorization',authorization_scheme:'Bearer',insecure_development:false,allowed_client_types:[clientType],scope:result.scopes[0]};
|
|
104
|
+
const connectionFile = join(attemptDir,'connection.json');
|
|
105
|
+
// Publish receipt before bundle: the returned bundle always has its token-bound receipt.
|
|
106
|
+
save(join(attemptDir,'enrollment.json'),JSON.stringify({format:'psst-enrollment-receipt-v1',broker_url:broker,...result,token_sha256:hash(state.token)})+'\n');
|
|
107
|
+
save(connectionFile,JSON.stringify(bundle,null,2)+'\n');
|
|
108
|
+
return {connectionFile,credentialId:result.credential_id,expiresAt:result.expires_at};
|
|
109
|
+
} catch(e) {
|
|
110
|
+
if (e.effect && /^[a-z_]+$/.test(e.code)) throw e;
|
|
111
|
+
fail('enrollment_local_error','unknown');
|
|
112
|
+
} finally {
|
|
113
|
+
if (lock !== undefined) { try { lock.close(); } catch { /* Never replace a sanitized result with raw filesystem details. */ } }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read only for a helper-enrolled bundle. Missing/corrupt receipt is not a legacy fallback. */
|
|
118
|
+
export function readEnrollmentReceipt({connectionFile,clientType}) {
|
|
119
|
+
try {
|
|
120
|
+
if(!TYPES.includes(clientType))fail('invalid_client_type');
|
|
121
|
+
const bundle=JSON.parse(privateRead(connectionFile));
|
|
122
|
+
const receipt=JSON.parse(privateRead(join(dirname(connectionFile),'enrollment.json')));
|
|
123
|
+
const token=privateRead(join(dirname(connectionFile),'token')).trim();
|
|
124
|
+
if(bundle?.format!=='broker-client-bundle-v1'||bundle.protocol_version!=='1'||bundle.token_file!=='token'||bundle.enrollment_receipt_file!=='enrollment.json'
|
|
125
|
+
||bundle.authorization_scheme!=='Bearer'||bundle.authorization_header!=='Authorization'||bundle.insecure_development!==false
|
|
126
|
+
||!Array.isArray(bundle.allowed_client_types)||bundle.allowed_client_types.length!==1||bundle.allowed_client_types[0]!==clientType
|
|
127
|
+
||receipt?.format!=='psst-enrollment-receipt-v1'||receipt.protocol_version!=='1'||receipt.client_type!==clientType
|
|
128
|
+
||receipt.broker_url!==origin(bundle.broker_url)||!/^[A-Za-z0-9_-]{43}$/.test(token)||receipt.token_sha256!==hash(token)
|
|
129
|
+
||!/^enrolled-[0-9a-f-]{36}$/.test(receipt.credential_id)||!/^installation-[0-9a-f-]{36}$/.test(receipt.adapter_id)
|
|
130
|
+
||!Array.isArray(receipt.scopes)||!receipt.scopes.length||receipt.scopes[0]!==bundle.scope||!Number.isFinite(Date.parse(receipt.expires_at)))fail('invalid_enrollment_receipt');
|
|
131
|
+
if(Date.parse(receipt.expires_at)<=Date.now())fail('credential_expired');
|
|
132
|
+
return {credentialId:receipt.credential_id,expiresAt:receipt.expires_at};
|
|
133
|
+
} catch(e) {
|
|
134
|
+
if(e.effect&&/^[a-z_]+$/.test(e.code))throw e;
|
|
135
|
+
fail('invalid_enrollment_receipt');
|
|
136
|
+
}
|
|
137
|
+
}
|