@nicknisi/pi-relay 0.3.1 → 0.3.3
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 +1 -0
- package/dist/filesystem.js +12 -1
- package/dist/index.js +26 -9
- package/dist/registry.d.ts +19 -0
- package/dist/registry.js +39 -0
- package/dist/relay.test.js +33 -1
- package/filesystem.ts +14 -1
- package/index.ts +27 -8
- package/package.json +1 -1
- package/registry.ts +37 -0
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ The directory is created `0700` and every file `0600` — other users on the mac
|
|
|
101
101
|
## Caveats
|
|
102
102
|
|
|
103
103
|
- **Mailbox semantics support Linux and macOS only.** Relay rejects user-controlled symlinks in configured-root components, permits protected root-owned system aliases such as macOS `/var`, pins root, mailbox, and durable-claim descriptors for each operation, and uses descriptor-relative `openat`/`renameat`/`unlinkat` calls, atomic renames, descriptor polling for watches, and `0600`/`0700` permission bits. Windows is unsupported.
|
|
104
|
+
- **Bun <= 1.3.14 refused.** Those Bun versions abort the process when koffi's GC finalizer releases an N-API reference ([oven-sh/bun#39263](https://github.com/oven-sh/bun/issues/39263), fixed upstream after 1.3.14), so relay fails load with a clear error on them instead. Node.js and newer Bun load normally.
|
|
104
105
|
- **One machine.** Delivery is a file landing in a directory; two sessions reach each other exactly when they share a filesystem. A container and its host cannot.
|
|
105
106
|
- **Presence is heartbeat-accurate**, not instantaneous (within ~45s).
|
|
106
107
|
- Delivery injects with `deliverAs: "steer"` (lands between tool calls) and `triggerTurn: true` (wakes an idle session).
|
package/dist/filesystem.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/** Descriptor-relative, symlink-safe filesystem operations for the relay store. Pi-free. */
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import * as path from 'node:path';
|
|
5
|
-
import koffi from 'koffi';
|
|
6
6
|
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
7
7
|
throw new Error(`Relay filesystem operations are unsupported on ${process.platform}`);
|
|
8
8
|
}
|
|
9
|
+
// Bun <= 1.3.14 aborts the whole process when koffi's GC finalizer releases
|
|
10
|
+
// an N-API reference (oven-sh/bun#39263, fixed after 1.3.14). Fail extension
|
|
11
|
+
// load cleanly on those versions instead.
|
|
12
|
+
const bunVersion = globalThis.Bun?.version;
|
|
13
|
+
if (bunVersion !== undefined) {
|
|
14
|
+
const [major = 0, minor = 0, patch = 0] = bunVersion.split('-')[0].split('.').map(Number);
|
|
15
|
+
if (major < 1 || (major === 1 && (minor < 3 || (minor === 3 && patch <= 14)))) {
|
|
16
|
+
throw new Error(`Relay requires Node.js or Bun > 1.3.14: koffi crashes Bun ${bunVersion} (oven-sh/bun#39263)`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const koffi = createRequire(import.meta.url)('koffi');
|
|
9
20
|
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
10
21
|
const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
|
|
11
22
|
const CLOEXEC = process.platform === 'darwin' ? 0x01000000 : 0x00080000;
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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) :
|
|
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,
|
package/dist/registry.d.ts
CHANGED
|
@@ -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();
|
package/dist/relay.test.js
CHANGED
|
@@ -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/filesystem.ts
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
/** Descriptor-relative, symlink-safe filesystem operations for the relay store. Pi-free. */
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import * as fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import * as path from 'node:path';
|
|
5
|
-
import koffi from 'koffi';
|
|
6
6
|
|
|
7
7
|
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
8
8
|
throw new Error(`Relay filesystem operations are unsupported on ${process.platform}`);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// Bun <= 1.3.14 aborts the whole process when koffi's GC finalizer releases
|
|
12
|
+
// an N-API reference (oven-sh/bun#39263, fixed after 1.3.14). Fail extension
|
|
13
|
+
// load cleanly on those versions instead.
|
|
14
|
+
const bunVersion = (globalThis as { Bun?: { version?: string } }).Bun?.version;
|
|
15
|
+
if (bunVersion !== undefined) {
|
|
16
|
+
const [major = 0, minor = 0, patch = 0] = bunVersion.split('-')[0]!.split('.').map(Number);
|
|
17
|
+
if (major < 1 || (major === 1 && (minor < 3 || (minor === 3 && patch <= 14)))) {
|
|
18
|
+
throw new Error(`Relay requires Node.js or Bun > 1.3.14: koffi crashes Bun ${bunVersion} (oven-sh/bun#39263)`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const koffi = createRequire(import.meta.url)('koffi') as typeof import('koffi');
|
|
23
|
+
|
|
11
24
|
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
12
25
|
const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
|
|
13
26
|
const CLOEXEC = process.platform === 'darwin' ? 0x01000000 : 0x00080000;
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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) :
|
|
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.
|
|
3
|
+
"version": "0.3.3",
|
|
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();
|