@nicknisi/pi-relay 0.3.1 → 0.3.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/dist/index.js CHANGED
@@ -20,7 +20,13 @@ import { Type } from 'typebox';
20
20
  import { formatAudit, formatDelivery, formatListing, formatPendingAsk, refusalAmbiguous, refusalUnknown, shortAddr, } from './format.js';
21
21
  import { ackClaimedLetter, appendAudit, awaitReceipt, claimInbox, clearAsk, deposit, outgoingAskIds, pendingAsks, previewBody, readAudit, readClaimedLetter, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
22
22
  import { OutboundPolicy, inboundAccepts } from './policy.js';
23
- import { claimAlias, deriveAddr, ensureRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
23
+ import { claimAlias, deriveAddr, ensureRoot, resolveRelayRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
24
+ /** What to tell the caller when there is no session record to work with. */
25
+ function describeUninitialized(startupError, root) {
26
+ return startupError
27
+ ? `Relay failed to start: ${startupError} (relay root: ${root})`
28
+ : 'Relay is not initialized (no session_start yet).';
29
+ }
24
30
  function toolResult(text, details = {}) {
25
31
  return { content: [{ type: 'text', text }], details };
26
32
  }
@@ -93,9 +99,18 @@ function collectResumableSessionIds() {
93
99
  return ids;
94
100
  }
95
101
  export default function relay(pi) {
96
- const root = process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay');
102
+ // Resolve before any relay I/O: pi's home is commonly a dotfiles symlink,
103
+ // and the traversal hardening (rightly) refuses user-controlled ancestor
104
+ // symlinks on every operation. See resolveRelayRoot.
105
+ const root = resolveRelayRoot(process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay'));
97
106
  const policy = new OutboundPolicy();
98
107
  let self;
108
+ // Why registration failed, if it did. Without this a startup failure is
109
+ // indistinguishable from "session_start hasn't fired yet", and the whole
110
+ // extension goes quietly inert -- which is exactly how a symlinked pi home
111
+ // disabled relay machine-wide for a day and a half before anyone noticed.
112
+ let startupError;
113
+ const uninitializedText = () => describeUninitialized(startupError, root);
99
114
  let unwatch;
100
115
  let heartbeat;
101
116
  const askWaiters = new Map();
@@ -411,6 +426,7 @@ export default function relay(pi) {
411
426
  }
412
427
  pi.on('session_start', (_event, ctx) => {
413
428
  try {
429
+ startupError = undefined;
414
430
  ensureRoot(root);
415
431
  const sessionId = ctx.sessionManager.getSessionId();
416
432
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
@@ -461,8 +477,11 @@ export default function relay(pi) {
461
477
  const initialDrain = setTimeout(checkInbox, 1200);
462
478
  initialDrain.unref();
463
479
  }
464
- catch {
465
- // registration failure never breaks the session
480
+ catch (error) {
481
+ // A failure here must never break the session -- but it must not be
482
+ // invisible either. Record it so every tool can say what actually
483
+ // happened instead of implying session_start simply hasn't run.
484
+ startupError = error instanceof Error ? error.message : String(error);
466
485
  }
467
486
  });
468
487
  pi.on('agent_start', () => writeSelf({ status: 'working' }));
@@ -515,7 +534,7 @@ export default function relay(pi) {
515
534
  }),
516
535
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
517
536
  if (!self)
518
- return toolResult('Relay is not initialized (no session_start yet).');
537
+ return toolResult(uninitializedText());
519
538
  switch (params.action) {
520
539
  case 'list':
521
540
  return toolResult(formatListing(listRecords(root), self.addr, (r) => presenceOf(r)));
@@ -748,7 +767,7 @@ export default function relay(pi) {
748
767
  if (sub === 'log') {
749
768
  const limit = Math.max(1, Math.min(500, Number(rest[0] ?? 50)));
750
769
  const entries = self ? readAudit(root, limit) : [];
751
- const text = self ? formatAudit(entries) : 'Relay is not initialized (no session_start yet).';
770
+ const text = self ? formatAudit(entries) : uninitializedText();
752
771
  if (!self || !ctx.hasUI) {
753
772
  pi.sendMessage({ customType: AUDIT_TYPE, content: text, display: true, details: { kind: 'relay-audit' } });
754
773
  return;
@@ -758,9 +777,7 @@ export default function relay(pi) {
758
777
  }
759
778
  if (!self || !ctx.hasUI) {
760
779
  // Plain-text fallback (print/rpc mode): the entry renderer never runs there.
761
- const text = self
762
- ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r))
763
- : 'Relay is not initialized (no session_start yet).';
780
+ const text = self ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r)) : uninitializedText();
764
781
  pi.sendMessage({
765
782
  customType: LIST_TYPE,
766
783
  content: text,
@@ -30,6 +30,25 @@ export declare const HEARTBEAT_STALE_MS = 45000;
30
30
  /** A mailbox holding undelivered mail is kept this long after last contact. */
31
31
  export declare const SWEEP_MAIL_KEEP_MS: number;
32
32
  export declare function deriveAddr(cwd: string, sessionId: string): string;
33
+ /**
34
+ * Pin the relay root to its real location, once, before any relay I/O.
35
+ *
36
+ * The traversal hardening refuses *user-controlled* ancestor symlinks, because
37
+ * a user-writable symlink can be swapped between the check and the use. That
38
+ * is the right rule for every operation after startup — but it also rejects a
39
+ * legitimate, stable arrangement: a dotfiles setup where `~/.pi` is a symlink
40
+ * into a managed repo. Since pi keeps everything under `~/.pi`, that made the
41
+ * relay root unopenable and the whole extension silently inert.
42
+ *
43
+ * Resolving once at startup keeps the property that matters. Every later
44
+ * operation traverses only real directories, so there is no user symlink left
45
+ * in the path to swap; repointing `~/.pi` afterwards cannot redirect this
46
+ * process. It is a pin, not a bypass.
47
+ *
48
+ * The root may not exist yet on a first run, so resolve the deepest ancestor
49
+ * that does and re-apply the remainder.
50
+ */
51
+ export declare function resolveRelayRoot(root: string): string;
33
52
  export declare function ensureRoot(root: string): void;
34
53
  export declare function recordPath(root: string, addr: string): string;
35
54
  export declare function inboxDir(root: string, addr: string): string;
package/dist/registry.js CHANGED
@@ -15,6 +15,7 @@
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';
18
19
  import * as path from 'node:path';
19
20
  import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
20
21
  export const HEARTBEAT_STALE_MS = 45_000;
@@ -23,6 +24,44 @@ export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
23
24
  export function deriveAddr(cwd, sessionId) {
24
25
  return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
25
26
  }
27
+ /**
28
+ * Pin the relay root to its real location, once, before any relay I/O.
29
+ *
30
+ * The traversal hardening refuses *user-controlled* ancestor symlinks, because
31
+ * a user-writable symlink can be swapped between the check and the use. That
32
+ * is the right rule for every operation after startup — but it also rejects a
33
+ * legitimate, stable arrangement: a dotfiles setup where `~/.pi` is a symlink
34
+ * into a managed repo. Since pi keeps everything under `~/.pi`, that made the
35
+ * relay root unopenable and the whole extension silently inert.
36
+ *
37
+ * Resolving once at startup keeps the property that matters. Every later
38
+ * operation traverses only real directories, so there is no user symlink left
39
+ * in the path to swap; repointing `~/.pi` afterwards cannot redirect this
40
+ * process. It is a pin, not a bypass.
41
+ *
42
+ * The root may not exist yet on a first run, so resolve the deepest ancestor
43
+ * that does and re-apply the remainder.
44
+ */
45
+ export function resolveRelayRoot(root) {
46
+ const absolute = path.resolve(root);
47
+ const tail = [];
48
+ let current = absolute;
49
+ for (;;) {
50
+ try {
51
+ return path.join(fs.realpathSync(current), ...tail);
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
55
+ throw error;
56
+ const parent = path.dirname(current);
57
+ // Filesystem root does not exist: nothing sane left to resolve against.
58
+ if (parent === current)
59
+ return absolute;
60
+ tail.unshift(path.basename(current));
61
+ current = parent;
62
+ }
63
+ }
64
+ }
26
65
  export function ensureRoot(root) {
27
66
  const directory = openRelayRoot(root, true);
28
67
  directory.close();
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
10
10
  import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
11
11
  import { ackClaimedLetter, appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readClaimedLetter, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
12
12
  import { BACKLOG_CAP, OutboundPolicy, inboundAccepts } from './policy.js';
13
- import { claimAlias, clearAlias, deriveAddr, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
13
+ import { claimAlias, clearAlias, deriveAddr, ensureRoot, resolveRelayRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
14
14
  const require = createRequire(import.meta.url);
15
15
  const dirs = [];
16
16
  function tmpRoot() {
@@ -615,3 +615,35 @@ describe('format', () => {
615
615
  expect(refusalAmbiguous('al', ['"alpha" (aaaa11)', '"alpine" (bbbb22)'])).toContain('ambiguous');
616
616
  });
617
617
  });
618
+ describe('relay root resolution', () => {
619
+ // pi's home is commonly a dotfiles symlink (`~/.pi -> ~/Developer/dotfiles/home/.pi`).
620
+ // The traversal hardening refuses user-controlled ancestor symlinks, so an
621
+ // unresolved root made every relay operation throw -- and because
622
+ // session_start swallowed it, relay went silently inert machine-wide.
623
+ // Note the rest of this suite calls fs.realpathSync.native() on its temp
624
+ // dirs, which is exactly why that path was never covered.
625
+ it('resolves a user-symlinked ancestor so the hardened root can be opened', () => {
626
+ const real = tmpRoot();
627
+ const link = path.join(tmpRoot(), 'link');
628
+ fs.symlinkSync(real, link, 'dir');
629
+ const root = path.join(link, 'relay');
630
+ // The hardening rejects the symlinked path itself -- deliberately.
631
+ expect(() => ensureRoot(root)).toThrow(/symlink/i);
632
+ // Resolving first yields the same directory by its real path, which opens.
633
+ const resolved = resolveRelayRoot(root);
634
+ expect(resolved).toBe(path.join(real, 'relay'));
635
+ expect(() => ensureRoot(resolved)).not.toThrow();
636
+ });
637
+ it('resolves a root whose directories do not exist yet', () => {
638
+ const real = tmpRoot();
639
+ const link = path.join(tmpRoot(), 'link');
640
+ fs.symlinkSync(real, link, 'dir');
641
+ // First run: nothing under the root exists, so the deepest existing
642
+ // ancestor is resolved and the missing tail re-applied.
643
+ expect(resolveRelayRoot(path.join(link, 'a', 'b', 'relay'))).toBe(path.join(real, 'a', 'b', 'relay'));
644
+ });
645
+ it('leaves an already-real path unchanged', () => {
646
+ const real = tmpRoot();
647
+ expect(resolveRelayRoot(path.join(real, 'relay'))).toBe(path.join(real, 'relay'));
648
+ });
649
+ });
package/index.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  claimAlias,
56
56
  deriveAddr,
57
57
  ensureRoot,
58
+ resolveRelayRoot,
58
59
  isValidAliasName,
59
60
  listAliases,
60
61
  listRecords,
@@ -69,6 +70,13 @@ import {
69
70
 
70
71
  type AskOutcome = { replied: true; body: string; from: string } | { replied: false; reason: string };
71
72
 
73
+ /** What to tell the caller when there is no session record to work with. */
74
+ function describeUninitialized(startupError: string | undefined, root: string): string {
75
+ return startupError
76
+ ? `Relay failed to start: ${startupError} (relay root: ${root})`
77
+ : 'Relay is not initialized (no session_start yet).';
78
+ }
79
+
72
80
  function toolResult(text: string, details: Record<string, unknown> = {}) {
73
81
  return { content: [{ type: 'text' as const, text }], details };
74
82
  }
@@ -166,10 +174,19 @@ function collectResumableSessionIds(): Set<string> {
166
174
  }
167
175
 
168
176
  export default function relay(pi: ExtensionAPI) {
169
- const root = process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay');
177
+ // Resolve before any relay I/O: pi's home is commonly a dotfiles symlink,
178
+ // and the traversal hardening (rightly) refuses user-controlled ancestor
179
+ // symlinks on every operation. See resolveRelayRoot.
180
+ const root = resolveRelayRoot(process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay'));
170
181
  const policy = new OutboundPolicy();
171
182
 
172
183
  let self: SessionRecord | undefined;
184
+ // Why registration failed, if it did. Without this a startup failure is
185
+ // indistinguishable from "session_start hasn't fired yet", and the whole
186
+ // extension goes quietly inert -- which is exactly how a symlinked pi home
187
+ // disabled relay machine-wide for a day and a half before anyone noticed.
188
+ let startupError: string | undefined;
189
+ const uninitializedText = (): string => describeUninitialized(startupError, root);
173
190
  let unwatch: (() => void) | undefined;
174
191
  let heartbeat: ReturnType<typeof setInterval> | undefined;
175
192
  const askWaiters = new Map<string, (outcome: AskOutcome) => void>();
@@ -478,6 +495,7 @@ export default function relay(pi: ExtensionAPI) {
478
495
 
479
496
  pi.on('session_start', (_event, ctx: ExtensionContext) => {
480
497
  try {
498
+ startupError = undefined;
481
499
  ensureRoot(root);
482
500
  const sessionId = ctx.sessionManager.getSessionId();
483
501
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
@@ -524,8 +542,11 @@ export default function relay(pi: ExtensionAPI) {
524
542
  // processing"); by the time this fires, steer/triggerTurn work.
525
543
  const initialDrain = setTimeout(checkInbox, 1200);
526
544
  initialDrain.unref();
527
- } catch {
528
- // registration failure never breaks the session
545
+ } catch (error) {
546
+ // A failure here must never break the session -- but it must not be
547
+ // invisible either. Record it so every tool can say what actually
548
+ // happened instead of implying session_start simply hasn't run.
549
+ startupError = error instanceof Error ? error.message : String(error);
529
550
  }
530
551
  });
531
552
 
@@ -588,7 +609,7 @@ export default function relay(pi: ExtensionAPI) {
588
609
  }),
589
610
 
590
611
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
591
- if (!self) return toolResult('Relay is not initialized (no session_start yet).');
612
+ if (!self) return toolResult(uninitializedText());
592
613
 
593
614
  switch (params.action) {
594
615
  case 'list':
@@ -823,7 +844,7 @@ export default function relay(pi: ExtensionAPI) {
823
844
  if (sub === 'log') {
824
845
  const limit = Math.max(1, Math.min(500, Number(rest[0] ?? 50)));
825
846
  const entries = self ? readAudit(root, limit) : [];
826
- const text = self ? formatAudit(entries) : 'Relay is not initialized (no session_start yet).';
847
+ const text = self ? formatAudit(entries) : uninitializedText();
827
848
  if (!self || !ctx.hasUI) {
828
849
  pi.sendMessage({ customType: AUDIT_TYPE, content: text, display: true, details: { kind: 'relay-audit' } });
829
850
  return;
@@ -833,9 +854,7 @@ export default function relay(pi: ExtensionAPI) {
833
854
  }
834
855
  if (!self || !ctx.hasUI) {
835
856
  // Plain-text fallback (print/rpc mode): the entry renderer never runs there.
836
- const text = self
837
- ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r))
838
- : 'Relay is not initialized (no session_start yet).';
857
+ const text = self ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r)) : uninitializedText();
839
858
  pi.sendMessage({
840
859
  customType: LIST_TYPE,
841
860
  content: text,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-relay",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Brokerless session-to-session messaging for pi — a file mailbox that outlives the process, with ask/reply coordination. Formerly @nicknisi/pi-intercom.",
5
5
  "keywords": [
6
6
  "pi",
package/registry.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import * as crypto from 'node:crypto';
19
+ import * as fs from 'node:fs';
19
20
  import * as path from 'node:path';
20
21
  import {
21
22
  assertPathSegment,
@@ -47,6 +48,42 @@ export function deriveAddr(cwd: string, sessionId: string): string {
47
48
  return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
48
49
  }
49
50
 
51
+ /**
52
+ * Pin the relay root to its real location, once, before any relay I/O.
53
+ *
54
+ * The traversal hardening refuses *user-controlled* ancestor symlinks, because
55
+ * a user-writable symlink can be swapped between the check and the use. That
56
+ * is the right rule for every operation after startup — but it also rejects a
57
+ * legitimate, stable arrangement: a dotfiles setup where `~/.pi` is a symlink
58
+ * into a managed repo. Since pi keeps everything under `~/.pi`, that made the
59
+ * relay root unopenable and the whole extension silently inert.
60
+ *
61
+ * Resolving once at startup keeps the property that matters. Every later
62
+ * operation traverses only real directories, so there is no user symlink left
63
+ * in the path to swap; repointing `~/.pi` afterwards cannot redirect this
64
+ * process. It is a pin, not a bypass.
65
+ *
66
+ * The root may not exist yet on a first run, so resolve the deepest ancestor
67
+ * that does and re-apply the remainder.
68
+ */
69
+ export function resolveRelayRoot(root: string): string {
70
+ const absolute = path.resolve(root);
71
+ const tail: string[] = [];
72
+ let current = absolute;
73
+ for (;;) {
74
+ try {
75
+ return path.join(fs.realpathSync(current), ...tail);
76
+ } catch (error) {
77
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
78
+ const parent = path.dirname(current);
79
+ // Filesystem root does not exist: nothing sane left to resolve against.
80
+ if (parent === current) return absolute;
81
+ tail.unshift(path.basename(current));
82
+ current = parent;
83
+ }
84
+ }
85
+ }
86
+
50
87
  export function ensureRoot(root: string): void {
51
88
  const directory = openRelayRoot(root, true)!;
52
89
  directory.close();