@glyphteck/veyl 0.66.3 → 0.68.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,357 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import {
4
+ chmod,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ rename,
9
+ unlink,
10
+ writeFile,
11
+ } from 'node:fs/promises';
12
+ import { dirname, resolve } from 'node:path';
13
+
14
+ export const AGENT_STATE_VERSION = 1;
15
+
16
+ const DIR_MODE = 0o700;
17
+ const FILE_MODE = 0o600;
18
+ const MAX_PROCESSED_MESSAGES = 2_000;
19
+
20
+ function cleanText(value) {
21
+ return String(value ?? '').trim();
22
+ }
23
+
24
+ function cleanBinding(value) {
25
+ const binding = cleanText(value);
26
+ if (!binding) throw new Error('agent state binding required');
27
+ return binding;
28
+ }
29
+
30
+ function cleanIdentity(value) {
31
+ if (value == null) return null;
32
+ const uid = cleanText(value.uid);
33
+ const chatPK = cleanText(value.chatPK).toLowerCase();
34
+ if (!uid || !/^[0-9a-f]{64}$/u.test(chatPK)) {
35
+ throw new Error('invalid pinned owner identity');
36
+ }
37
+ return Object.freeze({ uid, chatPK });
38
+ }
39
+
40
+ function cleanProcessed(values) {
41
+ if (!Array.isArray(values)) throw new Error('invalid processed messages');
42
+ return [...new Set(values.map(cleanText).filter(Boolean))]
43
+ .slice(-MAX_PROCESSED_MESSAGES);
44
+ }
45
+
46
+ function eventCoordinates(event) {
47
+ const chatId = cleanText(
48
+ event?.chatId || event?.message?.chatId || event?.chat?.id
49
+ )
50
+ .toLowerCase();
51
+ const messageId = cleanText(
52
+ event?.messageId || event?.message?.id || event?.message?.cid
53
+ );
54
+ return chatId && messageId ? `${chatId}:${messageId}` : '';
55
+ }
56
+
57
+ function cleanActive(value) {
58
+ if (value == null) return null;
59
+ const compositionId = cleanText(value.compositionId).toLowerCase();
60
+ const responseCid = value.responseCid == null
61
+ ? null
62
+ : cleanText(value.responseCid).toLowerCase();
63
+ const turnId = cleanText(value.turnId) || null;
64
+ const messages = Array.isArray(value.messages)
65
+ ? value.messages.map((message) => ({
66
+ chatId: cleanText(message?.chatId).toLowerCase(),
67
+ messageId: cleanText(message?.messageId),
68
+ }))
69
+ : [];
70
+ if (
71
+ !/^[0-9a-f]{32}$/u.test(compositionId)
72
+ || (
73
+ responseCid !== null
74
+ && (
75
+ responseCid.length <= 6
76
+ || !/^[0-9a-z]+[0-9a-f]{6}$/u.test(responseCid)
77
+ || !Number.isFinite(Number.parseInt(responseCid.slice(0, -6), 36))
78
+ )
79
+ )
80
+ || !messages.length
81
+ || messages.some((message) => (
82
+ !/^[0-9a-f]{64}$/u.test(message.chatId) || !message.messageId
83
+ ))
84
+ || messages.some((message) => message.chatId !== messages[0].chatId)
85
+ ) {
86
+ throw new Error('invalid active agent turn');
87
+ }
88
+ return {
89
+ turnId,
90
+ compositionId,
91
+ responseCid,
92
+ messages,
93
+ };
94
+ }
95
+
96
+ function currentUid() {
97
+ return typeof process.getuid === 'function' ? process.getuid() : null;
98
+ }
99
+
100
+ function assertOwned(stats, label) {
101
+ const uid = currentUid();
102
+ if (uid !== null && stats.uid !== uid) {
103
+ throw new Error(`${label} must be owned by the current user`);
104
+ }
105
+ }
106
+
107
+ function assertMode(stats, expected, label) {
108
+ if ((stats.mode & 0o777) !== expected) {
109
+ throw new Error(
110
+ `${label} must use ${expected.toString(8)} permissions`
111
+ );
112
+ }
113
+ }
114
+
115
+ async function ensurePrivateDirectory(path) {
116
+ await mkdir(path, { recursive: true, mode: DIR_MODE });
117
+ const stats = await lstat(path);
118
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
119
+ throw new Error('agent state parent must be a private directory');
120
+ }
121
+ assertOwned(stats, 'agent state parent');
122
+ assertMode(stats, DIR_MODE, 'agent state parent');
123
+ }
124
+
125
+ async function privateFileStats(path, options = {}) {
126
+ let stats;
127
+ try {
128
+ stats = await lstat(path);
129
+ } catch (error) {
130
+ if (options.allowMissing === true && error?.code === 'ENOENT') {
131
+ return null;
132
+ }
133
+ throw error;
134
+ }
135
+ if (!stats.isFile() || stats.isSymbolicLink()) {
136
+ throw new Error('agent state must be a private regular file');
137
+ }
138
+ assertOwned(stats, 'agent state');
139
+ assertMode(stats, FILE_MODE, 'agent state');
140
+ return stats;
141
+ }
142
+
143
+ async function readPrivateFile(path) {
144
+ if (!await privateFileStats(path, { allowMissing: true })) return null;
145
+ const noFollow = constants.O_NOFOLLOW || 0;
146
+ const handle = await open(path, constants.O_RDONLY | noFollow);
147
+ try {
148
+ const stats = await handle.stat();
149
+ if (!stats.isFile()) {
150
+ throw new Error('agent state must be a private regular file');
151
+ }
152
+ assertOwned(stats, 'agent state');
153
+ assertMode(stats, FILE_MODE, 'agent state');
154
+ return await handle.readFile('utf8');
155
+ } finally {
156
+ await handle.close();
157
+ }
158
+ }
159
+
160
+ function stateDocument(binding, value = {}) {
161
+ return {
162
+ version: AGENT_STATE_VERSION,
163
+ binding,
164
+ initialized: value.initialized === true,
165
+ owner: cleanIdentity(value.owner),
166
+ threadId: cleanText(value.threadId) || null,
167
+ active: cleanActive(value.active),
168
+ processed: cleanProcessed(value.processed || []),
169
+ };
170
+ }
171
+
172
+ export class AgentState {
173
+ constructor(path, value) {
174
+ this.path = resolve(path);
175
+ this.value = value;
176
+ this.processed = new Set(value.processed);
177
+ this.mutation = Promise.resolve();
178
+ }
179
+
180
+ get initialized() {
181
+ return this.value.initialized;
182
+ }
183
+
184
+ get owner() {
185
+ return this.value.owner;
186
+ }
187
+
188
+ get threadId() {
189
+ return this.value.threadId;
190
+ }
191
+
192
+ get active() {
193
+ return this.value.active;
194
+ }
195
+
196
+ get canReplaceThread() {
197
+ return !!this.value.threadId
198
+ && this.value.active === null
199
+ && this.processed.size === 0;
200
+ }
201
+
202
+ has(event) {
203
+ const key = eventCoordinates(event);
204
+ return !!key && this.processed.has(key);
205
+ }
206
+
207
+ hasChat(chat) {
208
+ const chatId = cleanText(chat?.id || chat).toLowerCase();
209
+ return !!chatId && this.value.processed.some((key) => (
210
+ key.startsWith(`${chatId}:`)
211
+ ));
212
+ }
213
+
214
+ update(operation) {
215
+ const task = this.mutation.then(async () => {
216
+ const previousValue = stateDocument(
217
+ this.value.binding,
218
+ this.value
219
+ );
220
+ const previousProcessed = new Set(this.processed);
221
+ try {
222
+ operation();
223
+ await this.persist();
224
+ } catch (error) {
225
+ this.value = previousValue;
226
+ this.processed = previousProcessed;
227
+ throw error;
228
+ }
229
+ });
230
+ this.mutation = task.catch(() => {});
231
+ return task;
232
+ }
233
+
234
+ setInitialized() {
235
+ if (this.value.initialized) return this.mutation;
236
+ return this.update(() => {
237
+ this.value.initialized = true;
238
+ });
239
+ }
240
+
241
+ pinOwner(owner) {
242
+ const identity = cleanIdentity(owner);
243
+ if (
244
+ this.value.owner
245
+ && (
246
+ this.value.owner.uid !== identity.uid
247
+ || this.value.owner.chatPK !== identity.chatPK
248
+ )
249
+ ) {
250
+ throw new Error('configured owner no longer matches the pinned identity');
251
+ }
252
+ if (this.value.owner) return this.mutation;
253
+ return this.update(() => {
254
+ this.value.owner = identity;
255
+ });
256
+ }
257
+
258
+ setThreadId(threadId) {
259
+ const id = cleanText(threadId);
260
+ if (!id) throw new Error('Codex thread id required');
261
+ if (this.value.threadId === id) return this.mutation;
262
+ return this.update(() => {
263
+ this.value.threadId = id;
264
+ });
265
+ }
266
+
267
+ replaceThreadId(previousThreadId, nextThreadId) {
268
+ const previous = cleanText(previousThreadId);
269
+ const next = cleanText(nextThreadId);
270
+ if (!previous || !next || previous === next) {
271
+ throw new Error('valid replacement Codex thread ids required');
272
+ }
273
+ if (
274
+ this.value.threadId !== previous
275
+ || this.value.active !== null
276
+ || this.processed.size !== 0
277
+ ) {
278
+ throw new Error('only a pristine missing Codex thread can be replaced');
279
+ }
280
+ return this.update(() => {
281
+ this.value.threadId = next;
282
+ });
283
+ }
284
+
285
+ setActive(active) {
286
+ const next = cleanActive(active);
287
+ return this.update(() => {
288
+ this.value.active = next;
289
+ });
290
+ }
291
+
292
+ mark(events) {
293
+ const list = Array.isArray(events) ? events : [events];
294
+ const keys = list.map(eventCoordinates).filter(Boolean);
295
+ if (!keys.length) return this.mutation;
296
+ return this.update(() => {
297
+ for (const key of keys) {
298
+ this.processed.delete(key);
299
+ this.processed.add(key);
300
+ }
301
+ this.value.processed = [...this.processed]
302
+ .slice(-MAX_PROCESSED_MESSAGES);
303
+ this.processed = new Set(this.value.processed);
304
+ });
305
+ }
306
+
307
+ complete(events) {
308
+ const list = Array.isArray(events) ? events : [events];
309
+ const keys = list.map(eventCoordinates).filter(Boolean);
310
+ if (!keys.length) throw new Error('completed agent turn requires messages');
311
+ return this.update(() => {
312
+ for (const key of keys) {
313
+ this.processed.delete(key);
314
+ this.processed.add(key);
315
+ }
316
+ this.value.processed = [...this.processed]
317
+ .slice(-MAX_PROCESSED_MESSAGES);
318
+ this.processed = new Set(this.value.processed);
319
+ this.value.active = null;
320
+ });
321
+ }
322
+
323
+ async persist() {
324
+ const parent = dirname(this.path);
325
+ await ensurePrivateDirectory(parent);
326
+ await privateFileStats(this.path, { allowMissing: true });
327
+ const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
328
+ let installed = false;
329
+ try {
330
+ await writeFile(
331
+ temporary,
332
+ `${JSON.stringify(this.value, null, 2)}\n`,
333
+ { mode: FILE_MODE, flag: 'wx' }
334
+ );
335
+ await chmod(temporary, FILE_MODE);
336
+ await rename(temporary, this.path);
337
+ installed = true;
338
+ } finally {
339
+ if (!installed) await unlink(temporary).catch(() => {});
340
+ }
341
+ }
342
+ }
343
+
344
+ export async function openAgentState(options = {}) {
345
+ const path = resolve(options.path || 'codex-agent-state.json');
346
+ const binding = cleanBinding(options.binding);
347
+ await ensurePrivateDirectory(dirname(path));
348
+ const serialized = await readPrivateFile(path);
349
+ const stored = serialized === null ? null : JSON.parse(serialized);
350
+ if (stored && stored.version !== AGENT_STATE_VERSION) {
351
+ throw new Error('unsupported agent state version');
352
+ }
353
+ if (stored && cleanBinding(stored.binding) !== binding) {
354
+ throw new Error('agent state belongs to a different connector');
355
+ }
356
+ return new AgentState(path, stateDocument(binding, stored || {}));
357
+ }
@@ -0,0 +1,104 @@
1
+ const CHAT_PK_RE = /^[0-9a-f]{64}$/iu;
2
+ const USERNAME_RE = /^[a-z0-9]{1,12}$/u;
3
+ const ACCEPT = 'accept';
4
+ const REJECT = 'reject';
5
+
6
+ function cleanText(value) {
7
+ return String(value ?? '').trim();
8
+ }
9
+
10
+ function cleanUsername(value) {
11
+ const username = cleanText(value).replace(/^@+/u, '').toLowerCase();
12
+ if (!USERNAME_RE.test(username)) {
13
+ throw new Error('valid whitelist username required');
14
+ }
15
+ return username;
16
+ }
17
+
18
+ function cleanChatPK(value) {
19
+ const chatPK = cleanText(value).toLowerCase();
20
+ if (!CHAT_PK_RE.test(chatPK)) {
21
+ throw new Error('valid whitelist chat key required');
22
+ }
23
+ return chatPK;
24
+ }
25
+
26
+ function cleanUid(value) {
27
+ const uid = cleanText(value);
28
+ if (!uid) throw new Error('valid whitelist uid required');
29
+ return uid;
30
+ }
31
+
32
+ function whitelistIdentity(value) {
33
+ if (typeof value === 'string') {
34
+ const peer = cleanText(value);
35
+ if (!peer) throw new Error('whitelist peer required');
36
+ return CHAT_PK_RE.test(peer)
37
+ ? Object.freeze({ chatPK: cleanChatPK(peer) })
38
+ : Object.freeze({ username: cleanUsername(peer) });
39
+ }
40
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
41
+ throw new Error('whitelist peer must be a username, chat key, or identity');
42
+ }
43
+ const identity = {};
44
+ if (Object.hasOwn(value, 'uid')) identity.uid = cleanUid(value.uid);
45
+ if (Object.hasOwn(value, 'chatPK')) identity.chatPK = cleanChatPK(value.chatPK);
46
+ if (Object.hasOwn(value, 'username')) {
47
+ identity.username = cleanUsername(value.username);
48
+ }
49
+ if (!Object.keys(identity).length) {
50
+ throw new Error('whitelist identity requires uid, chatPK, or username');
51
+ }
52
+ return Object.freeze(identity);
53
+ }
54
+
55
+ function sameIdentity(member, sender) {
56
+ const uid = cleanText(member?.uid);
57
+ const chatPK = cleanText(member?.chatPK).toLowerCase();
58
+ const chatSigningPK = cleanText(member?.chatSigningPK).toLowerCase();
59
+ const notificationPK = cleanText(member?.notificationPK).toLowerCase();
60
+ return !!uid
61
+ && CHAT_PK_RE.test(chatPK)
62
+ && CHAT_PK_RE.test(chatSigningPK)
63
+ && CHAT_PK_RE.test(notificationPK)
64
+ && uid === cleanText(sender?.uid)
65
+ && chatPK === cleanText(sender?.chatPK).toLowerCase()
66
+ && chatSigningPK === cleanText(sender?.chatSigningPK).toLowerCase()
67
+ && notificationPK === cleanText(sender?.notificationPK).toLowerCase();
68
+ }
69
+
70
+ function matchesAllowedPeer(peer, sender) {
71
+ return (peer.uid == null || peer.uid === cleanText(sender?.uid))
72
+ && (
73
+ peer.chatPK == null
74
+ || peer.chatPK === cleanText(sender?.chatPK).toLowerCase()
75
+ )
76
+ && (
77
+ peer.username == null
78
+ || peer.username === cleanText(sender?.username).toLowerCase()
79
+ );
80
+ }
81
+
82
+ export function createChatWhitelist(peers) {
83
+ if (!Array.isArray(peers)) throw new Error('chat whitelist must be an array');
84
+ const allowed = Object.freeze(peers.map(whitelistIdentity));
85
+ return (context) => {
86
+ if (context?.chat?.lineage !== 'direct') return REJECT;
87
+ const members = context?.chat?.members;
88
+ if (!Array.isArray(members) || members.length !== 2) return REJECT;
89
+
90
+ const selfUid = cleanText(context?.self?.uid);
91
+ const selfChatPK = cleanText(context?.self?.chatPK).toLowerCase();
92
+ if (!selfUid || !CHAT_PK_RE.test(selfChatPK)) return REJECT;
93
+ const self = members.filter((member) => (
94
+ cleanText(member?.uid) === selfUid
95
+ && cleanText(member?.chatPK).toLowerCase() === selfChatPK
96
+ ));
97
+ if (self.length !== 1) return REJECT;
98
+ const remote = members.find((member) => member !== self[0]);
99
+ if (!sameIdentity(remote, context?.sender)) return REJECT;
100
+ return allowed.some((peer) => matchesAllowedPeer(peer, context.sender))
101
+ ? ACCEPT
102
+ : REJECT;
103
+ };
104
+ }