@nicknisi/pi-relay 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.
@@ -0,0 +1,358 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+ import * as core from './core.js';
8
+ const relayDir = path.dirname(fileURLToPath(import.meta.url));
9
+ const repoRoot = path.resolve(relayDir, '../..');
10
+ const packageJson = JSON.parse(fs.readFileSync(path.join(relayDir, 'package.json'), 'utf8'));
11
+ const fixtures = [];
12
+ function fixture() {
13
+ const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'pi-relay-core-')));
14
+ fixtures.push(dir);
15
+ return dir;
16
+ }
17
+ function letter(over = {}) {
18
+ return {
19
+ id: '019fd000-0000-0000-0000-000000000001',
20
+ from: { addr: core.deriveAddr('/sender', 'session'), name: 'sender', cwd: '/sender' },
21
+ kind: 'ask',
22
+ body: 'question',
23
+ ts: Date.now(),
24
+ ...over,
25
+ };
26
+ }
27
+ afterEach(() => {
28
+ while (fixtures.length > 0)
29
+ fs.rmSync(fixtures.pop(), { recursive: true, force: true });
30
+ });
31
+ describe('/core filesystem boundary', () => {
32
+ it('rejects traversal in every address-bearing operation', async () => {
33
+ const base = fixture();
34
+ const root = path.join(base, 'relay');
35
+ const badAddr = '../../../escaped';
36
+ const validAddr = core.deriveAddr('/receiver', 'session');
37
+ const validLetter = letter();
38
+ const badRecord = {
39
+ addr: badAddr,
40
+ sessionId: 'session',
41
+ name: 'bad',
42
+ cwd: '/tmp',
43
+ pid: process.pid,
44
+ startedAt: Date.now(),
45
+ lastSeenAt: Date.now(),
46
+ status: 'idle',
47
+ };
48
+ const calls = [
49
+ () => core.readRecord(root, badAddr),
50
+ () => core.presenceOf(badRecord),
51
+ () => core.claimAlias(root, 'safe', badAddr, 'session'),
52
+ () => core.deposit(root, badAddr, validLetter),
53
+ () => core.deposit(root, validAddr, letter({ from: { ...validLetter.from, addr: badAddr } })),
54
+ () => core.drain(root, badAddr),
55
+ () => core.unreadCount(root, badAddr),
56
+ () => core.claimInbox(root, badAddr),
57
+ () => core.recoverInboxClaims(root, badAddr),
58
+ () => core.readClaimedLetter(root, badAddr, '0'.repeat(32), `${validLetter.ts}-${validLetter.id}.json`),
59
+ () => core.ackClaimedLetter(root, badAddr, '0'.repeat(32), `${validLetter.ts}-${validLetter.id}.json`),
60
+ () => core.requeueClaimedLetter(root, badAddr, '0'.repeat(32), `${validLetter.ts}-${validLetter.id}.json`),
61
+ () => core.watchInbox(root, badAddr, () => { }),
62
+ () => core.trackIncomingAsk(root, badAddr, validLetter),
63
+ () => core.trackOutgoingAsk(root, badAddr, {
64
+ askId: validLetter.id,
65
+ toAddr: validAddr,
66
+ body: 'question',
67
+ ts: Date.now(),
68
+ }),
69
+ () => core.trackOutgoingAsk(root, validAddr, {
70
+ askId: validLetter.id,
71
+ toAddr: badAddr,
72
+ body: 'question',
73
+ ts: Date.now(),
74
+ }),
75
+ () => core.readIncomingAsk(root, badAddr, validLetter.id),
76
+ () => core.readOutgoingAsk(root, badAddr, validLetter.id),
77
+ () => core.clearAsk(root, badAddr, validLetter.id),
78
+ () => core.resolveAskByRef(root, badAddr, validLetter.id),
79
+ () => core.pendingAsks(root, badAddr),
80
+ ];
81
+ for (const call of calls)
82
+ expect(call).toThrow(TypeError);
83
+ await expect(core.awaitReceipt(root, badAddr, validLetter, 1)).rejects.toThrow(TypeError);
84
+ expect(fs.readdirSync(base)).toEqual([]);
85
+ });
86
+ it.runIf(process.platform === 'darwin')('creates an exact /var temp root through the protected system link', () => {
87
+ const canonicalBase = fixture();
88
+ expect(canonicalBase.startsWith('/private/var/')).toBe(true);
89
+ const root = path.join('/var', path.relative('/private/var', canonicalBase), 'relay');
90
+ const addr = core.deriveAddr('/receiver', 'session');
91
+ const validLetter = letter();
92
+ core.deposit(root, addr, validLetter);
93
+ expect(fs.realpathSync.native(root)).toBe(path.join(canonicalBase, 'relay'));
94
+ expect(core.drain(root, addr)).toEqual([validLetter]);
95
+ });
96
+ it('rejects symlinked roots and user-controlled symlinked ancestor components', () => {
97
+ const base = fixture();
98
+ const outside = path.join(base, 'outside');
99
+ const root = path.join(base, 'relay');
100
+ const addr = core.deriveAddr('/receiver', 'session');
101
+ fs.mkdirSync(outside);
102
+ fs.symlinkSync(outside, root, 'dir');
103
+ expect(() => core.listRecords(root)).toThrow(/symlink/i);
104
+ expect(() => core.deposit(root, addr, letter())).toThrow(/symlink/i);
105
+ expect(fs.readdirSync(outside)).toEqual([]);
106
+ fs.unlinkSync(root);
107
+ const ancestor = path.join(base, 'ancestor');
108
+ fs.symlinkSync(outside, ancestor, 'dir');
109
+ const nestedRoot = path.join(ancestor, 'nested', 'relay');
110
+ expect(() => core.listRecords(nestedRoot)).toThrow(/symlink/i);
111
+ expect(() => core.deposit(nestedRoot, addr, letter())).toThrow(/symlink/i);
112
+ expect(fs.readdirSync(outside)).toEqual([]);
113
+ });
114
+ it('rejects symlinked inbox and ask directories in every access mode', async () => {
115
+ const base = fixture();
116
+ const root = path.join(base, 'relay');
117
+ const outside = path.join(base, 'outside');
118
+ const addr = core.deriveAddr('/receiver', 'session');
119
+ const validLetter = letter();
120
+ fs.mkdirSync(root);
121
+ fs.mkdirSync(outside);
122
+ fs.symlinkSync(outside, path.join(root, `${addr}.inbox`), 'dir');
123
+ for (const call of [
124
+ () => core.deposit(root, addr, validLetter),
125
+ () => core.drain(root, addr),
126
+ () => core.unreadCount(root, addr),
127
+ () => core.claimInbox(root, addr),
128
+ () => core.watchInbox(root, addr, () => { }),
129
+ ]) {
130
+ expect(call).toThrow(/symlink/i);
131
+ }
132
+ await expect(core.awaitReceipt(root, addr, validLetter, 1)).rejects.toThrow(/symlink/i);
133
+ fs.unlinkSync(path.join(root, `${addr}.inbox`));
134
+ fs.symlinkSync(outside, path.join(root, `${addr}.asks`), 'dir');
135
+ const out = {
136
+ askId: validLetter.id,
137
+ toAddr: core.deriveAddr('/peer', 'session'),
138
+ body: 'question',
139
+ ts: Date.now(),
140
+ };
141
+ for (const call of [
142
+ () => core.trackIncomingAsk(root, addr, validLetter),
143
+ () => core.trackOutgoingAsk(root, addr, out),
144
+ () => core.readIncomingAsk(root, addr, validLetter.id),
145
+ () => core.readOutgoingAsk(root, addr, validLetter.id),
146
+ () => core.clearAsk(root, addr, validLetter.id),
147
+ () => core.pendingAsks(root, addr),
148
+ () => core.resolveAskByRef(root, addr, validLetter.id),
149
+ ]) {
150
+ expect(call).toThrow(/symlink/i);
151
+ }
152
+ expect(fs.readdirSync(outside)).toEqual([]);
153
+ });
154
+ it('rejects symlinked aliases, records, letters, asks, and audit files without following them', () => {
155
+ const base = fixture();
156
+ const root = path.join(base, 'relay');
157
+ const outside = path.join(base, 'outside');
158
+ const addr = core.deriveAddr('/receiver', 'session');
159
+ const validLetter = letter();
160
+ const sentinel = path.join(outside, 'sentinel.json');
161
+ fs.mkdirSync(root);
162
+ fs.mkdirSync(outside);
163
+ fs.writeFileSync(sentinel, '{"sentinel":true}');
164
+ fs.symlinkSync(outside, path.join(root, 'aliases'), 'dir');
165
+ for (const call of [
166
+ () => core.claimAlias(root, 'ci', addr, 'session'),
167
+ () => core.readAlias(root, 'ci'),
168
+ () => core.listAliases(root),
169
+ () => core.clearAlias(root, 'ci'),
170
+ ]) {
171
+ expect(call).toThrow(/symlink/i);
172
+ }
173
+ fs.unlinkSync(path.join(root, 'aliases'));
174
+ fs.symlinkSync(sentinel, path.join(root, `${addr}.json`), 'file');
175
+ expect(() => core.readRecord(root, addr)).toThrow(/symlink/i);
176
+ expect(() => core.listRecords(root)).toThrow(/symlink/i);
177
+ fs.unlinkSync(path.join(root, `${addr}.json`));
178
+ const inbox = path.join(root, `${addr}.inbox`);
179
+ fs.mkdirSync(inbox);
180
+ fs.symlinkSync(sentinel, path.join(inbox, `${validLetter.ts}-${validLetter.id}.json`), 'file');
181
+ expect(() => core.drain(root, addr)).toThrow(/symlink/i);
182
+ fs.rmSync(inbox, { recursive: true, force: true });
183
+ const asks = path.join(root, `${addr}.asks`);
184
+ fs.mkdirSync(asks);
185
+ fs.symlinkSync(sentinel, path.join(asks, `${validLetter.id}.json`), 'file');
186
+ expect(() => core.readIncomingAsk(root, addr, validLetter.id)).toThrow(/symlink/i);
187
+ expect(() => core.clearAsk(root, addr, validLetter.id)).toThrow(/symlink/i);
188
+ fs.rmSync(asks, { recursive: true, force: true });
189
+ fs.symlinkSync(sentinel, path.join(root, 'audit.log'), 'file');
190
+ expect(() => core.readAudit(root)).toThrow(/symlink/i);
191
+ expect(() => core.deposit(root, addr, validLetter)).toThrow(/symlink/i);
192
+ expect(fs.readFileSync(sentinel, 'utf8')).toBe('{"sentinel":true}');
193
+ expect(fs.existsSync(path.join(root, `${addr}.inbox`))).toBe(false);
194
+ });
195
+ it('durably claims same-millisecond letters without collisions and recovers by opaque tokens', async () => {
196
+ const base = fixture();
197
+ const root = path.join(base, 'relay');
198
+ const addr = core.deriveAddr('/receiver', 'session');
199
+ const ts = Date.now();
200
+ const first = letter({ id: 'reply-prefix-000000000000000000000001', kind: 'reply', ts, body: 'first' });
201
+ const second = letter({ id: 'reply-prefix-000000000000000000000002', kind: 'reply', ts, body: 'second' });
202
+ core.deposit(root, addr, first);
203
+ core.deposit(root, addr, second);
204
+ expect(core.unreadCount(root, addr)).toBe(2);
205
+ const claim = core.claimInbox(root, addr);
206
+ expect(claim.claimToken).toMatch(/^[a-f0-9]{32}$/);
207
+ expect(claim.claimToken).not.toContain('/');
208
+ expect(claim.fileTokens).toEqual([`${ts}-${first.id}.json`, `${ts}-${second.id}.json`]);
209
+ expect(claim.fileTokens.every((token) => !token.includes('/'))).toBe(true);
210
+ expect(core.unreadCount(root, addr)).toBe(0);
211
+ expect(await core.awaitReceipt(root, addr, first, 1)).toBe('queued');
212
+ // A new process can recover the same stable claim/file tokens and read
213
+ // letters without retaining a path or descriptor from claimInbox.
214
+ expect(core.recoverInboxClaims(root, addr)).toEqual([claim]);
215
+ expect(core.readClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])?.body).toBe('first');
216
+ expect(core.readClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[1])?.body).toBe('second');
217
+ const journal = fs.openSync(path.join(base, 'journal'), 'w');
218
+ fs.writeFileSync(journal, JSON.stringify({ claim: claim.claimToken, file: claim.fileTokens[0] }));
219
+ fs.fsyncSync(journal);
220
+ fs.closeSync(journal);
221
+ expect(core.ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
222
+ expect(await core.awaitReceipt(root, addr, first, 1)).toBe('delivered');
223
+ const later = letter({ id: 'later-message', ts: ts + 1, body: 'new inbox' });
224
+ core.deposit(root, addr, later);
225
+ expect(core.requeueClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[1])).toBe(true);
226
+ expect(core.recoverInboxClaims(root, addr)).toEqual([]);
227
+ expect(core.drain(root, addr).map((entry) => entry.body)).toEqual(['second', 'new inbox']);
228
+ });
229
+ it('never follows symlinked durable claims or claimed letters', () => {
230
+ const base = fixture();
231
+ const root = path.join(base, 'relay');
232
+ const outside = path.join(base, 'outside');
233
+ const addr = core.deriveAddr('/receiver', 'session');
234
+ const claimToken = 'a'.repeat(32);
235
+ const validLetter = letter();
236
+ const fileToken = `${validLetter.ts}-${validLetter.id}.json`;
237
+ const sentinel = path.join(outside, 'sentinel.json');
238
+ fs.mkdirSync(root);
239
+ fs.mkdirSync(outside);
240
+ fs.writeFileSync(sentinel, 'sentinel');
241
+ fs.symlinkSync(outside, path.join(root, `${addr}.claims`), 'dir');
242
+ core.deposit(root, addr, validLetter);
243
+ for (const call of [
244
+ () => core.claimInbox(root, addr),
245
+ () => core.recoverInboxClaims(root, addr),
246
+ () => core.readClaimedLetter(root, addr, claimToken, fileToken),
247
+ () => core.ackClaimedLetter(root, addr, claimToken, fileToken),
248
+ () => core.requeueClaimedLetter(root, addr, claimToken, fileToken),
249
+ ]) {
250
+ expect(call).toThrow(/symlink/i);
251
+ }
252
+ fs.unlinkSync(path.join(root, `${addr}.claims`));
253
+ const claims = path.join(root, `${addr}.claims`);
254
+ fs.mkdirSync(claims);
255
+ fs.symlinkSync(outside, path.join(claims, claimToken), 'dir');
256
+ for (const call of [
257
+ () => core.recoverInboxClaims(root, addr),
258
+ () => core.readClaimedLetter(root, addr, claimToken, fileToken),
259
+ () => core.ackClaimedLetter(root, addr, claimToken, fileToken),
260
+ () => core.requeueClaimedLetter(root, addr, claimToken, fileToken),
261
+ ]) {
262
+ expect(call).toThrow(/symlink/i);
263
+ }
264
+ fs.unlinkSync(path.join(claims, claimToken));
265
+ const claim = path.join(claims, claimToken);
266
+ fs.mkdirSync(claim);
267
+ fs.symlinkSync(sentinel, path.join(claim, fileToken), 'file');
268
+ for (const call of [
269
+ () => core.recoverInboxClaims(root, addr),
270
+ () => core.readClaimedLetter(root, addr, claimToken, fileToken),
271
+ () => core.ackClaimedLetter(root, addr, claimToken, fileToken),
272
+ () => core.requeueClaimedLetter(root, addr, claimToken, fileToken),
273
+ ]) {
274
+ expect(call).toThrow(/symlink/i);
275
+ }
276
+ expect(fs.readFileSync(sentinel, 'utf8')).toBe('sentinel');
277
+ });
278
+ it('rejects traversal in aliases, claims, and ask/message identifiers', async () => {
279
+ const base = fixture();
280
+ const root = path.join(base, 'relay');
281
+ const addr = core.deriveAddr('/receiver', 'session');
282
+ const validLetter = letter();
283
+ const badId = '../../../escaped';
284
+ const badAlias = '../../../escaped';
285
+ const calls = [
286
+ () => core.readAlias(root, badAlias),
287
+ () => core.claimAlias(root, badAlias, addr, 'session'),
288
+ () => core.clearAlias(root, badAlias),
289
+ () => core.deposit(root, addr, letter({ id: badId })),
290
+ () => core.deposit(root, addr, letter({ replyTo: badId })),
291
+ () => core.trackIncomingAsk(root, addr, letter({ id: badId })),
292
+ () => core.trackOutgoingAsk(root, addr, {
293
+ askId: badId,
294
+ toAddr: addr,
295
+ body: 'question',
296
+ ts: Date.now(),
297
+ }),
298
+ () => core.readIncomingAsk(root, addr, badId),
299
+ () => core.readOutgoingAsk(root, addr, badId),
300
+ () => core.clearAsk(root, addr, badId),
301
+ () => core.resolveAskByRef(root, addr, badId),
302
+ () => core.readClaimedLetter(root, addr, badId, `${validLetter.ts}-${validLetter.id}.json`),
303
+ () => core.ackClaimedLetter(root, addr, '0'.repeat(32), badId),
304
+ () => core.requeueClaimedLetter(root, addr, '0'.repeat(32), badId),
305
+ ];
306
+ for (const call of calls)
307
+ expect(call).toThrow(TypeError);
308
+ await expect(core.awaitReceipt(root, addr, letter({ id: badId }), 1)).rejects.toThrow(TypeError);
309
+ expect(fs.readdirSync(base)).toEqual([]);
310
+ fs.mkdirSync(root);
311
+ fs.writeFileSync(path.join(root, `${addr}.json`), JSON.stringify({ addr: badId, pid: 2_000_000_000 }));
312
+ expect(() => core.sweep(root, Date.now(), () => false)).toThrow(TypeError);
313
+ expect(fs.readdirSync(base)).toEqual(['relay']);
314
+ });
315
+ });
316
+ it('keeps the Pi extension entry importable', async () => {
317
+ const entry = await import('./index.js');
318
+ expect(typeof entry.default).toBe('function');
319
+ });
320
+ it('builds, packs, installs, and imports the real core package without Pi or TUI', () => {
321
+ const base = fixture();
322
+ const tarball = path.join(base, 'relay.tgz');
323
+ const appDir = path.join(base, 'app');
324
+ fs.mkdirSync(appDir);
325
+ execFileSync('pnpm', ['--filter', packageJson.name, 'build'], { cwd: repoRoot, stdio: 'pipe' });
326
+ execFileSync('pnpm', ['pack', '--out', tarball], { cwd: relayDir, stdio: 'pipe' });
327
+ fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({
328
+ private: true,
329
+ type: 'module',
330
+ dependencies: { [packageJson.name]: 'file:../relay.tgz' },
331
+ }));
332
+ execFileSync('pnpm', ['install', '--offline', '--ignore-scripts'], { cwd: appDir, stdio: 'pipe' });
333
+ const installedDir = path.join(appDir, 'node_modules', '@nicknisi', 'pi-relay');
334
+ const installedPackage = JSON.parse(fs.readFileSync(path.join(installedDir, 'package.json'), 'utf8'));
335
+ const coreExport = installedPackage.exports['./core'];
336
+ expect(coreExport).toBeDefined();
337
+ for (const target of Object.values(coreExport)) {
338
+ expect(fs.existsSync(path.join(installedDir, target))).toBe(true);
339
+ }
340
+ expect(fs.existsSync(path.join(installedDir, 'filesystem.ts'))).toBe(true);
341
+ expect(packageJson.peerDependenciesMeta['@earendil-works/pi-coding-agent']?.optional).toBe(true);
342
+ expect(packageJson.peerDependenciesMeta['@earendil-works/pi-tui']?.optional).toBe(true);
343
+ expect(fs.existsSync(path.join(appDir, 'node_modules', '@earendil-works', 'pi-coding-agent'))).toBe(false);
344
+ expect(fs.existsSync(path.join(appDir, 'node_modules', '@earendil-works', 'pi-tui'))).toBe(false);
345
+ const output = execFileSync(process.execPath, [
346
+ '--input-type=module',
347
+ '--eval',
348
+ `const core = await import('@nicknisi/pi-relay/core');
349
+ for (const name of ['deriveAddr', 'deposit', 'drain', 'claimInbox', 'recoverInboxClaims', 'readClaimedLetter', 'ackClaimedLetter', 'requeueClaimedLetter', 'claimAlias', 'pendingAsks', 'OutboundPolicy']) {
350
+ if (typeof core[name] !== 'function') throw new Error(\`Missing core export: \${name}\`);
351
+ }
352
+ for (const name of ['recordPath', 'inboxDir', 'asksDir', 'aliasPath', 'auditLogPath', 'writeRecord', 'writeAlias', 'appendAudit']) {
353
+ if (name in core) throw new Error(\`Internal helper escaped through /core: \${name}\`);
354
+ }
355
+ console.log('core-ok');`,
356
+ ], { cwd: appDir, encoding: 'utf8' });
357
+ expect(output.trim()).toBe('core-ok');
358
+ }, 120_000);
@@ -0,0 +1,42 @@
1
+ /** An existing relay path is unsafe to traverse or is not the expected type. */
2
+ export declare class RelayFilesystemError extends Error {
3
+ constructor(message: string, options?: ErrorOptions);
4
+ }
5
+ export declare function rethrowFilesystemError(error: unknown): void;
6
+ export declare function assertPathSegment(segment: string, label?: string): void;
7
+ export interface RelayDirent {
8
+ name: string;
9
+ isFile(): boolean;
10
+ isDirectory(): boolean;
11
+ isSymbolicLink(): boolean;
12
+ }
13
+ /** A pinned directory descriptor. All child and leaf access is relative to this descriptor. */
14
+ export declare class RelayDirectoryHandle {
15
+ #private;
16
+ constructor(fd: number, label: string);
17
+ close(): void;
18
+ openDirectory(name: string, create?: boolean): RelayDirectoryHandle | null;
19
+ readDirectory(): RelayDirent[];
20
+ readFile(fileName: string): string | null;
21
+ writeFileAtomic(fileName: string, contents: string): void;
22
+ unlinkFile(fileName: string): boolean;
23
+ fileExists(fileName: string): boolean;
24
+ verifyFile(fileName: string): void;
25
+ appendFile(fileName: string, contents: string): number;
26
+ renameFile(fromName: string, toName: string): void;
27
+ /** Atomically move one pinned child directory beneath another pinned directory. */
28
+ moveDirectoryTo(name: string, target: RelayDirectoryHandle, targetName: string): RelayDirectoryHandle | null;
29
+ /** Atomically move one regular file between pinned directories without following links. */
30
+ moveFileTo(fileName: string, target: RelayDirectoryHandle, targetName?: string): boolean;
31
+ /** Remove a child directory only when it is still a real, empty directory. */
32
+ removeEmptyDirectory(name: string): boolean;
33
+ sync(): void;
34
+ removeDirectory(name: string): void;
35
+ watch(listener: () => void): RelayWatcher;
36
+ }
37
+ export interface RelayWatcher {
38
+ close(): void;
39
+ unref(): void;
40
+ }
41
+ /** Open every canonical configured-root component without following symlinks and return its pinned descriptor. */
42
+ export declare function openRelayRoot(root: string, create?: boolean): RelayDirectoryHandle | null;