@matuto-ai/pi-broker-connector 0.2.0 → 0.3.1
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/README.md +91 -12
- package/package.json +44 -9
- package/src/config-path.mjs +28 -42
- package/src/config.mjs +4 -2
- package/src/connect-config.mjs +8 -3
- package/src/extension.mjs +32 -7
- package/src/native-setup.mjs +31 -0
- package/src/private-input.mjs +37 -0
- package/src/storage.mjs +35 -0
- package/vendor/broker/ENROLLMENT_UPSTREAM.json +3 -3
- package/vendor/broker/reference/enrollment.mjs +12 -3
package/README.md
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
A native Pi extension providing durable, session-bound messaging through the [Psst broker](https://github.com/matuto-ai/psst). Runs **inside the Pi process in your existing sandbox**. No host launcher, local model, provider subscription, or additional service is required by the connector.
|
|
4
4
|
|
|
5
|
-
**Version 0.
|
|
5
|
+
**Version 0.3.1:** native private setup, persistent installation selection and automatic reconnection of independent Pi sessions, retaining automatic native delivery and transport-auth pause protection. Tested with the official Pi 1.4.0 SDK and a local fixture model; live authenticated user-sandbox acceptance remains a separate step.
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- Node **24.19+ within major 24** and Pi **1.4.0** (the tested host version).
|
|
10
|
-
- A reachable HTTPS Psst
|
|
10
|
+
- A reachable HTTPS Psst broker **0.3.0 with pairing enabled**, and one operator-approved **Pi-specific one-time pairing secret** per installation/scope. `/healthz` success or the broker URL alone does not authorize access. Existing connection bundles remain a compatibility option.
|
|
11
11
|
- Writable local persistent storage, not a shared/NFS directory. Preserve it across sandbox recreation if you want to resume the same Pi session.
|
|
12
12
|
|
|
13
13
|
The connector vendors the broker's dependency-free client; it does not fetch broker code at runtime. Pi supplies `typebox` and its host API as peer dependencies.
|
|
@@ -33,12 +33,40 @@ This uses the default branch. To pin a reviewed commit, append `@` and its exact
|
|
|
33
33
|
### From npm (only after publication)
|
|
34
34
|
|
|
35
35
|
```sh
|
|
36
|
-
pi install npm:@matuto-ai/pi-broker-connector@0.
|
|
36
|
+
pi install npm:@matuto-ai/pi-broker-connector@0.3.1
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
The command requires the version to be present on npm. If publication is still pending, use a reviewed checkout instead.
|
|
40
40
|
|
|
41
|
-
### Connect
|
|
41
|
+
### Connect once inside Pi — no bundle to copy
|
|
42
|
+
|
|
43
|
+
After installing/updating, use `/reload` in the running Pi, then run
|
|
44
|
+
`/psst-setup` **without arguments** inside Pi. It asks for the HTTPS broker
|
|
45
|
+
URL, explicit authorization confirmation, then an operator-issued one-time secret
|
|
46
|
+
in a focused **masked native field**. Never put that secret in the command, chat,
|
|
47
|
+
a tool argument or ordinary Pi editor. The connector generates its own connection
|
|
48
|
+
credential and keeps the generated files internal; you do not copy a bundle or
|
|
49
|
+
ticket file into the sandbox. This requires a broker with scoped pairing enabled
|
|
50
|
+
and an operator-approved grant for this installation/type/scope. A URL alone is
|
|
51
|
+
not authorization. The initial operator grant and secure handoff remain necessary;
|
|
52
|
+
this is not anonymous enrollment or a completed operator-provisioning UI.
|
|
53
|
+
|
|
54
|
+
The nonsecret URL is remembered in Pi's agent directory. `PSST_BROKER_URL` is an
|
|
55
|
+
optional explicit URL override; an origin mismatch fails instead of silently
|
|
56
|
+
sending a saved credential elsewhere. After successful setup, new native sessions
|
|
57
|
+
automatically use the saved credential, with independent session identities and
|
|
58
|
+
mailboxes. No setup command, environment edit or re-enrollment is needed for each
|
|
59
|
+
session sharing that persisted agent directory. Different project/profile mounts
|
|
60
|
+
are different installations. Authentication failure or credential expiry never
|
|
61
|
+
uses a leftover pairing secret to mint another identity; explicitly authorize a
|
|
62
|
+
replacement when required. Repeating setup with a connected session asks for
|
|
63
|
+
replacement confirmation and retains the old mailbox.
|
|
64
|
+
|
|
65
|
+
The memory-input helper is pinned to merged source in [broker PR20](https://github.com/matuto-ai/psst/pull/20). This does not deploy or enable pairing on your broker. Native input is interactive-only;
|
|
66
|
+
RPC/headless contexts cannot fall back to secret text in chat. `/psst-connect` with
|
|
67
|
+
an existing private file remains a compatibility option, not the new setup route.
|
|
68
|
+
|
|
69
|
+
### Compatibility: connect with an existing private file
|
|
42
70
|
|
|
43
71
|
With this version loaded, run one command **in the current Pi session**:
|
|
44
72
|
|
|
@@ -46,23 +74,23 @@ With this version loaded, run one command **in the current Pi session**:
|
|
|
46
74
|
/psst-connect /absolute/path/to/connection.json
|
|
47
75
|
```
|
|
48
76
|
|
|
49
|
-
Use the existing bundle path; keep its `token` next to `connection.json`. After connecting, background transport receives messages and the extension automatically delivers them to this Pi session at an idle boundary. `broker_list_clients` lists authorized recipients; `broker_send` sends to an exact client ID. Do not copy the JSON alone. The command connects the current native session and saves
|
|
77
|
+
Use the existing bundle path; keep its `token` next to `connection.json`. After connecting, background transport receives messages and the extension automatically delivers them to this Pi session at an idle boundary. `broker_list_clients` lists authorized recipients; `broker_send` sends to an exact client ID. Do not copy the JSON alone. The command connects the current native session and saves a private installation selection in `${PI_CODING_AGENT_DIR:-~/.pi/agent}/psst/selection.sqlite`. This selection contains a path and nonsecret metadata, not the token. `/reload` reads it again. Old private `pi-config-path` TXT selections remain a read-only compatibility fallback when no new selection exists.
|
|
50
78
|
|
|
51
79
|
Install or update **inside the same sandbox**, then use `/reload` in the running Pi:
|
|
52
80
|
|
|
53
81
|
```sh
|
|
54
|
-
pi install npm:@matuto-ai/pi-broker-connector@0.
|
|
82
|
+
pi install npm:@matuto-ai/pi-broker-connector@0.3.1
|
|
55
83
|
```
|
|
56
84
|
|
|
57
85
|
This installer command is not a second agent session. No sandbox recreation or Pi process restart is required. Version 0.1.1 does not have `/psst-connect`; 0.1.2 adds that command, while automatic delivery requires 0.2.0 or later.
|
|
58
86
|
|
|
59
87
|
Keep `connection.json` and `token` private to the sandbox user (`0600`), directory `0700`. Never paste tokens into prompts, command arguments, Git, or shared config. Paths must refer to real files rather than symlinks. A `/tmp` bundle works for the current container but may disappear on container removal; remembering its path does not make the bundle persistent.
|
|
60
88
|
|
|
61
|
-
Existing `PSST_CONFIG_FILE` and `PSST_CONFIG` configurations remain supported: on session start/reload, precedence is `PSST_CONFIG_FILE`, then `PSST_CONFIG`, then the saved
|
|
89
|
+
Existing `PSST_CONFIG_FILE` and `PSST_CONFIG` configurations remain supported: on session start/reload, precedence is `PSST_CONFIG_FILE`, then `PSST_CONFIG`, then the saved installation selection. `/psst-connect` explicitly selects the path for the current instance; it warns if inherited environment settings will take precedence on the next reload. `PSST_NAME` defaults to `Pi`; `PSST_STATE_DIR` defaults to `${PI_CODING_AGENT_DIR:-~/.pi/agent}/psst` for new installations. `/psst-status` displays status and the session's exact client ID. Repeated connect closes the previous runtime before reopening the same session inbox; invalid input leaves the current connection untouched. Failed new connections leave the previous saved selection unchanged. Successful explicit connection updates use compare-and-swap so concurrent changes are not silently overwritten.
|
|
62
90
|
|
|
63
|
-
###
|
|
91
|
+
### Compatibility: operator-provided pairing files
|
|
64
92
|
|
|
65
|
-
**Broker dependency:** pairing requires the enrollment API
|
|
93
|
+
**Broker dependency:** pairing requires the opt-in enrollment API included in broker 0.3.0. This connector vendors the helper at commit `4a9d604e1107a92c94f8b8a2f0e1cb2d1e2ecc39`; the helper runs locally inside Pi, not on the server. Vendoring the helper does not deploy or enable the broker endpoint. Legacy bundles remain usable without that endpoint.
|
|
66
94
|
|
|
67
95
|
When an operator provides a private **pairing file**, use its path instead of an existing bundle:
|
|
68
96
|
|
|
@@ -72,7 +100,7 @@ When an operator provides a private **pairing file**, use its path instead of an
|
|
|
72
100
|
|
|
73
101
|
The extension recognizes `psst-pairing-v1`, generates a unique installation credential locally, saves retry state before contacting `/v1/enrollments`, and remembers the resulting ordinary connection bundle. The pairing secret is read only from the private file: never paste it into a command, prompt, URL, or model tool. No environment variable, new agent, or sandbox restart is needed. Existing connection bundles still work unchanged.
|
|
74
102
|
|
|
75
|
-
|
|
103
|
+
New enrollment files live privately under the native agent directory's `psst/installation-credentials/`. Explicit `PSST_STATE_DIR` uses its `installations/` directory. Existing legacy file-pairing attempts retain their original retry directory. Uncertain responses reuse the same durable enrollment request and token when you repeat the command; they do not silently allocate another identity. An expired/revoked credential requires a newly approved secret through `/psst-setup` (or a new compatibility pairing file), never automatic credential minting. Pairing uses normal verified HTTPS; private-CA override is not currently supported by the shared pairing helper (legacy bundles still support their existing CA option).
|
|
76
104
|
|
|
77
105
|
Each installation credential is separate from each native Pi session identity. The private enrollment receipt is verified against the broker, client type and token before it can bind local state. Reload of the same native session and enrollment resumes its mailbox; a fresh pairing credential creates a separate mailbox and retains old state rather than adopting it. Legacy bundles without an enrollment receipt retain their original session binding. Pairing completion after a session switch/shutdown cannot attach to the departed session.
|
|
78
106
|
|
|
@@ -132,7 +160,7 @@ Broker `delivered` means committed to the receiver's local durable inbox. `broke
|
|
|
132
160
|
|
|
133
161
|
Each native Pi session has its own durable identity, bound to the endpoint and Pi session ID. `/new` gets a separate mailbox. `/reload`, process restart, or `/resume` of the same Pi session can reconnect its identity; no old inbox is silently adopted by another session. Shutdown stops background tasks and releases the local database lock. It leaves the broker lease to expire rather than permanently closing the identity, so reload/resume remains possible. Concurrent ownership of the same local inbox fails closed.
|
|
134
162
|
|
|
135
|
-
No settings, credentials, enrollment, provider routing, Nginx rules, or host installations are changed by installing this package. Without a
|
|
163
|
+
No settings, credentials, enrollment, provider routing, Nginx rules, or host installations are changed by installing this package. Without a selected credential the extension stays disconnected until native setup. A failed startup is surfaced as a sanitized diagnostic; check network/permissions and use `/reload` to retry the saved credential. Do not re-pair merely to hide an uncertain transport result.
|
|
136
164
|
|
|
137
165
|
## Development and verification
|
|
138
166
|
|
|
@@ -149,7 +177,7 @@ Tests cover private bundle validation, exact session isolation, duplicate owners
|
|
|
149
177
|
|
|
150
178
|
## Publishing
|
|
151
179
|
|
|
152
|
-
This release uses package version `0.
|
|
180
|
+
This release uses package version `0.3.1` and matching GitHub release tag `v0.3.1`. The release workflow publishes the package only after its checks pass.
|
|
153
181
|
|
|
154
182
|
The release workflow follows the existing Pi plugin convention: a published GitHub release triggers checks and npm publishing. It requires a `v<package.version>` tag and runs the native host gate before publishing. Nothing is published by CI on pull requests or ordinary pushes.
|
|
155
183
|
|
|
@@ -165,3 +193,54 @@ pi remove /absolute/path/to/pi-broker-connector
|
|
|
165
193
|
```
|
|
166
194
|
|
|
167
195
|
Restart/reload Pi. State and private enrollment files are deliberately retained for recovery; remove them only when you intend to abandon that mailbox.
|
|
196
|
+
|
|
197
|
+
## Persistent storage across native sessions
|
|
198
|
+
|
|
199
|
+
New installations keep enrollment in `installation-credentials/` and independent
|
|
200
|
+
session journals under
|
|
201
|
+
`PI_CODING_AGENT_DIR/psst` (default `~/.pi/agent/psst`). The remembered selection
|
|
202
|
+
is one private SQLite record containing the connection path, broker origin and
|
|
203
|
+
revision. Explicit setup commits it only after successful current-session connect;
|
|
204
|
+
compare-and-swap prevents concurrent setups overwriting a newer selection. This follows the existing
|
|
205
|
+
Pi agent-directory mount in a sandbox; different projects/profiles with different
|
|
206
|
+
agent directories remain separate installations. No host or launcher change is made.
|
|
207
|
+
An explicit `PSST_STATE_DIR` continues to select the inbox/enrollment root.
|
|
208
|
+
|
|
209
|
+
Existing `~/.local/state/psst/pi` is deliberately preserved, not silently copied
|
|
210
|
+
or moved: old running plugins do not participate in a new migration lock. A
|
|
211
|
+
legacy-storage warning identifies this compatibility mode for inboxes; new
|
|
212
|
+
native setup credentials still go to the persisted agent directory, not that
|
|
213
|
+
legacy inbox root (unless PSST_STATE_DIR explicitly overrides storage). It does **not** mean
|
|
214
|
+
the old path has become durable across sandbox recreation. The old
|
|
215
|
+
`~/.config/psst/pi-config-path` and native `pi-config-path` TXT are still read when
|
|
216
|
+
no SQLite selection exists; writing a new selection leaves those files untouched.
|
|
217
|
+
A native selection wins once explicitly saved, but invalid native selections
|
|
218
|
+
never fall back to old credentials. If both legacy and native roots contain state,
|
|
219
|
+
startup fails with `storage_migration_required` rather than choosing one history.
|
|
220
|
+
Selection database/journal and known private atomic-selection temporaries are
|
|
221
|
+
metadata, not another inbox history; an empty legacy root is conservatively retained.
|
|
222
|
+
An explicit state override can choose the intended existing root. Coordinated
|
|
223
|
+
offline migration remains an operator recovery task; preserve all old state.
|
|
224
|
+
|
|
225
|
+
Each native session has its own identity, inbox, outgoing retry ledger and owner
|
|
226
|
+
lock. Reopening the same session retains those records; a different session never
|
|
227
|
+
adopts them. This storage change does not authorize first enrollment, mint another
|
|
228
|
+
credential on startup, or remove the current pairing-file onboarding requirement.
|
|
229
|
+
|
|
230
|
+
Developer-only native setup proof (disposable CA/grants; Python3/PTY and OpenSSL):
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
PI_TEST_INSTALL=/path/to/official-pi-1.4.0 \
|
|
234
|
+
PSST_TEST_BROKER_ROOT=/path/to/reviewed-psst \
|
|
235
|
+
node scripts/native-setup-tls.probe.mjs
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
This opens the actual Pi UI, enters only a synthetic grant through the masked
|
|
239
|
+
component, connects to a real fixture HTTPS broker, and recreates HOME while
|
|
240
|
+
preserving the agent directory. It asserts one credential and two distinct
|
|
241
|
+
sessions without a second setup. No model turn or authenticated user-sandbox
|
|
242
|
+
acceptance is claimed. The broker checkout must include its enrollment TLS fixture.
|
|
243
|
+
|
|
244
|
+
### Masked-field keyboard regression tests
|
|
245
|
+
|
|
246
|
+
`npm test` requires Python 3 on Linux for the real POSIX PTY keyboard tests. They use only synthetic input and the pinned official Pi TUI 1.4.0 development dependency. Runtime imports resolve through Pi’s native extension loader; no terminal keyboard mode is changed by the connector itself. Legacy text/paste and Kitty printable input are supported, while control keys use the public host decoder. This does not claim complete xterm `modifyOtherKeys` printable-key support.
|
package/package.json
CHANGED
|
@@ -1,15 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matuto-ai/pi-broker-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Session-bound Psst broker messaging tools for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/matuto-ai/pi-broker-connector.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=24.19.0 <25"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"pi-package",
|
|
16
|
+
"pi",
|
|
17
|
+
"psst",
|
|
18
|
+
"broker"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"extensions/",
|
|
22
|
+
"src/",
|
|
23
|
+
"vendor/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./extensions/psst.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"typebox": "*",
|
|
35
|
+
"@earendil-works/pi-tui": "^1.4.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test test/*.test.mjs",
|
|
39
|
+
"check": "node scripts/check.mjs",
|
|
40
|
+
"test:host": "node --test test/native-host.probe.mjs",
|
|
41
|
+
"prepack": "npm run check && npm test"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-tui": "1.4.0"
|
|
49
|
+
}
|
|
15
50
|
}
|
package/src/config-path.mjs
CHANGED
|
@@ -1,43 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
write(path) {
|
|
30
|
-
validateConfigPath(path);
|
|
31
|
-
let temporary;
|
|
32
|
-
try {
|
|
33
|
-
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
34
|
-
const stat = lstatSync(directory);
|
|
35
|
-
if (realpathSync(directory) !== directory || !stat.isDirectory() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid())) throw new Error();
|
|
36
|
-
temporary = join(directory, `.pi-config-path-${randomUUID()}`);
|
|
37
|
-
writeFileSync(temporary, `${path}\n`, { mode: 0o600, flag: 'wx' });
|
|
38
|
-
renameSync(temporary, file);
|
|
39
|
-
} catch { throw new LocalError('invalid_configuration'); }
|
|
40
|
-
finally { if (temporary) { try { unlinkSync(temporary); } catch {} } }
|
|
41
|
-
},
|
|
42
|
-
};
|
|
1
|
+
import {constants,openSync,closeSync,fstatSync,readFileSync,realpathSync,mkdirSync,lstatSync,chmodSync} from 'node:fs';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import {isAbsolute,join} from 'node:path';
|
|
4
|
+
import {randomUUID,createHash} from 'node:crypto';
|
|
5
|
+
import {DatabaseSync} from 'node:sqlite';
|
|
6
|
+
import {storageLayout} from './storage.mjs';
|
|
7
|
+
import {LocalError} from '../vendor/broker/reference/errors.mjs';
|
|
8
|
+
export function validateConfigPath(value){if(typeof value!=='string'||!isAbsolute(value)||value.length>4096||/[\x00-\x1f\x7f]/.test(value))throw new LocalError('invalid_configuration');return value;}
|
|
9
|
+
function privateFile(file){if(realpathSync(file)!==file)throw Error();const fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW);try{const s=fstatSync(fd);if(!s.isFile()||(s.mode&0o077)||(process.getuid&&s.uid!==process.getuid()))throw Error();}finally{closeSync(fd);}}
|
|
10
|
+
export function createConfigPathStore(home=homedir(),env=process.env){
|
|
11
|
+
const {selectionDirectory:directory,legacySelection}=storageLayout(env,home),file=join(directory,'selection.sqlite'),previous=join(directory,'pi-config-path');
|
|
12
|
+
function legacy(){for(const candidate of [previous,legacySelection]){try{lstatSync(candidate);}catch(e){if(e.code==='ENOENT')continue;throw e;}privateFile(candidate);if(lstatSync(candidate).size>4097)throw Error();const path=validateConfigPath(readFileSync(candidate,'utf8').replace(/\n$/,''));return{connectionFile:path,brokerUrl:null,revision:'legacy-'+createHash('sha256').update(path).digest('hex')};}return{connectionFile:null,brokerUrl:null,revision:'empty'};}
|
|
13
|
+
function readDatabase(db){const row=db.prepare('SELECT value FROM selection WHERE id=1').get();if(!row)return legacy();const record=JSON.parse(row.value);validateConfigPath(record.connectionFile);if(typeof record.revision!=='string'||!/^[-a-z0-9]+$/.test(record.revision)||!(record.brokerUrl===null||typeof record.brokerUrl==='string'))throw Error();return record;}
|
|
14
|
+
function read(){let db;try{try{lstatSync(file);}catch(e){if(e.code==='ENOENT')return legacy();throw e;}privateFile(file);db=new DatabaseSync(file,{readOnly:true});return readDatabase(db);}catch{throw new LocalError('invalid_configuration');}finally{try{db?.close();}catch{}}}
|
|
15
|
+
return{
|
|
16
|
+
read(){return read().connectionFile;},snapshot:read,
|
|
17
|
+
write(path,brokerUrl=null,expectedRevision){
|
|
18
|
+
validateConfigPath(path);let db;
|
|
19
|
+
try{
|
|
20
|
+
mkdirSync(directory,{recursive:true,mode:0o700});const stat=lstatSync(directory);if(realpathSync(directory)!==directory||!stat.isDirectory()||(stat.mode&0o077)||(process.getuid&&stat.uid!==process.getuid()))throw Error();
|
|
21
|
+
try{privateFile(file);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
22
|
+
const fd=openSync(file,constants.O_CREAT|constants.O_WRONLY|constants.O_NOFOLLOW,0o600);closeSync(fd);chmodSync(file,0o600);
|
|
23
|
+
db=new DatabaseSync(file);db.exec('PRAGMA busy_timeout=2000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS selection(id INTEGER PRIMARY KEY CHECK(id=1),value TEXT NOT NULL);');
|
|
24
|
+
const current=readDatabase(db);if(expectedRevision!==undefined&¤t.revision!==expectedRevision){db.exec('ROLLBACK');return false;}
|
|
25
|
+
const value=JSON.stringify({connectionFile:path,brokerUrl,revision:randomUUID()});db.prepare('INSERT INTO selection(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value').run(value);db.exec('COMMIT');return true;
|
|
26
|
+
}catch{throw new LocalError('invalid_configuration');}finally{try{db?.close();}catch{}}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
43
29
|
}
|
package/src/config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readEnrollmentReceipt } from '../vendor/broker/reference/enrollment.mjs';
|
|
2
2
|
import { openSync, closeSync, fstatSync, readFileSync, realpathSync, lstatSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, dirname, resolve, basename } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { storageLayout } from './storage.mjs';
|
|
5
5
|
import { constants } from 'node:fs';
|
|
6
6
|
import { LocalError } from '../vendor/broker/reference/errors.mjs';
|
|
7
7
|
|
|
@@ -39,9 +39,11 @@ export function readConfig(env = process.env, explicitPath) {
|
|
|
39
39
|
try { credentialId = readEnrollmentReceipt({connectionFile:path,clientType:'pi'}).credentialId; }
|
|
40
40
|
catch (error) { throw new LocalError(error?.code === 'credential_expired' ? 'credential_expired' : 'invalid_configuration'); }
|
|
41
41
|
}
|
|
42
|
+
const configuredBroker=env.PSST_BROKER_URL;
|
|
43
|
+
if(configuredBroker){let expected;try{expected=new URL(configuredBroker);}catch{throw new LocalError('invalid_configuration');}if(expected.protocol!=='https:'||expected.username||expected.password||expected.search||expected.hash||expected.pathname!=='/'||expected.origin!==url.origin)throw new LocalError('broker_origin_mismatch');}
|
|
42
44
|
const name = env.PSST_NAME || 'Pi';
|
|
43
45
|
if (name.length > 128 || /[\x00-\x1f\x7f]/.test(name)) throw new LocalError('invalid_configuration');
|
|
44
46
|
return { url: url.href, ...(credentialId ? {credentialId} : {}), tokenFile: resolve(dirname(path), raw.token_file),
|
|
45
47
|
...(raw.ca_file ? { caFile: resolve(dirname(path), raw.ca_file) } : {}),
|
|
46
|
-
|
|
48
|
+
...storageLayout(env), name };
|
|
47
49
|
}
|
package/src/connect-config.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LocalError } from '../vendor/broker/reference/errors.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { storageLayout } from './storage.mjs';
|
|
3
3
|
import { resolve, join } from 'node:path';
|
|
4
|
+
import {createHash} from 'node:crypto';
|
|
5
|
+
import {lstatSync} from 'node:fs';
|
|
4
6
|
import { readPrivateJson } from './config.mjs';
|
|
5
7
|
import { validateConfigPath } from './config-path.mjs';
|
|
6
8
|
|
|
@@ -12,9 +14,12 @@ export async function resolveConnectionFile(file, { env = process.env,
|
|
|
12
14
|
validateConfigPath(file);
|
|
13
15
|
const raw = readPrivateJson(file);
|
|
14
16
|
if (raw.format !== 'psst-pairing-v1') return file;
|
|
15
|
-
const
|
|
17
|
+
const layout=storageLayout(env);let directory=layout.installationDirectory;
|
|
18
|
+
// Only an already-persisted legacy attempt keeps its original retry identity.
|
|
19
|
+
const previous=join(layout.stateDirectory,'installations');
|
|
20
|
+
if(previous!==directory&&typeof raw.pairing_secret==='string'){try{lstatSync(join(previous,'enrollments',createHash('sha256').update(raw.pairing_secret).digest('hex'),'attempt.json'));directory=previous;}catch(e){if(e.code!=='ENOENT')throw new LocalError('invalid_configuration');}}
|
|
16
21
|
try {
|
|
17
|
-
const result = await pairingConnector({pairingFile:file,directory
|
|
22
|
+
const result = await pairingConnector({pairingFile:file,directory,clientType:'pi'});
|
|
18
23
|
return validateConfigPath(result.connectionFile);
|
|
19
24
|
} catch (error) {
|
|
20
25
|
const codes = ['credential_expired','unauthenticated','forbidden','idempotency_conflict','rate_limited','enrollment_busy','enrollment_capacity','enrollment_transport_error','invalid_pairing_file','invalid_enrollment_receipt'];
|
package/src/extension.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {nativeSetup} from './native-setup.mjs';
|
|
1
2
|
import { resolveConnectionFile } from './connect-config.mjs';
|
|
2
3
|
import { NativeDelivery } from './native-delivery.mjs';
|
|
3
4
|
import { createConfigPathStore, validateConfigPath } from './config-path.mjs';
|
|
@@ -7,7 +8,7 @@ import { createCooperativeInbox } from './cooperative-inbox.mjs';
|
|
|
7
8
|
import { createTools, invokeTool } from '../vendor/broker/reference/tools.mjs';
|
|
8
9
|
import { LocalError, safeError } from '../vendor/broker/reference/errors.mjs';
|
|
9
10
|
|
|
10
|
-
export function installExtension(pi, Type, { configReader = readConfig, pathStore = createConfigPathStore(
|
|
11
|
+
export function installExtension(pi, Type, { configReader = readConfig, env = process.env, pathStore = createConfigPathStore(undefined, env), connectionResolver = resolveConnectionFile, createSession = options => new PiBrokerSession(options), setupConnector = options => import('../vendor/broker/reference/enrollment.mjs').then(m=>m.connectPairingSecret(options)), setupUI = nativeSetup } = {}) {
|
|
11
12
|
let owner = null, facade = null, delivery = null, ready = false, status = 'not_configured';
|
|
12
13
|
let generation = 0, operations = Promise.resolve();
|
|
13
14
|
const schedule = operation => {
|
|
@@ -30,6 +31,8 @@ export function installExtension(pi, Type, { configReader = readConfig, pathStor
|
|
|
30
31
|
try {
|
|
31
32
|
const config = selectedConfig || configReader(env, env.PSST_CONFIG_FILE || env.PSST_CONFIG || pathStore.read());
|
|
32
33
|
if (!config) { status = 'not_configured'; setStatus(ctx); return; }
|
|
34
|
+
if(!selectedConfig&&!env.PSST_CONFIG_FILE&&!env.PSST_CONFIG){const saved=pathStore.snapshot?.();if(saved?.brokerUrl&&new URL(config.url).origin!==saved.brokerUrl)throw new LocalError('broker_origin_mismatch');}
|
|
35
|
+
if (config.legacyStorage) notify(ctx, 'Psst is preserving existing legacy inbox storage. It has not been moved into Pi persistent storage; keep that directory until a coordinated offline migration.', 'warning');
|
|
33
36
|
const hostSessionId = ctx.sessionManager.getSessionId();
|
|
34
37
|
const current = createSession({ config, hostSessionId,
|
|
35
38
|
currentSessionId: () => ctx.sessionManager.getSessionId(),
|
|
@@ -51,23 +54,45 @@ export function installExtension(pi, Type, { configReader = readConfig, pathStor
|
|
|
51
54
|
delivery?.stop(); delivery = null;
|
|
52
55
|
const prior = owner; owner = null; facade = null; ready = false;
|
|
53
56
|
if (prior) await prior.close();
|
|
54
|
-
setStatus(ctx); notify(ctx, `Psst could not connect (${status}).
|
|
57
|
+
setStatus(ctx); notify(ctx, `Psst could not connect (${status}). Use /psst-status and /reload to retry the saved credential; authorize replacement setup only when needed.`, 'warning');
|
|
55
58
|
}
|
|
56
59
|
};
|
|
57
60
|
pi.on('session_start', async (_event, ctx) => schedule(epoch => connect(ctx, epoch)));
|
|
58
61
|
pi.on('session_shutdown', async () => schedule(async () => { await stop(); status = 'stopped'; }));
|
|
62
|
+
pi.registerCommand('psst-setup', {
|
|
63
|
+
description: 'Configure broker URL and authorize this installation using native private input',
|
|
64
|
+
handler: async (args, ctx) => {
|
|
65
|
+
if(args.trim()){notify(ctx,'Use /psst-setup without arguments. Never put a secret in a command.','warning');return;}
|
|
66
|
+
try {
|
|
67
|
+
const initial=generation,nativeId=ctx.sessionManager.getSessionId(),revision=pathStore.snapshot?.().revision;
|
|
68
|
+
if(ready&&!await ctx.ui.confirm('Replace Psst installation credential?', 'This creates a new credential/mailbox partition. Existing pending messages remain in their old inbox. Continue only with an explicitly approved new pairing secret.'))return;
|
|
69
|
+
if(initial!==generation||ctx.sessionManager.getSessionId()!==nativeId)throw new LocalError('session_mismatch');
|
|
70
|
+
const selected=await setupUI(ctx,{env,pairingConnector:setupConnector,stillCurrent:()=>initial===generation&&ctx.sessionManager.getSessionId()===nativeId});
|
|
71
|
+
if(!selected)return;
|
|
72
|
+
const path=selected.connectionFile;
|
|
73
|
+
if(initial!==generation||ctx.sessionManager.getSessionId()!==nativeId)throw new LocalError('session_mismatch');
|
|
74
|
+
const config=configReader(env,path);
|
|
75
|
+
if(new URL(config.url).origin!==selected.brokerUrl)throw new LocalError('broker_origin_mismatch');
|
|
76
|
+
const task=schedule(epoch=>connect(ctx,epoch,config,true)),expected=generation;await task;
|
|
77
|
+
if(!ready||generation!==expected||ctx.sessionManager.getSessionId()!==nativeId)return;
|
|
78
|
+
const saved=pathStore.write(path,selected.brokerUrl,revision);
|
|
79
|
+
notify(ctx,saved===false?'Psst connected for this session. Another setup changed the saved selection; future-session selection was not overwritten.':'Psst installation connected. Future native sessions use the saved credential.');
|
|
80
|
+
} catch {notify(ctx,'Psst setup did not complete. No secret was displayed; keep local retry state and retry explicitly.','warning');}
|
|
81
|
+
},
|
|
82
|
+
});
|
|
59
83
|
pi.registerCommand('psst-connect', {
|
|
60
84
|
description: 'Connect this Pi session using an absolute connection.json path; remember only the path',
|
|
61
85
|
handler: async (args, ctx) => {
|
|
62
86
|
try {
|
|
63
|
-
const epochBeforePairing = generation, sessionBeforePairing = ctx.sessionManager.getSessionId();
|
|
87
|
+
const epochBeforePairing = generation, sessionBeforePairing = ctx.sessionManager.getSessionId(),revision=pathStore.snapshot?.().revision;
|
|
64
88
|
const path = await connectionResolver(validateConfigPath(args.trim()), {env});
|
|
65
89
|
if (epochBeforePairing !== generation || ctx.sessionManager.getSessionId() !== sessionBeforePairing) throw new LocalError('session_mismatch');
|
|
66
90
|
const config = configReader(env, path);
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
const task=schedule(epoch => connect(ctx, epoch, config, true)),expected=generation;await task;
|
|
92
|
+
if(!ready||generation!==expected||ctx.sessionManager.getSessionId()!==sessionBeforePairing)return;
|
|
93
|
+
const saved=pathStore.write(path,new URL(config.url).origin,revision);
|
|
69
94
|
if (env.PSST_CONFIG_FILE || env.PSST_CONFIG) notify(ctx, 'An existing PSST_CONFIG_FILE/PSST_CONFIG environment setting still takes precedence after /reload.', 'warning');
|
|
70
|
-
notify(ctx,
|
|
95
|
+
notify(ctx,saved===false?'Psst connected for this session. Another setup changed the saved selection; future-session selection was not overwritten.':'Psst connected. Selection saved; no restart needed.');
|
|
71
96
|
} catch (error) { notify(ctx, `Psst could not save configuration (${safeError(error).error?.code || 'invalid_configuration'}). Use /psst-connect /absolute/path/connection.json.`, 'warning'); }
|
|
72
97
|
},
|
|
73
98
|
});
|
|
@@ -88,7 +113,7 @@ export function installExtension(pi, Type, { configReader = readConfig, pathStor
|
|
|
88
113
|
pi.registerCommand('psst-status', {
|
|
89
114
|
description: 'Show this Pi session’s Psst broker status and client ID',
|
|
90
115
|
handler: async (_args, ctx) => {
|
|
91
|
-
notify(ctx, ready && owner ? `Psst ${status}; client ID ${owner.inbox.identity.client_id}; inbox ${owner.inbox.stats().pending_count} pending. Delivery mode: ${delivery?.mode}. Native acceptance is not model read or action success.` : `Psst ${status}. Use /psst-
|
|
116
|
+
notify(ctx, ready && owner ? `Psst ${status}; client ID ${owner.inbox.identity.client_id}; inbox ${owner.inbox.stats().pending_count} pending. Delivery mode: ${delivery?.mode}. Native acceptance is not model read or action success.` : `Psst ${status}. Use /psst-setup for native first-time setup. No restart required.`);
|
|
92
117
|
},
|
|
93
118
|
});
|
|
94
119
|
const empty = Type.Object({}, { additionalProperties: false });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {createConfigPathStore} from './config-path.mjs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {storageLayout} from './storage.mjs';
|
|
4
|
+
import {LocalError} from '../vendor/broker/reference/errors.mjs';
|
|
5
|
+
import {promptPairingSecret} from './private-input.mjs';
|
|
6
|
+
export function brokerOrigin(value) {
|
|
7
|
+
let u;try{u=new URL(value);}catch{throw new LocalError('invalid_configuration');}
|
|
8
|
+
if(typeof value!=='string'||/[\u0000-\u0020\u007f]/.test(value)||u.protocol!=='https:'||u.username||u.password||u.pathname!=='/'||u.search||u.hash)throw new LocalError('invalid_configuration');
|
|
9
|
+
return u.origin;
|
|
10
|
+
}
|
|
11
|
+
export function nativeBrokerSettings(env=process.env) {
|
|
12
|
+
const store=createConfigPathStore(undefined,env);
|
|
13
|
+
return {read(){const url=env.PSST_BROKER_URL||store.snapshot().brokerUrl;return url?brokerOrigin(url):null;}};
|
|
14
|
+
}
|
|
15
|
+
// Called only from an explicit native command, never session_start/auth recovery.
|
|
16
|
+
export async function nativeSetup(ctx,{env=process.env,settings=nativeBrokerSettings(env),privateInput=promptPairingSecret,pairingConnector,stillCurrent=()=>true}={}) {
|
|
17
|
+
if(!ctx?.hasUI)throw new LocalError('native_private_input_unavailable');
|
|
18
|
+
const known=settings.read();
|
|
19
|
+
const entered=await ctx.ui.input('Psst broker HTTPS URL',known||'https://');
|
|
20
|
+
if(entered===undefined||!stillCurrent())return null;
|
|
21
|
+
const brokerUrl=brokerOrigin(entered);
|
|
22
|
+
if(env.PSST_BROKER_URL&&brokerOrigin(env.PSST_BROKER_URL)!==brokerUrl)throw new LocalError('broker_origin_mismatch');
|
|
23
|
+
if(!await ctx.ui.confirm('Psst first-time authorization',`Connect this installation to ${brokerUrl}? The next field accepts a one-time secret privately; it is not a message.`)||!stillCurrent())return null;
|
|
24
|
+
let pairingSecret=await privateInput(ctx);
|
|
25
|
+
if(pairingSecret===undefined||!stillCurrent())return null;
|
|
26
|
+
try {
|
|
27
|
+
const result=await pairingConnector({brokerUrl,pairingSecret,directory:storageLayout(env).installationDirectory,clientType:'pi'});
|
|
28
|
+
if(!stillCurrent())throw new LocalError('session_mismatch');
|
|
29
|
+
return {connectionFile:result.connectionFile,brokerUrl};
|
|
30
|
+
} finally {pairingSecret=undefined;}
|
|
31
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { matchesKey, isKeyRelease, decodeKittyPrintable } from '@earendil-works/pi-tui';
|
|
2
|
+
import { LocalError } from '../vendor/broker/reference/errors.mjs';
|
|
3
|
+
|
|
4
|
+
// A focused native component: the value never enters Pi's editor or a message.
|
|
5
|
+
// Use public host decoders for control keys and Kitty printable input; legacy text/paste stays masked.
|
|
6
|
+
export function maskedPairingComponent({done,requestRender}) {
|
|
7
|
+
let value='',closed=false,pasting=false,paste='',invalidPaste=false,hint=false;
|
|
8
|
+
const finish=result=>{if(closed)return;closed=true;value='';paste='';done(result);};
|
|
9
|
+
const append=data=>{if(/^[A-Za-z0-9_-]+$/.test(data)&&value.length+data.length<=43){value+=data;hint=false;}};
|
|
10
|
+
return {
|
|
11
|
+
render(width){return ['Psst: enter the one-time pairing secret (hidden)', '*'.repeat(value.length), hint?'Paste the complete 43-character pairing secret.':'Enter: connect Esc or Ctrl+C: cancel'].map(line=>line.slice(0,Math.max(0,width)));},
|
|
12
|
+
invalidate(){},
|
|
13
|
+
handleInput(data){
|
|
14
|
+
if(closed||typeof data!=='string'||isKeyRelease(data))return;
|
|
15
|
+
if(matchesKey(data,'escape')||matchesKey(data,'ctrl+c')){finish(undefined);return;}
|
|
16
|
+
if(matchesKey(data,'enter')){if(!pasting){if(/^[A-Za-z0-9_-]{43}$/.test(value))finish(value);else{hint=true;requestRender();}}return;}
|
|
17
|
+
if(!pasting&&matchesKey(data,'backspace')){value=value.slice(0,-1);hint=false;requestRender();return;}
|
|
18
|
+
// Pi normally wraps a whole paste; tolerate marker/body/end delivered separately.
|
|
19
|
+
if(data.startsWith('\x1b[200~')){pasting=true;paste='';invalidPaste=false;data=data.slice(6);}
|
|
20
|
+
if(pasting){
|
|
21
|
+
const end=data.indexOf('\x1b[201~');
|
|
22
|
+
const body=end<0?data:data.slice(0,end);
|
|
23
|
+
if(!/^[A-Za-z0-9_-]*$/.test(body)||paste.length+body.length>43)invalidPaste=true;
|
|
24
|
+
if(!invalidPaste)paste+=body;
|
|
25
|
+
if(end>=0){if(data.slice(end+6))invalidPaste=true;if(!invalidPaste)append(paste);else hint=true;paste='';pasting=false;invalidPaste=false;}
|
|
26
|
+
}else{
|
|
27
|
+
append(decodeKittyPrintable(data)??data);
|
|
28
|
+
}
|
|
29
|
+
requestRender();
|
|
30
|
+
},
|
|
31
|
+
dispose(){closed=true;value='';paste='';},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function promptPairingSecret(ctx) {
|
|
35
|
+
if(!ctx?.hasUI||typeof ctx.ui?.custom!=='function')throw new LocalError('native_private_input_unavailable');
|
|
36
|
+
return ctx.ui.custom((tui,_theme,_keys,done)=>maskedPairingComponent({done,requestRender:()=>tui.requestRender()}));
|
|
37
|
+
}
|
package/src/storage.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { lstatSync, readdirSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { LocalError } from '../vendor/broker/reference/errors.mjs';
|
|
5
|
+
|
|
6
|
+
const present = path => { try { lstatSync(path); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw new LocalError('invalid_configuration'); } };
|
|
7
|
+
|
|
8
|
+
// Match Pi's documented agent-directory scope. Never move a live legacy inbox:
|
|
9
|
+
// old plugin processes do not participate in a new migration lock.
|
|
10
|
+
export function storageLayout(env = process.env, home = homedir()) {
|
|
11
|
+
const configured = env.PI_CODING_AGENT_DIR;
|
|
12
|
+
const agent = configured ? resolve(configured === '~' ? home : configured.startsWith('~/') ? join(home, configured.slice(2)) : configured) : join(home, '.pi', 'agent');
|
|
13
|
+
const nativeRoot = join(agent, 'psst');
|
|
14
|
+
const legacyRoot = join(home, '.local', 'state', 'psst', 'pi');
|
|
15
|
+
const legacySelection = join(home, '.config', 'psst', 'pi-config-path');
|
|
16
|
+
const legacy = !env.PSST_STATE_DIR && present(legacyRoot);
|
|
17
|
+
if (legacy && present(nativeRoot)) {
|
|
18
|
+
const stat=lstatSync(nativeRoot);
|
|
19
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(nativeRoot)!==nativeRoot || (stat.mode&0o077) || (process.getuid && stat.uid!==process.getuid())) throw new LocalError('invalid_configuration');
|
|
20
|
+
// A selection pointer alone is not a second mailbox. Any other content may
|
|
21
|
+
// be enrollment/inbox state: never choose one history and hide the other.
|
|
22
|
+
if (readdirSync(nativeRoot).some(name => {
|
|
23
|
+
if (name === 'pi-config-path' || name === 'selection.sqlite' || name === 'selection.sqlite-journal') return false;
|
|
24
|
+
if(name === 'installation-credentials'){const s=lstatSync(join(nativeRoot,name));if(!s.isDirectory()||s.isSymbolicLink()||realpathSync(join(nativeRoot,name))!==join(nativeRoot,name)||(s.mode&0o077)||(process.getuid&&s.uid!==process.getuid()))throw new LocalError('invalid_configuration');return false;}
|
|
25
|
+
if (/^\.(?:pi-config-path-|broker-)[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(name)) {
|
|
26
|
+
try { const temp=lstatSync(join(nativeRoot,name)); return !temp.isFile() || temp.isSymbolicLink() || Boolean(temp.mode&0o077) || Boolean(process.getuid && temp.uid!==process.getuid()); }
|
|
27
|
+
catch (error) { if (error.code==='ENOENT') return false; throw new LocalError('invalid_configuration'); }
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
})) throw new LocalError('storage_migration_required');
|
|
31
|
+
}
|
|
32
|
+
const stateDirectory = resolve(env.PSST_STATE_DIR || (legacy ? legacyRoot : nativeRoot));
|
|
33
|
+
const installationDirectory=env.PSST_STATE_DIR?join(stateDirectory,'installations'):join(nativeRoot,'installation-credentials');
|
|
34
|
+
return { stateDirectory, installationDirectory, selectionDirectory: nativeRoot, legacySelection, legacyStorage: legacy };
|
|
35
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repository": "https://github.com/matuto-ai/psst",
|
|
3
|
-
"commit": "
|
|
3
|
+
"commit": "4a9d604e1107a92c94f8b8a2f0e1cb2d1e2ecc39",
|
|
4
4
|
"path": "reference/enrollment.mjs",
|
|
5
|
-
"git_blob": "
|
|
6
|
-
"sha256": "
|
|
5
|
+
"git_blob": "8a9ec6c2747ec9d25765a5ed506a4e43ef3cdd8e",
|
|
6
|
+
"sha256": "72318244ac7b799570704f59e167a0684b6c567cb6cda90303f32d7056ee0a06"
|
|
7
7
|
}
|
|
@@ -56,13 +56,22 @@ function post(url, secret, body) {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
/** Explicit connect only. The optional transport is a test seam, never a user configuration field. */
|
|
59
|
-
export
|
|
59
|
+
export function connectPairingFile(options, testOptions) {
|
|
60
|
+
return connectPairing({...options, pairingSecret:undefined}, testOptions);
|
|
61
|
+
}
|
|
62
|
+
/** Only a native private-input callback may supply this secret, never a model tool argument. */
|
|
63
|
+
export function connectPairingSecret({brokerUrl,pairingSecret,directory,clientType}, testOptions) {
|
|
64
|
+
return connectPairing({brokerUrl,pairingSecret,directory,clientType}, testOptions);
|
|
65
|
+
}
|
|
66
|
+
async function connectPairing({pairingFile,pairingSecret,directory,clientType,brokerUrl}, {transport = post} = {}) {
|
|
60
67
|
let lock, lockPath;
|
|
61
68
|
try {
|
|
62
69
|
if (!TYPES.includes(clientType)) fail('invalid_client_type');
|
|
63
|
-
const pair =
|
|
70
|
+
const pair = pairingFile === undefined
|
|
71
|
+
? {format:'psst-pairing-v1',client_type:clientType,pairing_secret:pairingSecret,broker_url:brokerUrl}
|
|
72
|
+
: JSON.parse(privateRead(pairingFile));
|
|
64
73
|
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');
|
|
74
|
+
|| !/^[A-Za-z0-9_-]{43}$/.test(pair.pairing_secret) || (pairingFile !== undefined && !Number.isFinite(Date.parse(pair.expires_at)))) fail('invalid_pairing_file');
|
|
66
75
|
const broker = origin(pair.broker_url);
|
|
67
76
|
if (brokerUrl !== undefined && origin(brokerUrl) !== broker) fail('broker_origin_mismatch');
|
|
68
77
|
privateDir(directory);
|