@agfpd/iapeer 0.1.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.
Files changed (63) hide show
  1. package/bin/iapeer +25 -0
  2. package/package.json +37 -0
  3. package/src/cli/cli.test.ts +130 -0
  4. package/src/cli/index.ts +608 -0
  5. package/src/cli/listTui.test.ts +70 -0
  6. package/src/cli/listTui.ts +165 -0
  7. package/src/codec/codec.test.ts +271 -0
  8. package/src/codec/index.ts +217 -0
  9. package/src/core/constants.test.ts +21 -0
  10. package/src/core/constants.ts +180 -0
  11. package/src/core/errors.ts +20 -0
  12. package/src/core/index.ts +3 -0
  13. package/src/core/normalize.test.ts +98 -0
  14. package/src/core/normalize.ts +89 -0
  15. package/src/core/socket.ts +63 -0
  16. package/src/create/create.test.ts +143 -0
  17. package/src/create/index.ts +178 -0
  18. package/src/daemon/daemon-http.test.ts +114 -0
  19. package/src/daemon/daemon.test.ts +103 -0
  20. package/src/daemon/index.ts +439 -0
  21. package/src/daemon/main.test.ts +194 -0
  22. package/src/daemon/main.ts +230 -0
  23. package/src/enable/enable.test.ts +92 -0
  24. package/src/enable/index.ts +381 -0
  25. package/src/identity/identity.test.ts +262 -0
  26. package/src/identity/index.ts +603 -0
  27. package/src/index.ts +27 -0
  28. package/src/init/index.ts +408 -0
  29. package/src/init/init.test.ts +171 -0
  30. package/src/init/runtime-resolve.test.ts +49 -0
  31. package/src/install/index.ts +84 -0
  32. package/src/install/install.test.ts +31 -0
  33. package/src/launch/adapters/claude.ts +250 -0
  34. package/src/launch/adapters/codex.ts +329 -0
  35. package/src/launch/adapters/notifier.ts +90 -0
  36. package/src/launch/adapters/telegram.ts +130 -0
  37. package/src/launch/bootstrap.test.ts +56 -0
  38. package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
  39. package/src/launch/composeSystemPrompt.test.ts +98 -0
  40. package/src/launch/composeSystemPrompt.ts +261 -0
  41. package/src/launch/index.ts +253 -0
  42. package/src/launch/launch.test.ts +233 -0
  43. package/src/launch/launchd.test.ts +363 -0
  44. package/src/launch/launchd.ts +375 -0
  45. package/src/launch/launchdRun.ts +168 -0
  46. package/src/launch/sockdir.test.ts +70 -0
  47. package/src/launch/types.ts +300 -0
  48. package/src/lifecycle/index.ts +840 -0
  49. package/src/lifecycle/lifecycle.test.ts +496 -0
  50. package/src/onboard/index.ts +135 -0
  51. package/src/onboard/onboard.test.ts +39 -0
  52. package/src/provision/index.ts +170 -0
  53. package/src/provision/provision.test.ts +104 -0
  54. package/src/registry/index.ts +453 -0
  55. package/src/registry/registry.test.ts +400 -0
  56. package/src/runtime/deploy.ts +230 -0
  57. package/src/runtime/index.ts +191 -0
  58. package/src/runtime/runtime.test.ts +226 -0
  59. package/src/storage/index.ts +331 -0
  60. package/src/storage/peers-home.test.ts +34 -0
  61. package/src/storage/storage.test.ts +65 -0
  62. package/src/transport/index.ts +522 -0
  63. package/tsconfig.json +17 -0
@@ -0,0 +1,70 @@
1
+ // list TUI — the PURE render + key state-machine (the raw-mode loop is live-verified).
2
+
3
+ import { describe, expect, test } from 'bun:test'
4
+ import { filterRows, handleListKey, renderListPanel, type TuiState } from './listTui.ts'
5
+ import type { PeerListing } from './index.ts'
6
+
7
+ function row(over: Partial<PeerListing>): PeerListing {
8
+ return {
9
+ personality: 'p',
10
+ default_runtime: 'claude',
11
+ intelligence: 'artificial',
12
+ description: '',
13
+ runtimes: [{ runtime: 'claude', status: 'asleep' }],
14
+ ...over,
15
+ }
16
+ }
17
+ const ROWS: PeerListing[] = [
18
+ row({ personality: 'arthur', default_runtime: 'telegram', description: 'owner' }),
19
+ row({ personality: 'boris', runtimes: [{ runtime: 'claude', status: 'live' }], last_active_runtime: 'claude' }),
20
+ row({ personality: 'doc' }),
21
+ ]
22
+ const S0: TuiState = { cursor: 0, filter: '', filterMode: false }
23
+
24
+ describe('filterRows', () => {
25
+ test('case-insensitive substring over personality + description', () => {
26
+ expect(filterRows(ROWS, 'BOR').map(r => r.personality)).toEqual(['boris'])
27
+ expect(filterRows(ROWS, 'owner').map(r => r.personality)).toEqual(['arthur']) // description match
28
+ expect(filterRows(ROWS, '').length).toBe(3) // empty → all
29
+ })
30
+ })
31
+
32
+ describe('renderListPanel', () => {
33
+ test('selected row carries the ❯ caret + reverse-video; others do not', () => {
34
+ const frame = renderListPanel(ROWS, { ...S0, cursor: 1 })
35
+ expect(frame).toContain('\x1b[7m❯ boris') // reverse + caret on the cursor row
36
+ expect(frame).toContain('arthur') // other rows present, no caret
37
+ expect(frame).not.toContain('❯ arthur')
38
+ expect(frame).toContain('● claude') // boris live glyph
39
+ })
40
+ test('filter narrows the rendered rows', () => {
41
+ const frame = renderListPanel(ROWS, { ...S0, filter: 'doc' })
42
+ expect(frame).toContain('doc')
43
+ expect(frame).not.toContain('arthur')
44
+ })
45
+ })
46
+
47
+ describe('handleListKey', () => {
48
+ const visible = ROWS
49
+ test('↓/↑ move the cursor, clamped to bounds', () => {
50
+ expect(handleListKey('\x1b[B', S0, visible).state.cursor).toBe(1) // down
51
+ expect(handleListKey('\x1b[A', S0, visible).state.cursor).toBe(0) // up clamped at 0
52
+ expect(handleListKey('\x1b[B', { ...S0, cursor: 2 }, visible).state.cursor).toBe(2) // down clamped at last
53
+ })
54
+ test('Enter → attach action with the selected peer', () => {
55
+ const r = handleListKey('\r', { ...S0, cursor: 1 }, visible)
56
+ expect(r.action).toEqual({ type: 'attach', personality: 'boris' })
57
+ })
58
+ test('q / Ctrl-C → quit', () => {
59
+ expect(handleListKey('q', S0, visible).action).toEqual({ type: 'quit' })
60
+ expect(handleListKey('\x03', S0, visible).action).toEqual({ type: 'quit' })
61
+ })
62
+ test('/ enters filter mode; typing edits the filter; Enter leaves it', () => {
63
+ const f = handleListKey('/', S0, visible)
64
+ expect(f.state.filterMode).toBe(true)
65
+ const typed = handleListKey('b', f.state, visible)
66
+ expect(typed.state.filter).toBe('b')
67
+ const left = handleListKey('\r', typed.state, visible)
68
+ expect(left.state.filterMode).toBe(false)
69
+ })
70
+ })
@@ -0,0 +1,165 @@
1
+ // list TUI — the interactive control-panel form of `iapeer list` (contract Примитивы
2
+ // §list TUI): a peer overview with per-runtime liveness, ↑/↓ navigation, `/` filter,
3
+ // `q` quit, and ENTER = attach to the selected peer. Scriptable `list` (non-tty /
4
+ // --json) stays the table in cli/index.ts; this is the tty form.
5
+ //
6
+ // The RENDER and the KEY state-machine are PURE (unit-testable); only runListTui does
7
+ // raw-mode terminal I/O, and on ENTER it hands off to attachPeer + tmux attach — the
8
+ // same ensure-live+resume path the `attach` verb uses (so a live session is never torn).
9
+
10
+ import { spawnSync } from 'child_process'
11
+ import { attachPeer } from '../lifecycle/index.ts'
12
+ import { listPeers, type PeerListing } from './index.ts'
13
+
14
+ const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
15
+
16
+ // ─── pure render ─────────────────────────────────────────────────────────────
17
+
18
+ /** Rows that match the filter (case-insensitive substring over personality/description). */
19
+ export function filterRows(rows: PeerListing[], filter: string): PeerListing[] {
20
+ const f = filter.trim().toLowerCase()
21
+ if (!f) return rows
22
+ return rows.filter(r => r.personality.toLowerCase().includes(f) || r.description.toLowerCase().includes(f))
23
+ }
24
+
25
+ export interface TuiState {
26
+ cursor: number
27
+ filter: string
28
+ filterMode: boolean
29
+ }
30
+
31
+ /** Render the panel frame (ANSI). `cursor` is an index into the FILTERED rows. */
32
+ export function renderListPanel(rows: PeerListing[], state: TuiState): string {
33
+ const visible = filterRows(rows, state.filter)
34
+ const lines: string[] = []
35
+ lines.push('\x1b[2J\x1b[H') // clear + home
36
+ lines.push(' iapeer peers — ↑/↓ navigate · Enter attach · / filter · q quit')
37
+ lines.push('')
38
+ if (visible.length === 0) {
39
+ lines.push(' (no peers match)')
40
+ }
41
+ visible.forEach((r, i) => {
42
+ const status = r.runtimes.map(s => `${GLYPH[s.status]} ${s.runtime}`).join(' ')
43
+ const la = r.last_active_runtime ? ` ⤳${r.last_active_runtime}` : ''
44
+ const row = `${r.personality.padEnd(16)} ${r.default_runtime.padEnd(9)} ${r.intelligence.padEnd(11)} ${status}${la}`
45
+ // selected row → reverse video + ❯ caret
46
+ lines.push(i === state.cursor ? `\x1b[7m❯ ${row}\x1b[0m` : ` ${row}`)
47
+ })
48
+ lines.push('')
49
+ lines.push(state.filterMode ? ` /filter: ${state.filter}_` : state.filter ? ` filter: ${state.filter}` : '')
50
+ return lines.join('\r\n') + '\r\n'
51
+ }
52
+
53
+ // ─── pure key state-machine ──────────────────────────────────────────────────
54
+
55
+ export type TuiAction =
56
+ | { type: 'none' }
57
+ | { type: 'redraw' }
58
+ | { type: 'attach'; personality: string }
59
+ | { type: 'quit' }
60
+
61
+ const UP = '\x1b[A'
62
+ const DOWN = '\x1b[B'
63
+ const ENTER1 = '\r'
64
+ const ENTER2 = '\n'
65
+ const ESC = '\x1b'
66
+ const CTRL_C = '\x03'
67
+ const BACKSPACE = /^[\x7f\b]$/
68
+
69
+ /**
70
+ * Advance the TUI state for a key. Pure: returns the next state + an action the loop
71
+ * performs (attach/quit/redraw). `visible` is the filtered row set (for bounds + the
72
+ * Enter target). In filter-mode, printable keys edit the filter; Enter/Esc leave it.
73
+ */
74
+ export function handleListKey(key: string, state: TuiState, visible: PeerListing[]): { state: TuiState; action: TuiAction } {
75
+ if (state.filterMode) {
76
+ if (key === ENTER1 || key === ENTER2 || key === ESC) {
77
+ return { state: { ...state, filterMode: false, cursor: 0 }, action: { type: 'redraw' } }
78
+ }
79
+ if (BACKSPACE.test(key)) {
80
+ return { state: { ...state, filter: state.filter.slice(0, -1), cursor: 0 }, action: { type: 'redraw' } }
81
+ }
82
+ if (key >= ' ' && key.length === 1) {
83
+ return { state: { ...state, filter: state.filter + key, cursor: 0 }, action: { type: 'redraw' } }
84
+ }
85
+ return { state, action: { type: 'none' } }
86
+ }
87
+ if (key === 'q' || key === CTRL_C) return { state, action: { type: 'quit' } }
88
+ if (key === '/') return { state: { ...state, filterMode: true }, action: { type: 'redraw' } }
89
+ if (key === UP) return { state: { ...state, cursor: Math.max(0, state.cursor - 1) }, action: { type: 'redraw' } }
90
+ if (key === DOWN) {
91
+ return { state: { ...state, cursor: Math.min(Math.max(0, visible.length - 1), state.cursor + 1) }, action: { type: 'redraw' } }
92
+ }
93
+ if (key === ENTER1 || key === ENTER2) {
94
+ const target = visible[state.cursor]
95
+ return target ? { state, action: { type: 'attach', personality: target.personality } } : { state, action: { type: 'none' } }
96
+ }
97
+ return { state, action: { type: 'none' } }
98
+ }
99
+
100
+ // ─── raw-mode loop (the only impure part) ────────────────────────────────────
101
+
102
+ /**
103
+ * Run the interactive list panel. Reads the registry once (read-only), renders, and
104
+ * loops on raw-mode key input. ENTER → attachPeer the selected peer (ensure-live +
105
+ * resume, last-active runtime), then exec `tmux attach` (TMUX unset, no nested error);
106
+ * the panel exits raw mode first so the operator drops cleanly into the session. `q` /
107
+ * Ctrl-C quit. Returns the process exit code.
108
+ */
109
+ export async function runListTui(env: NodeJS.ProcessEnv = process.env): Promise<number> {
110
+ const rows = listPeers({ env })
111
+ let state: TuiState = { cursor: 0, filter: '', filterMode: false }
112
+ const stdin = process.stdin
113
+ const stdout = process.stdout
114
+ if (!stdin.isTTY) {
115
+ stdout.write('list TUI requires a terminal (use `iapeer list --json` for scripts)\n')
116
+ return 2
117
+ }
118
+ const draw = () => stdout.write(renderListPanel(rows, state))
119
+ stdin.setRawMode(true)
120
+ stdin.resume()
121
+ stdin.setEncoding('utf8')
122
+ draw()
123
+ try {
124
+ for (;;) {
125
+ const key = await nextKey(stdin)
126
+ const { state: next, action } = handleListKey(key, state, filterRows(rows, state.filter))
127
+ state = next
128
+ if (action.type === 'quit') return 0
129
+ if (action.type === 'redraw') draw()
130
+ if (action.type === 'attach') {
131
+ // leave raw mode and the panel before handing the terminal to tmux attach
132
+ stdin.setRawMode(false)
133
+ stdin.pause()
134
+ stdout.write('\x1b[2J\x1b[H')
135
+ const r = await attachPeer({ personality: action.personality, env })
136
+ if (!r.ok) {
137
+ stdout.write(`attach: ${r.reason}\n`)
138
+ return 1
139
+ }
140
+ stdout.write(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
141
+ const attachEnv = { ...env }
142
+ delete attachEnv.TMUX
143
+ const a = spawnSync('tmux', ['-S', r.socketPath, 'attach', '-t', r.identity], {
144
+ stdio: 'inherit',
145
+ env: attachEnv as Record<string, string>,
146
+ })
147
+ return a.status ?? 0
148
+ }
149
+ }
150
+ } finally {
151
+ if (stdin.isTTY) stdin.setRawMode(false)
152
+ stdin.pause()
153
+ }
154
+ }
155
+
156
+ /** Resolve the next raw-mode keypress (one chunk = one key / escape sequence). */
157
+ function nextKey(stdin: NodeJS.ReadStream): Promise<string> {
158
+ return new Promise(resolve => {
159
+ const onData = (chunk: string) => {
160
+ stdin.off('data', onData)
161
+ resolve(chunk)
162
+ }
163
+ stdin.on('data', onData)
164
+ })
165
+ }
@@ -0,0 +1,271 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { buildEnvelope, decodeEnvelope, extractEnvelopes, type EnvelopeInput } from './index.ts'
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // WITNESS: faithful copy of the OLD telegram-runtime decoder (cli.ts:867-935).
6
+ // These reproduce the pre-fix behaviour so each adversarial test shows a real
7
+ // delta: the witness FAILS the assertion the new codec PASSES. (Brief: "тест,
8
+ // падающий ДО фикса и проходящий ПОСЛЕ".)
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+
11
+ function oldUnescapeAttr(value: string): string {
12
+ return value
13
+ .replace(/&quot;/g, '"')
14
+ .replace(/&lt;/g, '<')
15
+ .replace(/&gt;/g, '>')
16
+ .replace(/&amp;/g, '&')
17
+ }
18
+ function oldDecodeCdata(inner: string): string {
19
+ if (!inner.startsWith('<![CDATA[') || !inner.endsWith(']]>')) return inner
20
+ return inner.slice('<![CDATA['.length, -']]>'.length).replaceAll(']]]]><![CDATA[>', ']]>')
21
+ }
22
+ function oldTagContent(xml: string, tag: string): string | undefined {
23
+ const re = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`)
24
+ const m = re.exec(xml)
25
+ return m ? oldDecodeCdata(m[1]) : undefined
26
+ }
27
+ function oldParseMessage(xml: string): string | undefined {
28
+ xml = xml.replace(/\r\n?/g, '\n') // the CR→LF fold the codec must NOT do
29
+ return oldTagContent(xml, 'message')
30
+ }
31
+ function oldExtractEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
32
+ const envelopes: string[] = []
33
+ let rest = buffer
34
+ while (true) {
35
+ const start = rest.indexOf('<iap ')
36
+ if (start < 0) return { envelopes, rest: rest.slice(Math.max(0, rest.length - 8)) }
37
+ if (start > 0) rest = rest.slice(start)
38
+ const end = rest.indexOf('</iap>') // naive: not CDATA-aware
39
+ if (end < 0) return { envelopes, rest }
40
+ const envelopeEnd = end + '</iap>'.length
41
+ envelopes.push(rest.slice(0, envelopeEnd))
42
+ rest = rest.slice(envelopeEnd)
43
+ }
44
+ }
45
+
46
+ const base: Omit<EnvelopeInput, 'message'> = {
47
+ fromPersonality: 'arthur',
48
+ fromRuntime: 'telegram',
49
+ fromIntelligence: 'natural',
50
+ topic: 'Ф0 фундамента',
51
+ }
52
+
53
+ function roundTripMessage(message: string, extra: Partial<EnvelopeInput> = {}): string {
54
+ const xml = buildEnvelope({ ...base, ...extra, message })
55
+ const { envelopes, rest } = extractEnvelopes(xml)
56
+ expect(envelopes).toHaveLength(1)
57
+ expect(rest).toBe('')
58
+ return decodeEnvelope(envelopes[0]).message
59
+ }
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Basic round-trip
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ describe('codec round-trip (field-semantic equivalence)', () => {
66
+ test('preserves all fields', () => {
67
+ const input: EnvelopeInput = {
68
+ fromPersonality: 'boris',
69
+ fromRuntime: 'claude',
70
+ fromIntelligence: 'artificial',
71
+ topic: 'checkpoint',
72
+ attachments: ['/tmp/a.txt', '/tmp/b with space.png'],
73
+ message: 'plain multi\nline\nmessage',
74
+ }
75
+ const decoded = decodeEnvelope(buildEnvelope(input))
76
+ expect(decoded.fromPersonality).toBe(input.fromPersonality)
77
+ expect(decoded.fromRuntime).toBe(input.fromRuntime)
78
+ expect(decoded.fromIntelligence).toBe(input.fromIntelligence)
79
+ expect(decoded.topic).toBe(input.topic)
80
+ expect(decoded.attachments).toEqual([...input.attachments!])
81
+ expect(decoded.message).toBe(input.message)
82
+ })
83
+
84
+ test('attr special chars (<, >, &, ") survive in personality and topic', () => {
85
+ const xml = buildEnvelope({
86
+ fromPersonality: 'arthur',
87
+ fromRuntime: 'telegram',
88
+ fromIntelligence: 'natural',
89
+ topic: 'a < b & c > d "quoted"',
90
+ message: 'hi',
91
+ })
92
+ const d = decodeEnvelope(xml)
93
+ expect(d.topic).toBe('a < b & c > d "quoted"')
94
+ })
95
+
96
+ test('empty message round-trips', () => {
97
+ expect(roundTripMessage('')).toBe('')
98
+ })
99
+ })
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // Adversarial: message contains </iap> → extractEnvelopes must not truncate
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+
105
+ describe('adversarial: </iap> in message', () => {
106
+ const message = 'see the closing tag </iap> right here'
107
+
108
+ test('NEW codec: single envelope, message intact', () => {
109
+ expect(roundTripMessage(message)).toBe(message)
110
+ })
111
+
112
+ test('WITNESS: old naive indexOf truncates the envelope', () => {
113
+ const xml = buildEnvelope({ ...base, message })
114
+ const oldFirst = oldExtractEnvelopes(xml).envelopes[0]
115
+ // old splits at the inner </iap>, producing a shorter, broken envelope
116
+ expect(oldFirst.length).toBeLessThan(xml.length)
117
+ // ...and the new extractor produces the full envelope
118
+ expect(extractEnvelopes(xml).envelopes[0]).toBe(xml)
119
+ })
120
+
121
+ test('NEW codec: two real envelopes, first message holds a fake </iap>', () => {
122
+ const e1 = buildEnvelope({ ...base, message: 'fake </iap> inside' })
123
+ const e2 = buildEnvelope({ ...base, fromPersonality: 'darwin', message: 'second' })
124
+ const { envelopes, rest } = extractEnvelopes(e1 + '\n' + e2)
125
+ expect(envelopes).toHaveLength(2)
126
+ expect(rest).toBe('')
127
+ expect(decodeEnvelope(envelopes[0]).message).toBe('fake </iap> inside')
128
+ expect(decodeEnvelope(envelopes[1]).message).toBe('second')
129
+ // witness: old truncates the first envelope at the inner </iap>, so its
130
+ // first fragment is NOT the real e1 (and no longer decodes to a message)
131
+ const oldFirst = oldExtractEnvelopes(e1 + '\n' + e2).envelopes[0]
132
+ expect(oldFirst).not.toBe(e1)
133
+ expect(oldTagContent(oldFirst, 'message')).toBeUndefined()
134
+ })
135
+ })
136
+
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ // Adversarial: message contains </message> → tag-content must not truncate
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+
141
+ describe('adversarial: </message> in message', () => {
142
+ const message = 'a </message> b </message> c'
143
+
144
+ test('NEW codec: message intact', () => {
145
+ expect(roundTripMessage(message)).toBe(message)
146
+ })
147
+
148
+ test('WITNESS: old non-greedy regex truncates at inner </message>', () => {
149
+ const xml = buildEnvelope({ ...base, message })
150
+ expect(oldParseMessage(xml)).not.toBe(message)
151
+ })
152
+ })
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // Adversarial: message contains ]]> → CDATA split must reconstruct
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ describe('adversarial: ]]> in message', () => {
159
+ test('NEW codec: literal ]]> reconstructed', () => {
160
+ expect(roundTripMessage('a]]>b')).toBe('a]]>b')
161
+ expect(roundTripMessage('edge ]]> at ]]> several ]]> places')).toBe(
162
+ 'edge ]]> at ]]> several ]]> places',
163
+ )
164
+ expect(roundTripMessage(']]>')).toBe(']]>')
165
+ })
166
+ })
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // Adversarial: literal CR → codec must NOT fold (transport's job)
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+
172
+ describe('adversarial: literal CR is preserved (no fold in codec)', () => {
173
+ const message = 'line1\rline2\r\nline3'
174
+
175
+ test('NEW codec: CR survives round-trip', () => {
176
+ expect(roundTripMessage(message)).toBe(message)
177
+ })
178
+
179
+ test('WITNESS: old decoder folds CR→LF and loses it', () => {
180
+ const xml = buildEnvelope({ ...base, message })
181
+ const folded = oldParseMessage(xml)
182
+ expect(folded).not.toBe(message)
183
+ expect(folded).toBe('line1\nline2\nline3') // proof of the lossy fold
184
+ })
185
+ })
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ // Headline: all four adversarial features at once
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+
191
+ describe('adversarial: combined </iap> </message> ]]> CR', () => {
192
+ const message = 'X </iap> Y </message> Z ]]> W\rtail\nend'
193
+
194
+ test('NEW codec: full round-trip by field semantics', () => {
195
+ const input: EnvelopeInput = {
196
+ ...base,
197
+ attachments: ['/path/with ]]> weird', '/second </iap> path'],
198
+ message,
199
+ }
200
+ const xml = buildEnvelope(input)
201
+ const { envelopes, rest } = extractEnvelopes(xml)
202
+ expect(envelopes).toHaveLength(1)
203
+ expect(rest).toBe('')
204
+ const d = decodeEnvelope(envelopes[0])
205
+ expect(d.message).toBe(message)
206
+ expect(d.attachments).toEqual(['/path/with ]]> weird', '/second </iap> path'])
207
+ expect(d.fromPersonality).toBe('arthur')
208
+ expect(d.fromIntelligence).toBe('natural')
209
+ })
210
+ })
211
+
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ // extractEnvelopes streaming semantics
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+
216
+ describe('extractEnvelopes streaming', () => {
217
+ test('incomplete envelope kept in rest, completed on next chunk', () => {
218
+ const xml = buildEnvelope({ ...base, message: 'streamed' })
219
+ const cut = Math.floor(xml.length / 2)
220
+ const first = extractEnvelopes(xml.slice(0, cut))
221
+ expect(first.envelopes).toHaveLength(0)
222
+ const second = extractEnvelopes(first.rest + xml.slice(cut))
223
+ expect(second.envelopes).toHaveLength(1)
224
+ expect(decodeEnvelope(second.envelopes[0]).message).toBe('streamed')
225
+ })
226
+
227
+ test('mid-CDATA cut does not falsely close on inner </iap>', () => {
228
+ const xml = buildEnvelope({ ...base, message: 'pre </iap> post' })
229
+ // cut right after the inner </iap> but before the CDATA terminator
230
+ const innerIap = xml.indexOf('</iap>')
231
+ const cut = innerIap + 3
232
+ const first = extractEnvelopes(xml.slice(0, cut))
233
+ expect(first.envelopes).toHaveLength(0) // must NOT emit a truncated envelope
234
+ const second = extractEnvelopes(first.rest + xml.slice(cut))
235
+ expect(second.envelopes).toHaveLength(1)
236
+ expect(decodeEnvelope(second.envelopes[0]).message).toBe('pre </iap> post')
237
+ })
238
+
239
+ test('leading noise before <iap is discarded', () => {
240
+ const xml = buildEnvelope({ ...base, message: 'm' })
241
+ const { envelopes } = extractEnvelopes('garbage text\n' + xml)
242
+ expect(envelopes).toHaveLength(1)
243
+ expect(decodeEnvelope(envelopes[0]).message).toBe('m')
244
+ })
245
+ })
246
+
247
+ // ─────────────────────────────────────────────────────────────────────────────
248
+ // Decoder error surface
249
+ // ─────────────────────────────────────────────────────────────────────────────
250
+
251
+ describe('decodeEnvelope errors', () => {
252
+ test('missing <iap throws', () => {
253
+ expect(() => decodeEnvelope('no envelope here')).toThrow()
254
+ })
255
+ test('missing message throws', () => {
256
+ expect(() => decodeEnvelope('<iap from-personality="a" from-runtime="claude"></iap>')).toThrow()
257
+ })
258
+ test('unknown from-intelligence is dropped, not invented', () => {
259
+ const xml =
260
+ '<iap from-personality="a" from-runtime="claude" from-intelligence="bogus">\n<message><![CDATA[x]]></message>\n</iap>'
261
+ const d = decodeEnvelope(xml)
262
+ expect(d.fromIntelligence).toBeUndefined()
263
+ expect(d.message).toBe('x')
264
+ })
265
+
266
+ test('READ-COMPAT: legacy from-intelligence="human" decodes to natural', () => {
267
+ const xml =
268
+ '<iap from-personality="arthur" from-runtime="telegram" from-intelligence="human">\n<message><![CDATA[hi]]></message>\n</iap>'
269
+ expect(decodeEnvelope(xml).fromIntelligence).toBe('natural')
270
+ })
271
+ })