@dmsdc-ai/aigentry-telepty 0.5.8 → 0.6.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,234 @@
1
+ 'use strict';
2
+
3
+ // #43 P1 — inject audit spine.
4
+ //
5
+ // Component A in the spec (docs/specs/2026-06-09-inject-audit-provenance.md §3, §5, §8).
6
+ // Three concerns, pure Node only (§17 무의존 — `crypto`/`fs`/`events`, no external deps):
7
+ // 1. buildAuditLine(record) — pure builder for the JSONL schema v1 (one line/delivery).
8
+ // 2. createAuditWriter({...}) — bounded async append, size+age rotation, 0700 dir / 0600
9
+ // file, overflow→drop-oldest+event, NEVER fsync-block delivery.
10
+ // 3. readInjectLog(path, filters) — read/filter/paginate helper backing the P3 GET /api/injects.
11
+ //
12
+ // This is an AUDIT log, not a transactional ledger: no per-line fsync, bounded loss of the last
13
+ // in-flight batch on hard crash is accepted (spec §8). Rotation/prune is best-effort and never
14
+ // blocks the delivery hot path (append() is fire-and-forget; the writer drains on a timer).
15
+
16
+ const crypto = require('crypto');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { EventEmitter } = require('events');
20
+
21
+ const SCHEMA_VERSION = 1;
22
+ const DEFAULT_PREVIEW_BYTES = 200;
23
+ const DEFAULT_QUEUE_MAX = 10000;
24
+ const DEFAULT_FLUSH_MS = 250;
25
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
26
+ const DEFAULT_MAX_FILES = 5;
27
+ const DEFAULT_MAX_AGE_DAYS = 30;
28
+
29
+ // Build one compact JSON line for a single delivery (schema v1, spec §5). Pure: the only
30
+ // inputs are `record` fields; sha256/byte-length/spoof_suspected are derived here so the
31
+ // builder is the single testable source of truth. Redaction default = hash-only: payload
32
+ // content is NEVER written unless `record.preview === true`, and then only truncated.
33
+ function buildAuditLine(record = {}) {
34
+ const payload = typeof record.payload === 'string' ? record.payload : '';
35
+ const claimed = record.claimed_from != null ? record.claimed_from : null;
36
+ const verified = record.verified_sender_sid != null ? record.verified_sender_sid : null;
37
+ const previewOn = record.preview === true;
38
+ const previewBytes = Number.isFinite(record.previewBytes) ? record.previewBytes : DEFAULT_PREVIEW_BYTES;
39
+
40
+ const line = {
41
+ v: SCHEMA_VERSION,
42
+ ts: record.ts || new Date().toISOString(),
43
+ inject_id: record.inject_id != null ? record.inject_id : null,
44
+ kind: record.kind || 'inject',
45
+ source: record.source || record.kind || 'inject',
46
+ claimed_from: claimed,
47
+ verified_sender_sid: verified,
48
+ spoof_suspected: !!(claimed && verified && claimed !== verified),
49
+ to: record.to != null ? record.to : null,
50
+ to_alias: record.to_alias != null ? record.to_alias : null,
51
+ origin: record.origin || 'trusted-local',
52
+ origin_host: record.origin_host != null ? record.origin_host : null,
53
+ ref_path: record.ref_path != null ? record.ref_path : null,
54
+ payload_sha256: crypto.createHash('sha256').update(payload).digest('hex'),
55
+ payload_bytes: Buffer.byteLength(payload),
56
+ payload_preview: previewOn ? truncatePreview(payload, previewBytes) : null,
57
+ delivery_result: record.delivery_result || 'success'
58
+ };
59
+ return JSON.stringify(line);
60
+ }
61
+
62
+ // Truncate a preview to at most `previewBytes` characters — never the full payload. Slicing
63
+ // by character (not raw bytes) avoids splitting a multibyte sequence into invalid UTF-8.
64
+ function truncatePreview(payload, previewBytes) {
65
+ if (payload.length <= previewBytes) return payload;
66
+ return payload.slice(0, previewBytes);
67
+ }
68
+
69
+ // Bounded async writer. append() pushes onto an in-memory FIFO and returns immediately; a
70
+ // timer drains the batch with a single appendFile (spec §8). Overflow drops the OLDEST and
71
+ // emits `audit_overflow` (silent truncation forbidden). Rotation/prune runs inside the drain,
72
+ // off the delivery hot path. Returns an EventEmitter-like object ({ append, flush, close, on }).
73
+ function createAuditWriter(options = {}) {
74
+ const filePath = options.path;
75
+ if (!filePath) throw new Error('createAuditWriter requires a path');
76
+ const queueMax = Number.isFinite(options.queueMax) ? options.queueMax : DEFAULT_QUEUE_MAX;
77
+ const flushMs = Number.isFinite(options.flushMs) ? options.flushMs : DEFAULT_FLUSH_MS;
78
+ const preview = options.preview === true;
79
+ const previewBytes = Number.isFinite(options.previewBytes) ? options.previewBytes : DEFAULT_PREVIEW_BYTES;
80
+ const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
81
+ const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : DEFAULT_MAX_FILES;
82
+ const maxAgeDays = Number.isFinite(options.maxAgeDays) ? options.maxAgeDays : DEFAULT_MAX_AGE_DAYS;
83
+
84
+ const emitter = new EventEmitter();
85
+ let queue = [];
86
+ let timer = null;
87
+ let writing = false;
88
+ let closed = false;
89
+ let droppedTotal = 0;
90
+
91
+ ensureDir();
92
+
93
+ function ensureDir() {
94
+ const dir = path.dirname(filePath);
95
+ try {
96
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
97
+ fs.chmodSync(dir, 0o700); // tighten even if the dir pre-existed with a looser mode
98
+ } catch { /* best-effort; surfaced as audit_error at first write if truly broken */ }
99
+ }
100
+
101
+ // Fire-and-forget: NEVER returns a promise to the caller — the delivery path must not await.
102
+ function append(record) {
103
+ if (closed) return;
104
+ if (queue.length >= queueMax) {
105
+ queue.shift(); // drop oldest
106
+ droppedTotal += 1;
107
+ emitter.emit('audit_overflow', { dropped: droppedTotal, queueMax });
108
+ }
109
+ queue.push({
110
+ ...record,
111
+ preview: record.preview != null ? record.preview : preview,
112
+ previewBytes: record.previewBytes != null ? record.previewBytes : previewBytes
113
+ });
114
+ scheduleFlush();
115
+ }
116
+
117
+ function scheduleFlush() {
118
+ if (timer || writing || queue.length === 0) return;
119
+ timer = setTimeout(() => { timer = null; flush().catch(() => {}); }, flushMs);
120
+ if (timer && typeof timer.unref === 'function') timer.unref();
121
+ }
122
+
123
+ async function flush() {
124
+ if (writing || queue.length === 0) return;
125
+ writing = true;
126
+ const batch = queue;
127
+ queue = [];
128
+ try {
129
+ await rotateIfNeeded();
130
+ const data = batch.map((r) => buildAuditLine(r)).join('\n') + '\n';
131
+ await fs.promises.appendFile(filePath, data, { mode: 0o600 });
132
+ // appendFile's mode only applies on create — chmod each flush so a pre-existing file
133
+ // (or umask-loosened create) is forced to 0600 (the file may carry preview content).
134
+ try { await fs.promises.chmod(filePath, 0o600); } catch { /* best-effort */ }
135
+ } catch (err) {
136
+ emitter.emit('audit_error', err);
137
+ } finally {
138
+ writing = false;
139
+ if (queue.length > 0) scheduleFlush();
140
+ }
141
+ }
142
+
143
+ async function rotateIfNeeded() {
144
+ let size = 0;
145
+ try { size = (await fs.promises.stat(filePath)).size; } catch { size = 0; }
146
+ if (size >= maxBytes) {
147
+ // Shift .{maxFiles-1}→.{maxFiles} … .1→.2, dropping the oldest beyond maxFiles, then
148
+ // active→.1. rename overwrites, so the file falling off the top is discarded.
149
+ for (let i = maxFiles - 1; i >= 1; i--) {
150
+ try { await fs.promises.rename(`${filePath}.${i}`, `${filePath}.${i + 1}`); } catch { /* gap */ }
151
+ }
152
+ try { await fs.promises.rename(filePath, `${filePath}.1`); } catch { /* nothing to rotate */ }
153
+ }
154
+ await pruneByAge();
155
+ }
156
+
157
+ async function pruneByAge() {
158
+ if (!(maxAgeDays > 0)) return;
159
+ const cutoff = Date.now() - maxAgeDays * 86400000;
160
+ for (let i = 1; i <= maxFiles; i++) {
161
+ const rotated = `${filePath}.${i}`;
162
+ try {
163
+ const st = await fs.promises.stat(rotated);
164
+ if (st.mtimeMs < cutoff) await fs.promises.unlink(rotated);
165
+ } catch { /* absent or busy — best-effort */ }
166
+ }
167
+ }
168
+
169
+ async function close() {
170
+ closed = true;
171
+ if (timer) { clearTimeout(timer); timer = null; }
172
+ await flush();
173
+ }
174
+
175
+ return {
176
+ append,
177
+ flush,
178
+ close,
179
+ on: emitter.on.bind(emitter),
180
+ off: emitter.off.bind(emitter)
181
+ };
182
+ }
183
+
184
+ function toMs(value) {
185
+ if (value == null) return null;
186
+ if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
187
+ const s = String(value).trim();
188
+ if (/^\d+$/.test(s)) {
189
+ const n = Number(s);
190
+ return n < 1e12 ? n * 1000 : n;
191
+ }
192
+ const parsed = Date.parse(s);
193
+ return Number.isNaN(parsed) ? null : parsed;
194
+ }
195
+
196
+ // Filter an array of parsed records (spec §7). `from` matches claimed_from OR
197
+ // verified_sender_sid; `to` matches the resolved sid OR the pre-resolution alias.
198
+ function matchInjects(records, filters = {}) {
199
+ let out = records;
200
+ const since = toMs(filters.since);
201
+ const until = toMs(filters.until);
202
+ if (since != null) out = out.filter((r) => { const t = toMs(r.ts); return t != null && t >= since; });
203
+ if (until != null) out = out.filter((r) => { const t = toMs(r.ts); return t != null && t <= until; });
204
+ if (filters.to) out = out.filter((r) => r.to === filters.to || r.to_alias === filters.to);
205
+ if (filters.from) out = out.filter((r) => r.claimed_from === filters.from || r.verified_sender_sid === filters.from);
206
+ if (filters.spoof) out = out.filter((r) => r.spoof_suspected === true);
207
+ return out;
208
+ }
209
+
210
+ // Read the jsonl, filter, return newest-first, paginate by cursor (line offset into the
211
+ // filtered newest-first list). Bounded by `limit` (default 200). Absent file → empty result.
212
+ function readInjectLog(filePath, options = {}) {
213
+ const limit = Math.min(Math.max(Number(options.limit) || 200, 1), 1000);
214
+ const cursor = Number(options.cursor) > 0 ? Number(options.cursor) : 0;
215
+ let raw = '';
216
+ try { raw = fs.readFileSync(filePath, 'utf8'); } catch { return { injects: [], next_cursor: null }; }
217
+
218
+ const records = raw.split('\n').filter(Boolean).map((l) => {
219
+ try { return JSON.parse(l); } catch { return null; }
220
+ }).filter(Boolean);
221
+
222
+ const filtered = matchInjects(records, options);
223
+ filtered.reverse(); // newest first
224
+ const page = filtered.slice(cursor, cursor + limit);
225
+ const next = cursor + limit < filtered.length ? cursor + limit : null;
226
+ return { injects: page, next_cursor: next };
227
+ }
228
+
229
+ module.exports = {
230
+ buildAuditLine,
231
+ createAuditWriter,
232
+ readInjectLog,
233
+ matchInjects
234
+ };
@@ -21,6 +21,38 @@ function createVerifyJwt(JWT_SECRET) {
21
21
  return verifyJwt;
22
22
  }
23
23
 
24
+ function createBrokerAcl(aclTable = {}) {
25
+ return {
26
+ canInject(fromNode, toNode) {
27
+ if (!fromNode || !toNode) return false;
28
+ const allowedTargets = aclTable[fromNode];
29
+ if (Array.isArray(allowedTargets)) return allowedTargets.includes(toNode);
30
+ if (allowedTargets instanceof Set) return allowedTargets.has(toNode);
31
+ return false;
32
+ }
33
+ };
34
+ }
35
+
36
+ function signNodeJwt(secret, claims) {
37
+ if (!secret) throw new Error('JWT secret is required');
38
+ if (!claims || typeof claims !== 'object') throw new Error('JWT claims are required');
39
+
40
+ const headerB64 = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
41
+ const payloadB64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
42
+ const sigB64 = crypto.createHmac('sha256', secret)
43
+ .update(`${headerB64}.${payloadB64}`)
44
+ .digest('base64url');
45
+ return `${headerB64}.${payloadB64}.${sigB64}`;
46
+ }
47
+
48
+ function isRevokedNode(revokedNodes, decodedJwtOrSub) {
49
+ const sub = typeof decodedJwtOrSub === 'string' ? decodedJwtOrSub : decodedJwtOrSub && decodedJwtOrSub.sub;
50
+ if (!sub || !revokedNodes) return false;
51
+ if (Array.isArray(revokedNodes)) return revokedNodes.includes(sub);
52
+ if (revokedNodes instanceof Set) return revokedNodes.has(sub);
53
+ return false;
54
+ }
55
+
24
56
  function createIsAllowedPeer(PEER_ALLOWLIST) {
25
57
  function isAllowedPeer(ip) {
26
58
  if (!ip) return false;
@@ -66,6 +98,9 @@ function createAuthMiddleware(options) {
66
98
 
67
99
  module.exports = {
68
100
  createAuthMiddleware,
101
+ createBrokerAcl,
69
102
  createIsAllowedPeer,
70
- createVerifyJwt
103
+ createVerifyJwt,
104
+ isRevokedNode,
105
+ signNodeJwt
71
106
  };
@@ -198,6 +198,7 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
198
198
 
199
199
  let lastVisibility = null;
200
200
  let everVisible = false;
201
+ let everObservedOutput = false;
201
202
 
202
203
  while (true) {
203
204
  const state = getState ? getState() : null;
@@ -214,6 +215,7 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
214
215
 
215
216
  const visibility = observeBodyVisibility(session, bodyText, opts);
216
217
  lastVisibility = visibility;
218
+ if (visibility.observable && visibility.empty === false) everObservedOutput = true;
217
219
  if (visibility.reason === 'empty_body') {
218
220
  return {
219
221
  accepted: true,
@@ -235,22 +237,67 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
235
237
  }
236
238
  if (visibility.visible) {
237
239
  everVisible = true;
238
- } else {
240
+ } else if (everVisible) {
241
+ // Body was visible then disappeared — the CR consumed the input line.
242
+ return {
243
+ accepted: true,
244
+ retryable: false,
245
+ waited_ms: now() - start,
246
+ reason: 'body_consumed',
247
+ visibility,
248
+ };
249
+ } else if (!getState) {
250
+ // No state probe → preserve the optimistic body-absent accept so callers
251
+ // without a sessionStateManager keep the prior screen-free behavior.
239
252
  return {
240
253
  accepted: true,
241
254
  retryable: false,
242
255
  waited_ms: now() - start,
243
- reason: everVisible ? 'body_consumed' : 'body_absent',
256
+ reason: 'body_absent',
244
257
  visibility,
245
258
  };
246
259
  }
260
+ // else: body never observably present AND a state probe IS available — do NOT
261
+ // optimistically accept on absence. A dropped CR on codex alt-screen renders
262
+ // the body OFF the outputRing tail, so absence is not positive submit evidence
263
+ // (#568 FM3). Keep polling within the window for the primary signal — a state
264
+ // transition idle→working/thinking. If none arrives, fall through to no_land.
247
265
 
248
266
  if (now() - start >= timeoutMs) {
267
+ if (visibility.visible) {
268
+ // Body stayed in the input box the whole window — the CR was not consumed.
269
+ return {
270
+ accepted: false,
271
+ retryable: true,
272
+ waited_ms: now() - start,
273
+ reason: 'body_still_visible',
274
+ visibility,
275
+ state: state || undefined,
276
+ };
277
+ }
278
+ if (everObservedOutput) {
279
+ // The terminal produced output (it is alive) but the body was never consumed
280
+ // AND no state transition occurred — the CR did not land (#568 FM3, e.g. a
281
+ // dropped CR on codex alt-screen). Truthful retryable failure, never a false
282
+ // success on an unsent CR.
283
+ return {
284
+ accepted: false,
285
+ retryable: true,
286
+ waited_ms: now() - start,
287
+ reason: 'no_land',
288
+ visibility,
289
+ state: state || undefined,
290
+ };
291
+ }
292
+ // No observable terminal output at all for the whole window — we have zero
293
+ // screen evidence either way. Preserve the long-standing optimistic accept
294
+ // (back-compat: a wrapped session that never echoes), but mark it ambiguous.
249
295
  return {
250
- accepted: false,
251
- retryable: true,
296
+ accepted: true,
297
+ retryable: false,
252
298
  waited_ms: now() - start,
253
- reason: 'body_still_visible',
299
+ reason: 'no_observable',
300
+ ambiguous: true,
254
301
  visibility,
255
302
  state: state || undefined,
256
303
  };
@@ -260,6 +307,78 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
260
307
  }
261
308
  }
262
309
 
310
+ /**
311
+ * Render-gate the submit CR (#568). Resolve `ready` only when the input is
312
+ * settled enough to safely receive a bare 0x0D:
313
+ * - the injected body is echoed in the outputRing tail (the input is present), AND
314
+ * - the render has gone quiet — the tail is unchanged for ≥ quietWindowMs.
315
+ *
316
+ * This closes the FM1 busy-render race: the pre-#568 submit fired the CR with no
317
+ * readiness gate, so under load the CR landed mid-render and the TUI dropped it.
318
+ * The daemon applies the same gate before each retry CR (FM2).
319
+ *
320
+ * Bounded + best-effort: if the render never goes quiet within timeoutMs (e.g. a
321
+ * continuous spinner), resolve { ready:false, reason:'timeout' } so the caller
322
+ * STILL writes the CR — never worse than the pre-gate behavior. When the body
323
+ * never echoes into the tail (alt-screen / non-echoing TUI), settle on the quiet
324
+ * window alone once echoGraceMs has elapsed (reason:'settled_no_echo').
325
+ *
326
+ * Pure: outputRing-only, DI now/sleep — no I/O, no daemon coupling.
327
+ *
328
+ * @param {{ outputRing?: string[] }} session
329
+ * @param {string} bodyText
330
+ * @param {{ timeoutMs?: number, quietWindowMs?: number, echoGraceMs?: number, pollIntervalMs?: number, tailBytes?: number, stripAnsi?: Function, now?: Function, sleep?: Function }} [opts]
331
+ * @returns {Promise<{ ready: boolean, reason: string, echoed: boolean, settled: boolean, waited_ms: number }>}
332
+ */
333
+ async function awaitInputSettled(session, bodyText, opts = {}) {
334
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1500;
335
+ const quietWindowMs = Number.isFinite(opts.quietWindowMs) ? opts.quietWindowMs : 100;
336
+ const echoGraceMs = Number.isFinite(opts.echoGraceMs) ? opts.echoGraceMs : 400;
337
+ const pollIntervalMs = Number.isFinite(opts.pollIntervalMs) ? opts.pollIntervalMs : 30;
338
+ const tailBytes = Number.isFinite(opts.tailBytes) ? opts.tailBytes : 8192;
339
+ const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
340
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
341
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
342
+
343
+ const needle = normalize(bodyText);
344
+ if (!needle) {
345
+ return { ready: true, reason: 'empty_body', echoed: false, settled: true, waited_ms: 0 };
346
+ }
347
+ if (!session || !Array.isArray(session.outputRing)) {
348
+ // No ring to observe — cannot gate; stay optimistic and never block the CR.
349
+ return { ready: true, reason: 'no_ring', echoed: false, settled: false, waited_ms: 0 };
350
+ }
351
+
352
+ const start = now();
353
+ let lastTail = null;
354
+ let lastChangeAt = start;
355
+ let everEchoed = false;
356
+
357
+ while (true) {
358
+ const tail = normalize(stripAnsi(readTail(session, tailBytes)));
359
+ if (tail.indexOf(needle) !== -1) everEchoed = true;
360
+
361
+ if (lastTail === null || tail !== lastTail) {
362
+ // Render still active — reset the quiet-window timer.
363
+ lastTail = tail;
364
+ lastChangeAt = now();
365
+ } else if (now() - lastChangeAt >= quietWindowMs) {
366
+ // Tail unchanged for the full quiet window → render settled.
367
+ if (everEchoed) {
368
+ return { ready: true, reason: 'settled', echoed: true, settled: true, waited_ms: now() - start };
369
+ }
370
+ if (now() - start >= echoGraceMs) {
371
+ return { ready: true, reason: 'settled_no_echo', echoed: false, settled: true, waited_ms: now() - start };
372
+ }
373
+ }
374
+
375
+ if (now() - start >= timeoutMs) {
376
+ return { ready: false, reason: 'timeout', echoed: everEchoed, settled: false, waited_ms: now() - start };
377
+ }
378
+ await sleep(pollIntervalMs);
379
+ }
380
+ }
381
+
263
382
  function isAcceptedSubmitState(state, submittedAtMs) {
264
383
  if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
265
384
  if (!Number.isFinite(submittedAtMs)) {
@@ -292,6 +411,7 @@ function observeBodyVisibility(session, bodyText, opts = {}) {
292
411
  observable: true,
293
412
  visible: haystack.indexOf(needle) !== -1,
294
413
  source: 'screen',
414
+ empty: haystack.length === 0,
295
415
  };
296
416
  }
297
417
 
@@ -305,6 +425,10 @@ function observeBodyVisibility(session, bodyText, opts = {}) {
305
425
  observable: true,
306
426
  visible: haystack.indexOf(needle) !== -1,
307
427
  source: 'output_ring',
428
+ // #568: distinguish "terminal alive but body off-screen" (empty=false → a
429
+ // dropped CR is no_land) from "no screen evidence at all" (empty=true → stay
430
+ // optimistic). Used by confirmSubmitAccepted's bounded timeout fallback.
431
+ empty: haystack.length === 0,
308
432
  };
309
433
  }
310
434
 
@@ -425,6 +549,7 @@ function defaultReadScreen(workspaceId, lines) {
425
549
 
426
550
  module.exports = {
427
551
  awaitReplReady,
552
+ awaitInputSettled,
428
553
  verifyBodyConsumed,
429
554
  confirmSubmitAccepted,
430
555
  observeBodyVisibility,