@matuto-ai/pi-broker-connector 0.1.0 → 0.1.2

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 CHANGED
@@ -28,29 +28,37 @@ Or, after the npm-scope correction PR is merged into `main`:
28
28
  pi install git:github.com/matuto-ai/pi-broker-connector
29
29
  ```
30
30
 
31
- This uses the default branch; wait for the scope correction to merge first. To test before merge, append `@` and the exact reviewed commit from the scope-correction PR. Existing tags are unchanged and may still contain the old npm package identity; do not use an old tag as evidence of the new scope. Publishing the new scoped npm package remains paused until source corrections are reviewed and publication is explicitly resumed.
31
+ This uses the default branch; wait for the scope correction to merge first. To test before merge, append `@` and the exact reviewed commit from the scope-correction PR. Existing tags are unchanged and may still contain the old npm package identity; do not use an old tag as evidence of the new scope. Publish a new package version only after its source changes have been reviewed and the owner is ready to release it.
32
32
 
33
33
  ### From npm (only after publication)
34
34
 
35
35
  ```sh
36
- pi install npm:@matuto-ai/pi-broker-connector@0.1.0
36
+ pi install npm:@matuto-ai/pi-broker-connector@0.1.2
37
37
  ```
38
38
 
39
39
  The proposed scope must be controlled by the publisher before publication. A repository name or workflow does not create an npm account/package. Until published, use the checkout route above; do not treat the npm command as currently available.
40
40
 
41
- ### Connect
41
+ ### Connect — no restart or environment variable needed
42
42
 
43
- Mount/copy only this client's private bundle into the sandbox. Keep `connection.json` and `token` private to the sandbox's user (`0600`), and the directory `0700`. Do not paste tokens into prompts, command arguments, screenshots, Git, or shared config.
43
+ With this version loaded, run one command **in the current Pi session**:
44
+
45
+ ```text
46
+ /psst-connect /absolute/path/to/connection.json
47
+ ```
48
+
49
+ Use the existing bundle path; keep its `token` next to `connection.json`. Do not copy the JSON alone. The command connects the current native session and saves **only the absolute path**, as one line in `~/.config/psst/pi-config-path`. It does not copy credentials or alter the process environment. `/reload` reads that TXT again. You can also create that TXT yourself (mode `0600`, directory `0700`) and use `/reload`.
50
+
51
+ For an existing installation, after the owner publishes this version, update the package **inside the same sandbox**, then use `/reload` in the running Pi and the command above:
44
52
 
45
53
  ```sh
46
- export PSST_CONFIG_FILE=/absolute/private/psst/pi/connection.json
47
- export PSST_NAME='Pi coding'
48
- # Optional: a durable path INSIDE this sandbox, owned by its user, mode 0700
49
- export PSST_STATE_DIR=/absolute/private/psst/pi/state
50
- pi
54
+ pi install npm:@matuto-ai/pi-broker-connector@0.1.2
51
55
  ```
52
56
 
53
- `PSST_CONFIG` is an alias; `PSST_CONFIG_FILE` wins. `PSST_NAME` defaults to `Pi`. State defaults to `~/.local/state/psst/pi`. Existing shells/processes do not inherit newly exported variables: restart Pi once with these variables, then `/reload` can retry after configuration changes. `/psst-status` displays status and the session's exact client ID.
57
+ 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`; updating the package and reloading are required first.
58
+
59
+ 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
+
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 TXT. `/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 `~/.local/state/psst/pi`. `/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. Connection failures retain the selected path for `/reload` retry.
54
62
 
55
63
  Expected non-secret bundle structure, generated by the broker enrollment utility:
56
64
 
@@ -111,6 +119,8 @@ Tests cover private bundle validation, exact session isolation, duplicate owners
111
119
 
112
120
  ## Publishing
113
121
 
122
+ The `0.1.2` patch adds current-session connection through `/psst-connect` and a saved TXT path. After this PR is merged, the owner can create a GitHub release with tag **`v0.1.2`**. Preparing this PR does not publish that release or npm version.
123
+
114
124
  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.
115
125
 
116
126
  For OIDC trusted publishing, configure the npm package's trusted publisher for GitHub owner `matuto-ai`, repo `pi-broker-connector`, workflow **`npm-publish.yml`** (filename only), allowing direct `npm publish`. No `NPM_TOKEN` is embedded or created. Use npm CLI >=11.5.1 with the supported Node version. If the npm package does not yet exist, the owner must complete the first package publication/account setup using npm's supported process before configuring its package settings. That setup is not performed by this repository. If the first manual publication uses `0.1.0`, bump the package version and matching release tag before the next automated publish; npm does not allow republishing the same version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matuto-ai/pi-broker-connector",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Session-bound Psst broker messaging tools for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,43 @@
1
+ import { constants, openSync, closeSync, fstatSync, readFileSync, realpathSync, mkdirSync, lstatSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { LocalError } from '../vendor/broker/reference/errors.mjs';
6
+
7
+ export function validateConfigPath(value) {
8
+ if (typeof value !== 'string' || !isAbsolute(value) || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)) throw new LocalError('invalid_configuration');
9
+ return value;
10
+ }
11
+
12
+ // This TXT contains a path only, never the bundle or token.
13
+ export function createConfigPathStore(home = homedir()) {
14
+ const directory = join(home, '.config', 'psst');
15
+ const file = join(directory, 'pi-config-path');
16
+ return {
17
+ read() {
18
+ let fd;
19
+ try {
20
+ try { lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
21
+ if (realpathSync(file) !== file) throw new Error();
22
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
23
+ const stat = fstatSync(fd);
24
+ if (!stat.isFile() || stat.size > 4097 || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid())) throw new Error();
25
+ return validateConfigPath(readFileSync(fd, 'utf8').replace(/\n$/, ''));
26
+ } catch { throw new LocalError('invalid_configuration'); }
27
+ finally { if (fd !== undefined) closeSync(fd); }
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
+ };
43
+ }
package/src/config.mjs CHANGED
@@ -16,9 +16,10 @@ export function readPrivateJson(path) {
16
16
  finally { if (fd !== undefined) closeSync(fd); }
17
17
  }
18
18
 
19
- export function readConfig(env = process.env) {
20
- if (!env.PSST_CONFIG_FILE && !env.PSST_CONFIG) return null;
21
- const path = resolve(env.PSST_CONFIG_FILE || env.PSST_CONFIG);
19
+ export function readConfig(env = process.env, explicitPath) {
20
+ const selected = explicitPath || env.PSST_CONFIG_FILE || env.PSST_CONFIG;
21
+ if (!selected) return null;
22
+ const path = resolve(selected);
22
23
  const raw = readPrivateJson(path);
23
24
  if (raw.format !== 'broker-client-bundle-v1' || !Array.isArray(raw.allowed_client_types) || !raw.allowed_client_types.includes('pi')
24
25
  || raw.protocol_version !== '1' || raw.file_paths_relative_to !== 'connection_file_directory'
package/src/extension.mjs CHANGED
@@ -1,24 +1,31 @@
1
+ import { createConfigPathStore, validateConfigPath } from './config-path.mjs';
1
2
  import { readConfig } from './config.mjs';
2
3
  import { PiBrokerSession } from './session.mjs';
3
4
  import { createCooperativeInbox } from './cooperative-inbox.mjs';
4
5
  import { createTools, invokeTool } from '../vendor/broker/reference/tools.mjs';
5
6
  import { LocalError, safeError } from '../vendor/broker/reference/errors.mjs';
6
7
 
7
- export function installExtension(pi, Type, { configReader = readConfig, createSession = options => new PiBrokerSession(options) } = {}) {
8
+ export function installExtension(pi, Type, { configReader = readConfig, pathStore = createConfigPathStore(), env = process.env, createSession = options => new PiBrokerSession(options) } = {}) {
8
9
  let owner = null, facade = null, ready = false, status = 'not_configured';
9
- let generation = 0;
10
+ let generation = 0, operations = Promise.resolve();
11
+ const schedule = operation => {
12
+ const epoch = ++generation;
13
+ ready = false;
14
+ const next = operations.then(() => operation(epoch));
15
+ operations = next.catch(() => {});
16
+ return next;
17
+ };
10
18
  const notify = (ctx, text, kind = 'info') => { if (ctx?.hasUI) ctx.ui.notify(text, kind); };
11
19
  const setStatus = ctx => { if (ctx?.hasUI) ctx.ui.setStatus('psst', `Psst: ${status}`); };
12
20
  const stop = async () => {
13
- generation++;
14
21
  const prior = owner; owner = null; facade = null; ready = false;
15
22
  if (prior) await prior.close();
16
23
  };
17
- pi.on('session_start', async (_event, ctx) => {
24
+ const connect = async (ctx, epoch, selectedConfig) => {
18
25
  await stop();
19
- const epoch = generation;
26
+ if (epoch !== generation) return;
20
27
  try {
21
- const config = configReader();
28
+ const config = selectedConfig || configReader(env, env.PSST_CONFIG_FILE || env.PSST_CONFIG || pathStore.read());
22
29
  if (!config) { status = 'not_configured'; setStatus(ctx); return; }
23
30
  const hostSessionId = ctx.sessionManager.getSessionId();
24
31
  const current = createSession({ config, hostSessionId,
@@ -39,12 +46,26 @@ export function installExtension(pi, Type, { configReader = readConfig, createSe
39
46
  if (prior) await prior.close();
40
47
  setStatus(ctx); notify(ctx, `Psst could not connect (${status}). Check the private bundle and use /reload to retry.`, 'warning');
41
48
  }
49
+ };
50
+ pi.on('session_start', async (_event, ctx) => schedule(epoch => connect(ctx, epoch)));
51
+ pi.on('session_shutdown', async () => schedule(async () => { await stop(); status = 'stopped'; }));
52
+ pi.registerCommand('psst-connect', {
53
+ description: 'Connect this Pi session using an absolute connection.json path; remember only the path',
54
+ handler: async (args, ctx) => {
55
+ try {
56
+ const path = validateConfigPath(args.trim());
57
+ const config = configReader(env, path);
58
+ pathStore.write(path);
59
+ await schedule(epoch => connect(ctx, epoch, config));
60
+ 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');
61
+ notify(ctx, ready ? 'Psst connected. Configuration path saved; no restart needed.' : 'Psst path saved. Check /psst-status; /reload retries the connection.');
62
+ } catch (error) { notify(ctx, `Psst could not save configuration (${safeError(error).error?.code || 'invalid_configuration'}). Use /psst-connect /absolute/path/connection.json.`, 'warning'); }
63
+ },
42
64
  });
43
- pi.on('session_shutdown', async () => { await stop(); status = 'stopped'; });
44
65
  pi.registerCommand('psst-status', {
45
66
  description: 'Show this Pi session’s Psst broker status and client ID',
46
67
  handler: async (_args, ctx) => {
47
- notify(ctx, ready && owner ? `Psst ${status}; client ID ${owner.inbox.identity.client_id}; inbox ${owner.inbox.stats().pending_count} pending. Cooperative mode: ask the agent to check broker_receive.` : `Psst ${status}. Set PSST_CONFIG_FILE to the private connection.json bundle, then /reload.`);
68
+ notify(ctx, ready && owner ? `Psst ${status}; client ID ${owner.inbox.identity.client_id}; inbox ${owner.inbox.stats().pending_count} pending. Cooperative mode: ask the agent to check broker_receive.` : `Psst ${status}. Use /psst-connect /absolute/path/connection.json. No restart required.`);
48
69
  },
49
70
  });
50
71
  const empty = Type.Object({}, { additionalProperties: false });