@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.
- package/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- 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(/"/g, '"')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/&/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
|
+
})
|