@nicknisi/pi-relay 0.1.0 → 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/dist/policy.d.ts CHANGED
@@ -23,11 +23,13 @@ export declare class OutboundPolicy {
23
23
  /**
24
24
  * Gate one outbound letter. `unreadBacklog` is the target's current
25
25
  * unread count (report 0 when the target is idle — an idle agent has by
26
- * definition worked through what it was handed).
26
+ * definition worked through what it was handed). `target` scopes the
27
+ * identical-body dedupe to a single peer (loop-breaking) so a broadcast
28
+ * of one body to N peers is not deduped after the first.
27
29
  */
28
- check(body: string, unreadBacklog: number): OutboundVerdict;
30
+ check(body: string, unreadBacklog: number, target?: string): OutboundVerdict;
29
31
  /** Record a successful send so dedupe/rate state stays current. */
30
- recordSend(body: string): void;
32
+ recordSend(body: string, target?: string): void;
31
33
  }
32
34
  /** Inbound guard: PI_RELAY_INBOUND=refuse drops all peer mail. */
33
35
  export declare function inboundAccepts(env?: string | undefined): boolean;
package/dist/policy.js CHANGED
@@ -20,9 +20,11 @@ export class OutboundPolicy {
20
20
  /**
21
21
  * Gate one outbound letter. `unreadBacklog` is the target's current
22
22
  * unread count (report 0 when the target is idle — an idle agent has by
23
- * definition worked through what it was handed).
23
+ * definition worked through what it was handed). `target` scopes the
24
+ * identical-body dedupe to a single peer (loop-breaking) so a broadcast
25
+ * of one body to N peers is not deduped after the first.
24
26
  */
25
- check(body, unreadBacklog) {
27
+ check(body, unreadBacklog, target) {
26
28
  if (body.length > MAX_BODY_CHARS) {
27
29
  return {
28
30
  ok: false,
@@ -36,9 +38,10 @@ export class OutboundPolicy {
36
38
  };
37
39
  }
38
40
  const now = this.now();
39
- const lastSame = this.recentBodies.get(body);
41
+ const dedupeKey = `${body}\u0000${target ?? ''}`;
42
+ const lastSame = this.recentBodies.get(dedupeKey);
40
43
  if (lastSame !== undefined && now - lastSame < DEDUPE_WINDOW_MS) {
41
- return { ok: false, reason: 'Identical message sent less than 10s ago — dropped to break loops.' };
44
+ return { ok: false, reason: 'Identical message to the same peer less than 10s ago — dropped to break loops.' };
42
45
  }
43
46
  const windowStart = now - RATE_LIMIT_WINDOW_MS;
44
47
  while (this.sentAt.length > 0 && this.sentAt[0] < windowStart)
@@ -52,9 +55,9 @@ export class OutboundPolicy {
52
55
  return { ok: true };
53
56
  }
54
57
  /** Record a successful send so dedupe/rate state stays current. */
55
- recordSend(body) {
58
+ recordSend(body, target) {
56
59
  this.sentAt.push(this.now());
57
- this.recentBodies.set(body, this.now());
60
+ this.recentBodies.set(`${body}\u0000${target ?? ''}`, this.now());
58
61
  // bound the dedupe map
59
62
  if (this.recentBodies.size > 200) {
60
63
  const cutoff = this.now() - DEDUPE_WINDOW_MS;
@@ -34,6 +34,22 @@ export declare function ensureRoot(root: string): void;
34
34
  export declare function recordPath(root: string, addr: string): string;
35
35
  export declare function inboxDir(root: string, addr: string): string;
36
36
  export declare function asksDir(root: string, addr: string): string;
37
+ export interface AliasRecord {
38
+ name: string;
39
+ addr: string;
40
+ sessionId: string;
41
+ claimedAt: number;
42
+ }
43
+ export declare function aliasesDir(root: string): string;
44
+ export declare function aliasPath(root: string, name: string): string;
45
+ export declare function writeAlias(root: string, alias: AliasRecord): void;
46
+ /** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
47
+ export declare function isValidAliasName(name: string): boolean;
48
+ export declare function readAlias(root: string, name: string): AliasRecord | null;
49
+ export declare function listAliases(root: string): AliasRecord[];
50
+ /** Last-claim-wins: overwrites any prior owner. */
51
+ export declare function claimAlias(root: string, name: string, addr: string, sessionId: string): AliasRecord;
52
+ export declare function clearAlias(root: string, name: string): void;
37
53
  export declare function writeRecord(root: string, record: SessionRecord): void;
38
54
  export declare function readRecord(root: string, addr: string): SessionRecord | null;
39
55
  /** Read-only listing, oldest first. Never mutates anything. */
package/dist/registry.js CHANGED
@@ -15,8 +15,8 @@
15
15
  * - Listing has NO side effects: reading the directory never deletes.
16
16
  */
17
17
  import * as crypto from 'node:crypto';
18
- import * as fs from 'node:fs';
19
18
  import * as path from 'node:path';
19
+ import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
20
20
  export const HEARTBEAT_STALE_MS = 45_000;
21
21
  /** A mailbox holding undelivered mail is kept this long after last contact. */
22
22
  export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
@@ -24,60 +24,222 @@ export function deriveAddr(cwd, sessionId) {
24
24
  return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
25
25
  }
26
26
  export function ensureRoot(root) {
27
- fs.mkdirSync(root, { recursive: true, mode: 0o700 });
28
- try {
29
- fs.chmodSync(root, 0o700);
30
- }
31
- catch {
32
- // best-effort on filesystems that support modes
33
- }
27
+ const directory = openRelayRoot(root, true);
28
+ directory.close();
34
29
  }
35
30
  export function recordPath(root, addr) {
31
+ assertPathSegment(addr, 'relay address');
36
32
  return path.join(root, `${addr}.json`);
37
33
  }
38
34
  export function inboxDir(root, addr) {
35
+ assertPathSegment(addr, 'relay address');
39
36
  return path.join(root, `${addr}.inbox`);
40
37
  }
41
38
  export function asksDir(root, addr) {
39
+ assertPathSegment(addr, 'relay address');
42
40
  return path.join(root, `${addr}.asks`);
43
41
  }
44
- export function writeRecord(root, record) {
45
- ensureRoot(root);
46
- const file = recordPath(root, record.addr);
47
- const tmp = `${file}.${process.pid}.tmp`;
48
- fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
49
- fs.renameSync(tmp, file);
42
+ export function aliasesDir(root) {
43
+ return path.join(root, 'aliases');
50
44
  }
51
- export function readRecord(root, addr) {
45
+ export function aliasPath(root, name) {
46
+ assertPathSegment(name, 'relay alias');
47
+ return path.join(aliasesDir(root), `${name}.json`);
48
+ }
49
+ export function writeAlias(root, alias) {
50
+ if (!isValidAliasName(alias.name))
51
+ throw new TypeError(`Invalid relay alias: ${alias.name}`);
52
+ const relay = openRelayRoot(root, true);
52
53
  try {
53
- const parsed = JSON.parse(fs.readFileSync(recordPath(root, addr), 'utf8'));
54
- if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') {
55
- return parsed;
54
+ const aliases = relay.openDirectory('aliases', true);
55
+ try {
56
+ aliases.writeFileAtomic(`${alias.name}.json`, JSON.stringify(alias, null, 2));
57
+ }
58
+ finally {
59
+ aliases.close();
56
60
  }
61
+ }
62
+ finally {
63
+ relay.close();
64
+ }
65
+ }
66
+ /** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
67
+ export function isValidAliasName(name) {
68
+ return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
69
+ }
70
+ function readAliasFromDirectory(directory, name) {
71
+ const raw = directory.readFile(`${name}.json`);
72
+ if (raw === null)
73
+ return null;
74
+ const parsed = JSON.parse(raw);
75
+ if (parsed && typeof parsed.name === 'string' && typeof parsed.addr === 'string')
76
+ return parsed;
77
+ return null;
78
+ }
79
+ export function readAlias(root, name) {
80
+ // The name can come from LLM-controlled tool input — never let it reach
81
+ // the filesystem unvalidated (e.g. '@../../../../tmp/x').
82
+ if (!isValidAliasName(name))
83
+ return null;
84
+ const relay = openRelayRoot(root);
85
+ if (relay === null)
57
86
  return null;
87
+ try {
88
+ const aliases = relay.openDirectory('aliases');
89
+ if (aliases === null)
90
+ return null;
91
+ try {
92
+ return readAliasFromDirectory(aliases, name);
93
+ }
94
+ catch (error) {
95
+ rethrowFilesystemError(error);
96
+ return null;
97
+ }
98
+ finally {
99
+ aliases.close();
100
+ }
101
+ }
102
+ finally {
103
+ relay.close();
104
+ }
105
+ }
106
+ export function listAliases(root) {
107
+ const relay = openRelayRoot(root);
108
+ if (relay === null)
109
+ return [];
110
+ try {
111
+ const aliases = relay.openDirectory('aliases');
112
+ if (aliases === null)
113
+ return [];
114
+ try {
115
+ const out = [];
116
+ for (const entry of aliases.readDirectory()) {
117
+ if (!entry.name.endsWith('.json'))
118
+ continue;
119
+ if (entry.isSymbolicLink())
120
+ throw new RelayFilesystemError(`Refusing symlinked relay alias: ${entry.name}`);
121
+ if (!entry.isFile())
122
+ continue;
123
+ try {
124
+ const alias = readAliasFromDirectory(aliases, entry.name.slice(0, -'.json'.length));
125
+ if (alias)
126
+ out.push(alias);
127
+ }
128
+ catch (error) {
129
+ rethrowFilesystemError(error);
130
+ }
131
+ }
132
+ return out.sort((a, b) => a.claimedAt - b.claimedAt);
133
+ }
134
+ finally {
135
+ aliases.close();
136
+ }
58
137
  }
59
- catch {
138
+ catch (error) {
139
+ rethrowFilesystemError(error);
140
+ return [];
141
+ }
142
+ finally {
143
+ relay.close();
144
+ }
145
+ }
146
+ /** Last-claim-wins: overwrites any prior owner. */
147
+ export function claimAlias(root, name, addr, sessionId) {
148
+ const alias = { name, addr, sessionId, claimedAt: Date.now() };
149
+ writeAlias(root, alias);
150
+ return alias;
151
+ }
152
+ export function clearAlias(root, name) {
153
+ if (!isValidAliasName(name))
154
+ return;
155
+ const relay = openRelayRoot(root);
156
+ if (relay === null)
157
+ return;
158
+ try {
159
+ const aliases = relay.openDirectory('aliases');
160
+ if (aliases === null)
161
+ return;
162
+ try {
163
+ aliases.unlinkFile(`${name}.json`);
164
+ }
165
+ catch (error) {
166
+ rethrowFilesystemError(error);
167
+ // deletion remains best-effort for ordinary I/O failures
168
+ }
169
+ finally {
170
+ aliases.close();
171
+ }
172
+ }
173
+ finally {
174
+ relay.close();
175
+ }
176
+ }
177
+ export function writeRecord(root, record) {
178
+ const relay = openRelayRoot(root, true);
179
+ try {
180
+ relay.writeFileAtomic(`${record.addr}.json`, JSON.stringify(record, null, 2));
181
+ }
182
+ finally {
183
+ relay.close();
184
+ }
185
+ }
186
+ function readRecordFromDirectory(directory, addr) {
187
+ const raw = directory.readFile(`${addr}.json`);
188
+ if (raw === null)
60
189
  return null;
190
+ const parsed = JSON.parse(raw);
191
+ if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number')
192
+ return parsed;
193
+ return null;
194
+ }
195
+ export function readRecord(root, addr) {
196
+ assertPathSegment(addr, 'relay address');
197
+ const relay = openRelayRoot(root);
198
+ if (relay === null)
199
+ return null;
200
+ try {
201
+ return readRecordFromDirectory(relay, addr);
202
+ }
203
+ catch (error) {
204
+ rethrowFilesystemError(error);
205
+ return null;
206
+ }
207
+ finally {
208
+ relay.close();
61
209
  }
62
210
  }
63
211
  /** Read-only listing, oldest first. Never mutates anything. */
64
212
  export function listRecords(root) {
65
- let entries;
213
+ const relay = openRelayRoot(root);
214
+ if (relay === null)
215
+ return [];
66
216
  try {
67
- entries = fs.readdirSync(root, { withFileTypes: true });
217
+ const out = [];
218
+ for (const entry of relay.readDirectory()) {
219
+ if (!entry.name.endsWith('.json'))
220
+ continue;
221
+ if (entry.isSymbolicLink())
222
+ throw new RelayFilesystemError(`Refusing symlinked relay record: ${entry.name}`);
223
+ if (!entry.isFile())
224
+ continue;
225
+ try {
226
+ const record = readRecordFromDirectory(relay, entry.name.slice(0, -'.json'.length));
227
+ if (record)
228
+ out.push(record);
229
+ }
230
+ catch (error) {
231
+ rethrowFilesystemError(error);
232
+ }
233
+ }
234
+ return out.sort((a, b) => a.startedAt - b.startedAt);
68
235
  }
69
- catch {
236
+ catch (error) {
237
+ rethrowFilesystemError(error);
70
238
  return [];
71
239
  }
72
- const out = [];
73
- for (const entry of entries) {
74
- if (!entry.isFile() || !entry.name.endsWith('.json'))
75
- continue;
76
- const record = readRecord(root, entry.name.slice(0, -'.json'.length));
77
- if (record)
78
- out.push(record);
240
+ finally {
241
+ relay.close();
79
242
  }
80
- return out.sort((a, b) => a.startedAt - b.startedAt);
81
243
  }
82
244
  function pidAlive(pid) {
83
245
  if (!Number.isInteger(pid) || pid <= 0)
@@ -98,21 +260,96 @@ export function presenceOf(record, now = Date.now()) {
98
260
  return 'offline';
99
261
  return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? 'live' : 'stalled';
100
262
  }
101
- function dirHasJson(dir) {
263
+ function dirHasJson(root, name) {
264
+ const relay = openRelayRoot(root);
265
+ if (relay === null)
266
+ return false;
102
267
  try {
103
- return fs.readdirSync(dir).some((f) => f.endsWith('.json'));
268
+ const directory = relay.openDirectory(name);
269
+ if (directory === null)
270
+ return false;
271
+ try {
272
+ return directory.readDirectory().some((entry) => {
273
+ if (!entry.name.endsWith('.json'))
274
+ return false;
275
+ if (entry.isSymbolicLink())
276
+ throw new RelayFilesystemError(`Refusing symlinked relay entry: ${entry.name}`);
277
+ return entry.isFile();
278
+ });
279
+ }
280
+ finally {
281
+ directory.close();
282
+ }
104
283
  }
105
- catch {
284
+ catch (error) {
285
+ rethrowFilesystemError(error);
286
+ return false;
287
+ }
288
+ finally {
289
+ relay.close();
290
+ }
291
+ }
292
+ function claimsHaveJson(root, addr) {
293
+ const relay = openRelayRoot(root);
294
+ if (relay === null)
106
295
  return false;
296
+ try {
297
+ const claims = relay.openDirectory(`${addr}.claims`);
298
+ if (claims === null)
299
+ return false;
300
+ try {
301
+ for (const entry of claims.readDirectory()) {
302
+ if (entry.isSymbolicLink())
303
+ throw new RelayFilesystemError(`Refusing symlinked relay claim: ${entry.name}`);
304
+ if (!entry.isDirectory())
305
+ continue;
306
+ const claim = claims.openDirectory(entry.name);
307
+ if (claim === null)
308
+ continue;
309
+ try {
310
+ if (claim.readDirectory().some((file) => {
311
+ if (!file.name.endsWith('.json'))
312
+ return false;
313
+ if (file.isSymbolicLink()) {
314
+ throw new RelayFilesystemError(`Refusing symlinked claimed letter: ${file.name}`);
315
+ }
316
+ return file.isFile();
317
+ })) {
318
+ return true;
319
+ }
320
+ }
321
+ finally {
322
+ claim.close();
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+ finally {
328
+ claims.close();
329
+ }
330
+ }
331
+ finally {
332
+ relay.close();
107
333
  }
108
334
  }
109
- function rmrf(target) {
335
+ function removeClaims(directory, addr) {
336
+ const claimsName = `${addr}.claims`;
337
+ const claims = directory.openDirectory(claimsName);
338
+ if (claims === null)
339
+ return;
110
340
  try {
111
- fs.rmSync(target, { recursive: true, force: true });
341
+ for (const entry of claims.readDirectory()) {
342
+ if (entry.isSymbolicLink())
343
+ throw new RelayFilesystemError(`Refusing symlinked relay claim: ${entry.name}`);
344
+ if (!entry.isDirectory())
345
+ continue;
346
+ claims.removeDirectory(entry.name);
347
+ }
112
348
  }
113
- catch {
114
- // sweep is best-effort
349
+ finally {
350
+ claims.close();
115
351
  }
352
+ directory.removeDirectory(claimsName);
116
353
  }
117
354
  /**
118
355
  * Reclaim dead sessions' files. Rules (mail outranks tidiness):
@@ -131,19 +368,38 @@ export function sweep(root, now = Date.now(), sessionExists) {
131
368
  for (const record of listRecords(root)) {
132
369
  if (presenceOf(record, now) !== 'offline')
133
370
  continue;
134
- const hasMail = dirHasJson(inboxDir(root, record.addr)) || dirHasJson(asksDir(root, record.addr));
371
+ const hasMail = dirHasJson(root, `${record.addr}.inbox`) ||
372
+ dirHasJson(root, `${record.addr}.asks`) ||
373
+ claimsHaveJson(root, record.addr);
135
374
  const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
136
375
  if (hasMail && !expired)
137
376
  continue;
138
377
  if (!expired && (sessionExists?.(record.sessionId) ?? true))
139
378
  continue;
140
- rmrf(inboxDir(root, record.addr));
141
- rmrf(asksDir(root, record.addr));
142
- try {
143
- fs.unlinkSync(recordPath(root, record.addr));
144
- }
145
- catch {
146
- // already gone
379
+ const relay = openRelayRoot(root);
380
+ if (relay !== null) {
381
+ try {
382
+ relay.removeDirectory(`${record.addr}.inbox`);
383
+ relay.removeDirectory(`${record.addr}.asks`);
384
+ removeClaims(relay, record.addr);
385
+ try {
386
+ relay.unlinkFile(`${record.addr}.json`);
387
+ }
388
+ catch (error) {
389
+ rethrowFilesystemError(error);
390
+ // deletion remains best-effort for ordinary I/O failures
391
+ }
392
+ }
393
+ finally {
394
+ relay.close();
395
+ }
147
396
  }
148
397
  }
398
+ // Aliases are swept when the owning session dies: an alias whose record
399
+ // was just reaped (or never existed) is reclaimed. A resumable-but-offline
400
+ // session keeps its record, so it keeps its alias too.
401
+ for (const alias of listAliases(root)) {
402
+ if (readRecord(root, alias.addr) === null)
403
+ clearAlias(root, alias.name);
404
+ }
149
405
  }