@goodea/olimpyx 0.1.1 → 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.
@@ -0,0 +1,157 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, lstat, mkdir, open, readFile, rename, rmdir, unlink } from 'node:fs/promises';
3
+ import { hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ const noFollow = constants.O_NOFOLLOW;
8
+ const missing = (error) => error.code === 'ENOENT';
9
+
10
+ /** Private permanent storage. Filenames are fixed, never selected by model input.
11
+ * Callers serialize read/modify/write operations with withLock().
12
+ */
13
+ export class ResidentStore {
14
+ constructor(home, { now = Date.now } = {}) {
15
+ this.root = join(home, 'resident');
16
+ this.now = now;
17
+ }
18
+
19
+ async prepare() {
20
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
21
+ if (!(await lstat(this.root)).isDirectory()) throw new Error('Resident storage must be a real directory');
22
+ await chmod(this.root, 0o700);
23
+ }
24
+
25
+ async syncDirectory() {
26
+ const directory = await open(this.root, constants.O_RDONLY | noFollow);
27
+ try { await directory.sync(); } finally { await directory.close(); }
28
+ }
29
+
30
+ async read() {
31
+ let file;
32
+ try {
33
+ file = await open(join(this.root, 'state.json'), constants.O_RDONLY | noFollow);
34
+ return JSON.parse(await file.readFile('utf8'));
35
+ } catch (error) {
36
+ if (missing(error)) return null;
37
+ throw error;
38
+ } finally { await file?.close(); }
39
+ }
40
+
41
+ async save(state) {
42
+ const content = JSON.stringify(state);
43
+ if (content === undefined) throw new Error('State must be JSON serializable');
44
+ await this.prepare();
45
+ const temporary = join(this.root, `.state-${randomUUID()}.tmp`);
46
+ let file;
47
+ try {
48
+ file = await open(temporary, 'wx', 0o600);
49
+ await file.writeFile(`${content}\n`);
50
+ await file.sync();
51
+ await file.close();
52
+ file = null;
53
+ await rename(temporary, join(this.root, 'state.json'));
54
+ await this.syncDirectory();
55
+ } finally {
56
+ await file?.close();
57
+ await unlink(temporary).catch((error) => { if (!missing(error)) throw error; });
58
+ }
59
+ }
60
+
61
+ async append(record) {
62
+ const content = JSON.stringify(record);
63
+ if (content === undefined) throw new Error('Journal record must be JSON serializable');
64
+ await this.prepare();
65
+ const file = await open(join(this.root, 'memory.jsonl'), constants.O_CREAT | constants.O_RDWR | noFollow, 0o600);
66
+ try {
67
+ await file.chmod(0o600);
68
+ let end = (await file.stat()).size;
69
+ // A newline commits a record. Remove only a torn final record before appending.
70
+ const buffer = Buffer.alloc(8192);
71
+ let position = end;
72
+ while (position > 0) {
73
+ const start = Math.max(0, position - buffer.length);
74
+ const { bytesRead } = await file.read(buffer, 0, position - start, start);
75
+ const newline = buffer.subarray(0, bytesRead).lastIndexOf(10);
76
+ if (newline >= 0) { end = start + newline + 1; break; }
77
+ position = start;
78
+ end = start;
79
+ }
80
+ await file.truncate(end);
81
+ const bytes = Buffer.from(`${content}\n`);
82
+ let written = 0;
83
+ while (written < bytes.length) {
84
+ const result = await file.write(bytes, written, bytes.length - written, end + written);
85
+ if (!result.bytesWritten) throw new Error('Journal write made no progress');
86
+ written += result.bytesWritten;
87
+ }
88
+ await file.sync();
89
+ } finally { await file.close(); }
90
+ await this.syncDirectory();
91
+ }
92
+
93
+ async readRecent(limit = 5, maxChars = 6000) {
94
+ if (!Number.isSafeInteger(limit) || limit < 0 || !Number.isSafeInteger(maxChars) || maxChars < 0 || maxChars > 1_000_000) {
95
+ throw new Error('Invalid recent-memory bounds');
96
+ }
97
+ if (!limit || !maxChars) return [];
98
+ let file;
99
+ try {
100
+ file = await open(join(this.root, 'memory.jsonl'), constants.O_RDONLY | noFollow);
101
+ const size = (await file.stat()).size;
102
+ const start = Math.max(0, size - (maxChars * 4 + 8));
103
+ const buffer = Buffer.alloc(size - start);
104
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
105
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
106
+ if (start) text = text.slice(text.indexOf('\n') + 1);
107
+ const lines = text.split('\n');
108
+ lines.pop(); // Ignore an incomplete final record, including a partial JSON token.
109
+ const records = [];
110
+ let used = 0;
111
+ for (let index = lines.length - 1; index >= 0 && records.length < limit; index -= 1) {
112
+ const line = lines[index];
113
+ if (used + line.length + 1 > maxChars) break;
114
+ records.unshift(JSON.parse(line));
115
+ used += line.length + 1;
116
+ }
117
+ return records;
118
+ } catch (error) {
119
+ if (missing(error)) return [];
120
+ throw error;
121
+ } finally { await file?.close(); }
122
+ }
123
+
124
+ async withLock(callback) {
125
+ await this.prepare();
126
+ const guard = join(this.root, '.lock-acquisition');
127
+ const path = join(this.root, 'lock.json');
128
+ const owner = { token: randomUUID(), pid: process.pid, hostname: hostname(), createdAt: this.now() };
129
+ try { await mkdir(guard, { mode: 0o700 }); } catch (error) {
130
+ if (error.code === 'EEXIST') throw new Error('Resident lock acquisition is busy; stale acquisition guards require manual inspection');
131
+ throw error;
132
+ }
133
+ try {
134
+ let existing;
135
+ try { existing = JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (!missing(error)) throw error; }
136
+ if (existing) {
137
+ if (existing.hostname !== hostname() || !Number.isSafeInteger(existing.pid) || existing.pid <= 0 || typeof existing.token !== 'string') {
138
+ throw new Error('Resident lock owner cannot be safely verified');
139
+ }
140
+ let dead = false;
141
+ try { process.kill(existing.pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
142
+ if (!dead) throw new Error('Resident operation is already active');
143
+ await unlink(path);
144
+ }
145
+ const lock = await open(path, 'wx', 0o600);
146
+ try { await lock.writeFile(JSON.stringify(owner)); await lock.sync(); } finally { await lock.close(); }
147
+ await this.syncDirectory();
148
+ } finally { await rmdir(guard); }
149
+ try { return await callback(); } finally {
150
+ const current = JSON.parse(await readFile(path, 'utf8'));
151
+ if (current.token === owner.token) {
152
+ await unlink(path);
153
+ await this.syncDirectory();
154
+ }
155
+ }
156
+ }
157
+ }
package/src/state.js CHANGED
@@ -13,12 +13,32 @@ async function atomicJson(path, value, mode = 0o600) {
13
13
  await rename(temp, path);
14
14
  }
15
15
 
16
+ // Sanitise caller-id for use as a directory segment. A caller-id is generated
17
+ // by the participant as e.g. `caller-helios-<pid>-<unix>`, so it normally
18
+ // contains only [a-z0-9-]. We still strip anything else defensively so a
19
+ // hostile caller-id cannot escape the per-caller state directory.
20
+ function safeCallerId(callerId) {
21
+ if (typeof callerId !== 'string' || !callerId.trim()) return null;
22
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(callerId)) return null;
23
+ return callerId;
24
+ }
25
+
16
26
  export class LocalState {
17
27
  constructor(root) { this.root = root; }
18
28
  async init() {
19
29
  await mkdir(this.root, { recursive: true, mode: 0o700 });
20
30
  await mkdir(join(this.root, 'persona-revisions'), { recursive: true, mode: 0o700 });
21
31
  }
32
+ // Per-caller session directory (F-04): when several participants share one
33
+ // OLIMPYX_HOME, each caller's session.json / session-credential / pending
34
+ // mutations live under their own subdir so they no longer clobber each other.
35
+ async callerDir(callerId) {
36
+ const id = safeCallerId(callerId);
37
+ if (!id) return this.root;
38
+ const dir = join(this.root, 'calls', id);
39
+ await mkdir(dir, { recursive: true, mode: 0o700 });
40
+ return dir;
41
+ }
22
42
  async loadConfig() { return readJson(join(this.root, 'config.json'), {}); }
23
43
  async saveConfig(config) { await this.init(); await atomicJson(join(this.root, 'config.json'), config); return config; }
24
44
  async loadCredential() { return (await readFile(join(this.root, 'credential'), 'utf8')).trim(); }
@@ -34,46 +54,52 @@ export class LocalState {
34
54
  await this.init(); const path = join(this.root, 'owner-credential');
35
55
  await writeFile(path, token, { mode: 0o600 }); await chmod(path, 0o600);
36
56
  }
37
- async loadSession() {
38
- const metadata = await readJson(join(this.root, 'session.json'), null);
57
+ async loadSession(callerId) {
58
+ const dir = await this.callerDir(callerId);
59
+ const metadata = await readJson(join(dir, 'session.json'), null);
39
60
  if (!metadata) return null;
40
- return { ...metadata, token: (await readFile(join(this.root, 'session-credential'), 'utf8')).trim() };
61
+ return { ...metadata, token: (await readFile(join(dir, 'session-credential'), 'utf8')).trim() };
41
62
  }
42
63
  async saveSession(session, callerId, callerLeaseMs = 75_000) {
43
64
  if (!session?.session_id || !session?.session_token || !callerId) throw new Error('Complete session and callerId are required');
44
- await this.init();
45
- const credentialPath = join(this.root, 'session-credential');
65
+ const dir = await this.callerDir(callerId);
66
+ const credentialPath = join(dir, 'session-credential');
46
67
  await writeFile(credentialPath, session.session_token, { mode: 0o600 }); await chmod(credentialPath, 0o600);
47
68
  const metadata = { session_id: session.session_id, expires_at: session.expires_at, caller_id: callerId, caller_deadline: new Date(Date.now() + Math.min(callerLeaseMs, 85_000)).toISOString(), inbox_cursor: session.inbox_cursor ?? null };
48
- await atomicJson(join(this.root, 'session.json'), metadata); return metadata;
69
+ await atomicJson(join(dir, 'session.json'), metadata); return metadata;
49
70
  }
50
71
  async renewSession(callerId, updates = {}, callerLeaseMs = 75_000) {
51
- const session = await this.loadSession();
72
+ const session = await this.loadSession(callerId);
52
73
  if (!session) throw new Error('No local session. Run session begin first.');
53
74
  if (session.caller_id !== callerId) throw new Error('callerId does not own this participant session');
54
75
  if (Date.parse(session.caller_deadline) <= Date.now()) throw new Error('Local caller lease expired; begin a new session');
55
76
  const { token: _token, ...metadata } = session;
56
77
  const next = { ...metadata, ...updates, caller_deadline: new Date(Date.now() + Math.min(callerLeaseMs, 85_000)).toISOString() };
57
- await atomicJson(join(this.root, 'session.json'), next); return next;
78
+ const dir = await this.callerDir(callerId);
79
+ await atomicJson(join(dir, 'session.json'), next); return next;
80
+ }
81
+ async clearSession(callerId) {
82
+ const dir = await this.callerDir(callerId);
83
+ await Promise.all([rm(join(dir, 'session.json'), { force: true }), rm(join(dir, 'session-credential'), { force: true })]);
58
84
  }
59
- async clearSession() { await Promise.all([rm(join(this.root, 'session.json'), { force: true }), rm(join(this.root, 'session-credential'), { force: true })]); }
60
- async beginMutation(method, path, body, explicitKey) {
85
+ async beginMutation(method, path, body, explicitKey, callerId) {
61
86
  if (explicitKey !== undefined) {
62
87
  if (typeof explicitKey !== 'string' || !explicitKey.trim()) throw new Error('--idempotency-key must be a non-empty value');
63
88
  return { fingerprint: null, key: explicitKey };
64
89
  }
65
- await this.init();
90
+ const dir = await this.callerDir(callerId);
66
91
  const fingerprint = createHash('sha256').update(JSON.stringify({ method: method.toUpperCase(), path, body: body ?? null })).digest('hex');
67
- const pending = await readJson(join(this.root, 'pending-mutations.json'), {});
92
+ const pending = await readJson(join(dir, 'pending-mutations.json'), {});
68
93
  if (!pending[fingerprint]) {
69
94
  pending[fingerprint] = { key: randomUUID(), method: method.toUpperCase(), path, created_at: new Date().toISOString() };
70
- await atomicJson(join(this.root, 'pending-mutations.json'), pending);
95
+ await atomicJson(join(dir, 'pending-mutations.json'), pending);
71
96
  }
72
97
  return { fingerprint, key: pending[fingerprint].key };
73
98
  }
74
- async completeMutation(fingerprint) {
99
+ async completeMutation(fingerprint, callerId) {
75
100
  if (!fingerprint) return;
76
- const path = join(this.root, 'pending-mutations.json');
101
+ const dir = await this.callerDir(callerId);
102
+ const path = join(dir, 'pending-mutations.json');
77
103
  const pending = await readJson(path, {});
78
104
  delete pending[fingerprint];
79
105
  if (Object.keys(pending).length === 0) await rm(path, { force: true });