@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,217 @@
|
|
|
1
|
+
// IAP envelope codec — encoder + decoder, both halves in one module.
|
|
2
|
+
//
|
|
3
|
+
// Encoder: inter-agent-protocol/src/lib/preamble.ts buildEnvelope (wins as-is).
|
|
4
|
+
// Decoder: telegram-runtime/src/cli.ts parseIapEnvelope/extractIapEnvelopes,
|
|
5
|
+
// rewritten CDATA-AWARE (blueprint-v2 codec-fixes) so that an envelope whose
|
|
6
|
+
// message contains `</iap>`, `</message>`, `]]>` or a literal CR round-trips
|
|
7
|
+
// by FIELD-semantic equivalence, not byte-equality.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately NO CR→LF fold here. The old telegram decoder did
|
|
10
|
+
// `xml.replace(/\r\n?/g, '\n')` to repair tmux paste (which rewrites LF→CR).
|
|
11
|
+
// That is a TRANSPORT concern: folding inside the codec silently destroys a
|
|
12
|
+
// literal CR that a caller legitimately put in the message, breaking round-trip.
|
|
13
|
+
// The fold lives in the transport / telegram pane-adapter, not here
|
|
14
|
+
// (blueprint §0.5-ish, brief: "CR→LF fold — в transport/telegram-адаптере, НЕ в codec").
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
IAP_INSTRUCTION,
|
|
18
|
+
MAX_TOPIC_LEN,
|
|
19
|
+
normalizeIntelligenceValue,
|
|
20
|
+
type Intelligence,
|
|
21
|
+
type Runtime,
|
|
22
|
+
} from '../core/constants.ts'
|
|
23
|
+
import { IapError } from '../core/errors.ts'
|
|
24
|
+
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Encoder (canon)
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface EnvelopeInput {
|
|
30
|
+
fromPersonality: string
|
|
31
|
+
fromRuntime: Runtime
|
|
32
|
+
fromIntelligence: Intelligence
|
|
33
|
+
topic?: string
|
|
34
|
+
attachments?: readonly string[]
|
|
35
|
+
message: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escapeAttr(value: string): string {
|
|
39
|
+
return value
|
|
40
|
+
.replace(/&/g, '&')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cdata(value: string): string {
|
|
47
|
+
// A literal `]]>` would terminate the CDATA early. Split it across two
|
|
48
|
+
// sections; the decoder reconstructs it by concatenating adjacent sections.
|
|
49
|
+
return `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildEnvelope(input: EnvelopeInput): string {
|
|
53
|
+
const attrs = [
|
|
54
|
+
`from-personality="${escapeAttr(input.fromPersonality)}"`,
|
|
55
|
+
`from-runtime="${escapeAttr(input.fromRuntime)}"`,
|
|
56
|
+
`from-intelligence="${escapeAttr(input.fromIntelligence)}"`,
|
|
57
|
+
]
|
|
58
|
+
const topic = input.topic?.trim()
|
|
59
|
+
if (topic) {
|
|
60
|
+
attrs.push(`topic="${escapeAttr(topic.slice(0, MAX_TOPIC_LEN))}"`)
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
`<iap ${attrs.join(' ')}>`,
|
|
64
|
+
IAP_INSTRUCTION,
|
|
65
|
+
...(input.attachments?.length
|
|
66
|
+
? [`<attachments>${cdata(input.attachments.join('\n'))}</attachments>`]
|
|
67
|
+
: []),
|
|
68
|
+
`<message>${cdata(input.message)}</message>`,
|
|
69
|
+
'</iap>',
|
|
70
|
+
].join('\n')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
// CDATA-aware low-level scan
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const CDATA_OPEN = '<![CDATA['
|
|
78
|
+
const CDATA_CLOSE = ']]>'
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Scan from `start` and return the index of the first occurrence of `needle`
|
|
82
|
+
* that lies OUTSIDE any CDATA section, or -1 if none (or if an unterminated
|
|
83
|
+
* CDATA section is hit — meaning the buffer is incomplete and the caller should
|
|
84
|
+
* wait for more input). CDATA sections are skipped wholesale: `]]>` inside them
|
|
85
|
+
* is the section terminator, never a match for `needle`.
|
|
86
|
+
*/
|
|
87
|
+
function indexOfOutsideCdata(buffer: string, needle: string, start: number): number {
|
|
88
|
+
let i = start
|
|
89
|
+
while (i < buffer.length) {
|
|
90
|
+
if (buffer.startsWith(CDATA_OPEN, i)) {
|
|
91
|
+
const term = buffer.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
|
|
92
|
+
if (term < 0) return -1 // unterminated CDATA → incomplete buffer
|
|
93
|
+
i = term + CDATA_CLOSE.length
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (buffer.startsWith(needle, i)) return i
|
|
97
|
+
i++
|
|
98
|
+
}
|
|
99
|
+
return -1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract and decode the content of `<tag>…</tag>`, treating the body as a
|
|
104
|
+
* sequence of CDATA sections (and any stray raw text) and concatenating them.
|
|
105
|
+
* Concatenating adjacent CDATA sections is exactly what reverses the
|
|
106
|
+
* `]]>` → `]]]]><![CDATA[>` split the encoder performs, so this both
|
|
107
|
+
* (a) ignores `</tag>` / `</iap>` that appear inside CDATA, and
|
|
108
|
+
* (b) reconstructs a literal `]]>` in the original payload.
|
|
109
|
+
* Returns undefined when the open tag is absent or the close tag is never
|
|
110
|
+
* reached outside CDATA.
|
|
111
|
+
*/
|
|
112
|
+
function readTagContent(xml: string, tag: string): string | undefined {
|
|
113
|
+
const open = `<${tag}>`
|
|
114
|
+
const close = `</${tag}>`
|
|
115
|
+
const openIdx = xml.indexOf(open)
|
|
116
|
+
if (openIdx < 0) return undefined
|
|
117
|
+
let i = openIdx + open.length
|
|
118
|
+
let out = ''
|
|
119
|
+
while (i < xml.length) {
|
|
120
|
+
if (xml.startsWith(CDATA_OPEN, i)) {
|
|
121
|
+
const term = xml.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
|
|
122
|
+
if (term < 0) {
|
|
123
|
+
// Unterminated CDATA — malformed; treat the remainder as raw content.
|
|
124
|
+
out += xml.slice(i + CDATA_OPEN.length)
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
out += xml.slice(i + CDATA_OPEN.length, term)
|
|
128
|
+
i = term + CDATA_CLOSE.length
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (xml.startsWith(close, i)) return out
|
|
132
|
+
out += xml[i]
|
|
133
|
+
i++
|
|
134
|
+
}
|
|
135
|
+
return undefined // close tag never found
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Decoder
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export interface IapEnvelope {
|
|
143
|
+
fromPersonality: string
|
|
144
|
+
fromRuntime: Runtime
|
|
145
|
+
fromIntelligence?: Intelligence
|
|
146
|
+
topic?: string
|
|
147
|
+
attachments: string[]
|
|
148
|
+
message: string
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function attrValue(attrs: string, name: string): string | undefined {
|
|
152
|
+
const re = new RegExp(`${name}="([^"]*)"`)
|
|
153
|
+
const m = re.exec(attrs)
|
|
154
|
+
return m ? unescapeAttr(m[1]) : undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function unescapeAttr(value: string): string {
|
|
158
|
+
// Reverse escapeAttr. Order matters: & LAST so an escaped "&lt;"
|
|
159
|
+
// in the source does not get double-decoded.
|
|
160
|
+
return value
|
|
161
|
+
.replace(/"/g, '"')
|
|
162
|
+
.replace(/</g, '<')
|
|
163
|
+
.replace(/>/g, '>')
|
|
164
|
+
.replace(/&/g, '&')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function decodeEnvelope(xml: string): IapEnvelope {
|
|
168
|
+
const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
|
|
169
|
+
if (!open) throw new IapError('invalid IAP envelope: missing <iap ...>')
|
|
170
|
+
const fromPersonality = attrValue(open[1], 'from-personality')
|
|
171
|
+
const fromRuntime = attrValue(open[1], 'from-runtime')
|
|
172
|
+
if (!fromPersonality || !fromRuntime) {
|
|
173
|
+
throw new IapError('invalid IAP envelope: missing from-personality/from-runtime')
|
|
174
|
+
}
|
|
175
|
+
// READ-COMPAT: a legacy peer (live telegram) may stamp from-intelligence="human";
|
|
176
|
+
// normalize human→natural / scripted→absent, drop a genuinely unknown value.
|
|
177
|
+
const fromIntelligence = normalizeIntelligenceValue(attrValue(open[1], 'from-intelligence'))
|
|
178
|
+
const message = readTagContent(xml, 'message')
|
|
179
|
+
if (message === undefined) throw new IapError('invalid IAP envelope: missing message')
|
|
180
|
+
const attachmentsRaw = readTagContent(xml, 'attachments')
|
|
181
|
+
const topic = attrValue(open[1], 'topic')
|
|
182
|
+
return {
|
|
183
|
+
fromPersonality,
|
|
184
|
+
fromRuntime,
|
|
185
|
+
...(fromIntelligence ? { fromIntelligence } : {}),
|
|
186
|
+
...(topic ? { topic } : {}),
|
|
187
|
+
attachments: attachmentsRaw
|
|
188
|
+
? attachmentsRaw.split('\n').map(item => item.trim()).filter(Boolean)
|
|
189
|
+
: [],
|
|
190
|
+
message,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pull complete `<iap …>…</iap>` envelopes out of a streaming buffer.
|
|
196
|
+
* The closing `</iap>` is located CDATA-aware, so an envelope whose message
|
|
197
|
+
* body contains the literal text `</iap>` is not truncated. `rest` holds the
|
|
198
|
+
* trailing bytes that do not yet form a complete envelope (incl. an envelope
|
|
199
|
+
* still mid-CDATA), to be prepended to the next chunk.
|
|
200
|
+
*/
|
|
201
|
+
export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
|
|
202
|
+
const envelopes: string[] = []
|
|
203
|
+
let rest = buffer
|
|
204
|
+
while (true) {
|
|
205
|
+
const start = rest.indexOf('<iap ')
|
|
206
|
+
if (start < 0) {
|
|
207
|
+
// Keep a small tail in case `<iap ` is split across chunk boundaries.
|
|
208
|
+
return { envelopes, rest: rest.slice(Math.max(0, rest.length - '<iap '.length)) }
|
|
209
|
+
}
|
|
210
|
+
if (start > 0) rest = rest.slice(start)
|
|
211
|
+
const close = indexOfOutsideCdata(rest, '</iap>', '<iap '.length)
|
|
212
|
+
if (close < 0) return { envelopes, rest } // incomplete (or mid-CDATA) → wait
|
|
213
|
+
const envelopeEnd = close + '</iap>'.length
|
|
214
|
+
envelopes.push(rest.slice(0, envelopeEnd))
|
|
215
|
+
rest = rest.slice(envelopeEnd)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// resolveSockDir — the ONE socket-dir resolver every socket-touching site shares
|
|
2
|
+
// (transport scan/resolve, lifecycle, launchdRun). Regression guard against a site
|
|
3
|
+
// re-hardcoding DEFAULT_SOCK_DIR, which made an IAPEER_SOCK_DIR sandbox lie (the
|
|
4
|
+
// session created on the override dir, the resolver scanning /tmp → false offline).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { DEFAULT_SOCK_DIR, resolveSockDir } from './constants.ts'
|
|
8
|
+
|
|
9
|
+
describe('resolveSockDir', () => {
|
|
10
|
+
test('no override → DEFAULT_SOCK_DIR (/tmp, contract sock convention)', () => {
|
|
11
|
+
expect(resolveSockDir({})).toBe(DEFAULT_SOCK_DIR)
|
|
12
|
+
expect(resolveSockDir({})).toBe('/tmp')
|
|
13
|
+
})
|
|
14
|
+
test('IAPEER_SOCK_DIR override is respected (host-wide, like IAPEER_ROOT)', () => {
|
|
15
|
+
expect(resolveSockDir({ IAPEER_SOCK_DIR: '/tmp/sbx/socks' })).toBe('/tmp/sbx/socks')
|
|
16
|
+
})
|
|
17
|
+
test('blank/whitespace override falls back to the default (not an empty dir)', () => {
|
|
18
|
+
expect(resolveSockDir({ IAPEER_SOCK_DIR: ' ' })).toBe(DEFAULT_SOCK_DIR)
|
|
19
|
+
expect(resolveSockDir({ IAPEER_SOCK_DIR: '' })).toBe(DEFAULT_SOCK_DIR)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Canonical constants for the IAPeer foundation.
|
|
2
|
+
// Consolidated from inter-agent-protocol/src/lib/constants.ts (wins as-is) and
|
|
3
|
+
// extended with storage-layer path names (blueprint §1 core/constants).
|
|
4
|
+
|
|
5
|
+
export const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
|
|
6
|
+
export const NAME_RE_SOURCE = '^[a-z][a-z0-9-]{0,31}$'
|
|
7
|
+
export const RUNTIME_RE = /^[a-z][a-z0-9]{0,31}$/
|
|
8
|
+
export const RUNTIME_RE_SOURCE = '^[a-z][a-z0-9]{0,31}$'
|
|
9
|
+
|
|
10
|
+
export type Runtime = string
|
|
11
|
+
export type TmuxRuntime = Runtime
|
|
12
|
+
export const SUPPORTED_LOCAL_RUNTIMES = ['claude', 'codex'] as const
|
|
13
|
+
export type SupportedLocalRuntime = (typeof SUPPORTED_LOCAL_RUNTIMES)[number]
|
|
14
|
+
|
|
15
|
+
export const PEERS_SCHEMA_VERSION = 2
|
|
16
|
+
export const MAX_DESCRIPTION_LEN = 250
|
|
17
|
+
|
|
18
|
+
// Contract vocabulary (docs/Идентичность, Артур 05.06): the nature of the
|
|
19
|
+
// intelligence expressing itself through a runtime.
|
|
20
|
+
// artificial — AI agent · natural — human · absent — programmatic source
|
|
21
|
+
export const INTELLIGENCE_VALUES = ['artificial', 'natural', 'absent'] as const
|
|
22
|
+
export type Intelligence = (typeof INTELLIGENCE_VALUES)[number]
|
|
23
|
+
|
|
24
|
+
export function isIntelligence(value: unknown): value is Intelligence {
|
|
25
|
+
return typeof value === 'string' && (INTELLIGENCE_VALUES as readonly string[]).includes(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// READ-COMPAT: the live registry/profiles still carry the previous vocabulary
|
|
29
|
+
// (human/scripted) until the coordinated live-fleet migration. The foundation
|
|
30
|
+
// MUST read that data correctly — map legacy → contract on READ only. It does
|
|
31
|
+
// NOT rewrite the live fleet (that migration is a separate, coordinated step;
|
|
32
|
+
// it touches the live telegram human-guard which keys on 'human').
|
|
33
|
+
// human → natural · scripted → absent
|
|
34
|
+
const LEGACY_INTELLIGENCE: Readonly<Record<string, Intelligence>> = {
|
|
35
|
+
human: 'natural',
|
|
36
|
+
scripted: 'absent',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read-compat normalizer for an intelligence value coming off disk / the wire.
|
|
41
|
+
* Returns the contract value (passing through artificial/natural/absent and
|
|
42
|
+
* mapping legacy human→natural / scripted→absent), or undefined when the value
|
|
43
|
+
* is unrecognised (caller decides whether to throw or fall back to a default).
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeIntelligenceValue(value: unknown): Intelligence | undefined {
|
|
46
|
+
if (typeof value !== 'string') return undefined
|
|
47
|
+
if (isIntelligence(value)) return value
|
|
48
|
+
return LEGACY_INTELLIGENCE[value]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const NATURAL_RUNTIMES = new Set(['telegram', 'discord', 'matrix', 'email', 'web'])
|
|
52
|
+
const ABSENT_RUNTIMES = new Set(['notifier', 'webhook', 'api', 'cron'])
|
|
53
|
+
|
|
54
|
+
export function defaultIntelligenceForRuntime(runtime: string): Intelligence {
|
|
55
|
+
if (NATURAL_RUNTIMES.has(runtime)) return 'natural'
|
|
56
|
+
if (ABSENT_RUNTIMES.has(runtime)) return 'absent'
|
|
57
|
+
return 'artificial'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Infra runtimes are ALWAYS-ON (held live by launchd KeepAlive), as opposed to the
|
|
61
|
+
// warm-on-demand agentic runtimes (claude/codex, woken by the daemon). Liveness is
|
|
62
|
+
// a property of the RUNTIME, not the personality (zone Идентичность). Both infra
|
|
63
|
+
// runtimes are routers with a tmux endpoint: telegram receives inbound messages,
|
|
64
|
+
// notifier receives send_to_peer(timer|watcher, …) registration/live-reload — so
|
|
65
|
+
// each needs a live tmux pane for the daemon's deliverViaTmux to paste into. This
|
|
66
|
+
// set gates always-on launchd plist generation (src/launch/launchd.ts).
|
|
67
|
+
const INFRA_RUNTIMES = new Set(['notifier', 'telegram'])
|
|
68
|
+
|
|
69
|
+
export function isInfraRuntime(runtime: string): boolean {
|
|
70
|
+
return INFRA_RUNTIMES.has(runtime)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Each INFRA runtime's always-on plist PINS its launcher binary to an ABSOLUTE
|
|
74
|
+
// path via a runtime-specific env var. launchd gives a job a MINIMAL PATH (no
|
|
75
|
+
// ~/.local/bin, ~/.bun/bin, /opt/homebrew/bin), so a bare `notifier-runtime`
|
|
76
|
+
// would not resolve and the always-on session would crash-loop. The plist baker
|
|
77
|
+
// (launchd.installAlwaysOnPlist) resolves the bin against the rich provisioning
|
|
78
|
+
// PATH and writes the abs path here; launchdRun reads it back into
|
|
79
|
+
// LaunchConfig.{notifierBin,telegramBin} → adapter.buildArgv. SINGLE source for
|
|
80
|
+
// both the baker and the reader so they cannot drift on the var name.
|
|
81
|
+
export const INFRA_RUNTIME_BIN_ENV: Readonly<Record<string, string>> = {
|
|
82
|
+
notifier: 'NOTIFIER_RUNTIME_BIN',
|
|
83
|
+
telegram: 'TELEGRAM_RUNTIME_BIN',
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The PATH-resolvable default launcher name per infra runtime (when no abs path
|
|
87
|
+
* is pinned). Mirrors the adapter buildArgv fallbacks (`notifier-runtime` /
|
|
88
|
+
* `telegram-runtime`). */
|
|
89
|
+
export const INFRA_RUNTIME_DEFAULT_BIN: Readonly<Record<string, string>> = {
|
|
90
|
+
notifier: 'notifier-runtime',
|
|
91
|
+
telegram: 'telegram-runtime',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// codex MCP token-free import (contract Установка §codex MCP). codex REFUSES to import
|
|
95
|
+
// tools from an OPEN streamable-HTTP MCP server — it marks it authStatus=unsupported
|
|
96
|
+
// (and blocks on startup) unless an auth scheme is configured (codex bug #21532). The
|
|
97
|
+
// fix is a NON-SECRET fixed bearer: setting `bearer_token_env_var` flips authStatus to
|
|
98
|
+
// `bearer_token` purely from the config FACT (codex does not require the server to
|
|
99
|
+
// validate the token). The daemon stays OPEN — it ignores `Authorization` and resolves
|
|
100
|
+
// the caller from the X-IAPeer-Identity header (loopback same-uid + per-peer identity
|
|
101
|
+
// is the auth, the same as the claude side). So a codex peer's launch sets this env var
|
|
102
|
+
// to the public stub below; nothing real is gated. Proven live (codex 0.136, gpt-5.5).
|
|
103
|
+
/** The env var codex reads the bearer from (config `bearer_token_env_var`); the launch
|
|
104
|
+
* sets it for a codex peer. */
|
|
105
|
+
export const CODEX_BEARER_ENV_VAR = 'IAPEER_BEARER'
|
|
106
|
+
/** The fixed, PUBLIC, non-secret bearer value codex sends to satisfy its own auth gate.
|
|
107
|
+
* Deliberately not a secret — the daemon never validates it (it is open on loopback). */
|
|
108
|
+
export const CODEX_DUMMY_BEARER = 'iapeer-localhost-open-no-secret'
|
|
109
|
+
|
|
110
|
+
export const MAX_MESSAGE_LEN = 16_000
|
|
111
|
+
export const MAX_TOPIC_LEN = 200
|
|
112
|
+
export const MAX_ATTACHMENTS = 20
|
|
113
|
+
export const DEFAULT_SOCK_DIR = '/tmp'
|
|
114
|
+
|
|
115
|
+
// The directory holding tmux iap-sockets. Canonically /tmp (contract sock convention
|
|
116
|
+
// `/tmp/tmux-iap-<identity>.sock`); IAPEER_SOCK_DIR overrides it host-wide, exactly
|
|
117
|
+
// like IAPEER_ROOT overrides the storage root. EVERY socket-touching site (transport
|
|
118
|
+
// scan/resolve, lifecycle, launchdRun) MUST resolve through this ONE helper so they
|
|
119
|
+
// agree — a site that hardcodes DEFAULT_SOCK_DIR would look in /tmp while a sandbox
|
|
120
|
+
// (IAPEER_SOCK_DIR set) created the session elsewhere → a false "offline".
|
|
121
|
+
export function resolveSockDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
122
|
+
return env.IAPEER_SOCK_DIR?.trim() || DEFAULT_SOCK_DIR
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// === per-peer cwd scope ===
|
|
126
|
+
export const IAPEER_DIR = '.iapeer'
|
|
127
|
+
export const PEER_PROFILE_FILE = 'peer-profile.json'
|
|
128
|
+
|
|
129
|
+
// === global scope ~/.iapeer/ ===
|
|
130
|
+
export const IAP_PLUGIN_DIR = 'iap'
|
|
131
|
+
export const PEERS_PROFILES_FILE = 'peers-profiles.json'
|
|
132
|
+
export const PEERS_PROFILES_LOCK_FILE = 'peers-profiles.lock'
|
|
133
|
+
|
|
134
|
+
// Storage category roots (blueprint §1 storage). One env override: IAPEER_ROOT.
|
|
135
|
+
export const IAPEER_ROOT_ENV = 'IAPEER_ROOT'
|
|
136
|
+
export const STATE_DIR = 'state'
|
|
137
|
+
export const LOGS_DIR = 'logs'
|
|
138
|
+
export const CACHE_DIR = 'cache'
|
|
139
|
+
export const PLUGINS_DIR = 'plugins'
|
|
140
|
+
export const RUNTIMES_DIR = 'runtimes'
|
|
141
|
+
// The default home for foundation-provisioned peer cwds (`iapeer create` lands a peer
|
|
142
|
+
// here when --path is not given): ~/.iapeer/peers/<personality>. Foundation-owned and
|
|
143
|
+
// collision-free — unlike the organic ~/Peers/ the legacy fleet grew in (NOT the
|
|
144
|
+
// default; existing ~/Peers/* peers are grandfathered, the registry holds any cwd).
|
|
145
|
+
export const PEERS_HOME_DIR = 'peers'
|
|
146
|
+
|
|
147
|
+
// launchd labels (future lifecycle/daemon phases; named here so storage/cli agree).
|
|
148
|
+
export const LAUNCHD_LABEL_PREFIX = 'com.iapeer.'
|
|
149
|
+
export const DAEMON_PLIST_LABEL = 'com.agfpd.iapeer'
|
|
150
|
+
|
|
151
|
+
export const IAP_INSTRUCTION =
|
|
152
|
+
'IAP message from known peers. Reply via send_to_peer.'
|
|
153
|
+
|
|
154
|
+
export const ALWAYS_LOAD_META = {
|
|
155
|
+
'anthropic/alwaysLoad': true,
|
|
156
|
+
} as const
|
|
157
|
+
|
|
158
|
+
export function isRuntime(value: unknown): value is Runtime {
|
|
159
|
+
return typeof value === 'string' && RUNTIME_RE.test(value)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function isTmuxRuntime(value: unknown): value is TmuxRuntime {
|
|
163
|
+
return isRuntime(value)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isSupportedLocalRuntime(value: unknown): value is SupportedLocalRuntime {
|
|
167
|
+
return (
|
|
168
|
+
typeof value === 'string' &&
|
|
169
|
+
(SUPPORTED_LOCAL_RUNTIMES as readonly string[]).includes(value)
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isValidName(value: unknown): boolean {
|
|
174
|
+
return typeof value === 'string' && NAME_RE.test(value)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The name normalizer (normalize(s) = slug(transliterate(s))) lives in its own
|
|
178
|
+
// module so the transliteration engine is swappable behind one interface; it is
|
|
179
|
+
// re-exported here so existing `from '../core/constants.ts'` importers are unchanged.
|
|
180
|
+
export { normalizeNameCandidate } from './normalize.ts'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Result type + IapError. Consolidated from inter-agent-protocol/src/lib/errors.ts (as-is).
|
|
2
|
+
|
|
3
|
+
export class IapError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'IapError'
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Result<T> =
|
|
11
|
+
| { ok: true; value: T }
|
|
12
|
+
| { ok: false; error: IapError }
|
|
13
|
+
|
|
14
|
+
export function ok<T>(value: T): Result<T> {
|
|
15
|
+
return { ok: true, value }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function err<T = never>(message: string): Result<T> {
|
|
19
|
+
return { ok: false, error: new IapError(message) }
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// normalizeNameCandidate — the shared name normalizer (normalize = slug∘translit).
|
|
2
|
+
// Covers the zone goldens, ASCII non-regression, writing systems (Cyrillic / Greek
|
|
3
|
+
// / CJK), idempotency, the ICU `№` symbol-alignment, and the fail-to-explicit edge
|
|
4
|
+
// (a non-transliterable / leading-digit / empty result is returned AS-IS and does
|
|
5
|
+
// NOT satisfy NAME_RE, so the caller can reject it rather than invent a name).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test'
|
|
8
|
+
import { isValidName, NAME_RE, normalizeNameCandidate as normalize } from './constants.ts'
|
|
9
|
+
|
|
10
|
+
describe('normalizeNameCandidate — zone goldens', () => {
|
|
11
|
+
test.each([
|
|
12
|
+
['Café №2', 'cafe-no-2'], // accents + ICU `№ → No.` symbol alignment
|
|
13
|
+
['项目', 'xiang-mu'], // Han → pinyin WITH syllable spacing
|
|
14
|
+
['My Project', 'my-project'],
|
|
15
|
+
])('%j → %j', (input, expected) => {
|
|
16
|
+
expect(normalize(input)).toBe(expected)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('normalizeNameCandidate — ASCII non-regression', () => {
|
|
21
|
+
test.each([
|
|
22
|
+
['iapeer', 'iapeer'],
|
|
23
|
+
['boris', 'boris'],
|
|
24
|
+
['notifier-timer', 'notifier-timer'], // hyphen preserved, not collapsed
|
|
25
|
+
['darwin', 'darwin'],
|
|
26
|
+
])('%j → %j', (input, expected) => {
|
|
27
|
+
expect(normalize(input)).toBe(expected)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('normalizeNameCandidate — writing systems (ICU Any-Latin; Latin-ASCII)', () => {
|
|
32
|
+
test.each([
|
|
33
|
+
['Артур', 'artur'], // Cyrillic
|
|
34
|
+
['Борис', 'boris'],
|
|
35
|
+
['Наталья', 'natalya'], // matches the live registered peer "natalya"
|
|
36
|
+
['東京', 'dong-jing'], // CJK with syllable spacing
|
|
37
|
+
['北京', 'bei-jing'],
|
|
38
|
+
['Ω', 'o'], // Greek
|
|
39
|
+
['naïve', 'naive'], // Latin diacritics
|
|
40
|
+
['Köln', 'koln'],
|
|
41
|
+
['Straße', 'strasse'],
|
|
42
|
+
])('%j → %j', (input, expected) => {
|
|
43
|
+
expect(normalize(input)).toBe(expected)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('normalizeNameCandidate — slug rules', () => {
|
|
48
|
+
test('collapses non-alnum runs to a single hyphen and trims edges', () => {
|
|
49
|
+
expect(normalize(' a b ')).toBe('a-b')
|
|
50
|
+
expect(normalize('a___b...c')).toBe('a-b-c')
|
|
51
|
+
expect(normalize('--lead-and-trail--')).toBe('lead-and-trail')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('caps length at 32 with no dangling trailing hyphen', () => {
|
|
55
|
+
const long = 'a'.repeat(40)
|
|
56
|
+
expect(normalize(long)).toBe('a'.repeat(32))
|
|
57
|
+
// a hyphen landing exactly at the cut must not survive as a trailing hyphen
|
|
58
|
+
const cut = `${'a'.repeat(31)}-bbb`
|
|
59
|
+
const out = normalize(cut)
|
|
60
|
+
expect(out.length).toBeLessThanOrEqual(32)
|
|
61
|
+
expect(out.endsWith('-')).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('normalizeNameCandidate — idempotency (normalize∘normalize === normalize)', () => {
|
|
66
|
+
test.each(['Café №2', '项目', 'My Project', 'Артур', 'boris', 'notifier-timer', '東京'])(
|
|
67
|
+
'%j is a fixed point of a second pass',
|
|
68
|
+
input => {
|
|
69
|
+
const once = normalize(input)
|
|
70
|
+
expect(normalize(once)).toBe(once)
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('normalizeNameCandidate — fail-to-explicit edges (returned AS-IS, not mangled)', () => {
|
|
76
|
+
test.each([
|
|
77
|
+
['', ''], // empty
|
|
78
|
+
['①②③', ''], // non-transliterable circled digits → drop
|
|
79
|
+
['😀', ''], // emoji → drop
|
|
80
|
+
['---', ''], // only separators
|
|
81
|
+
['2nd', '2nd'], // leading digit survives but is NOT a valid name
|
|
82
|
+
['42', '42'],
|
|
83
|
+
])('%j → %j', (input, expected) => {
|
|
84
|
+
expect(normalize(input)).toBe(expected)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('edge results do NOT satisfy NAME_RE → caller fails-to-explicit', () => {
|
|
88
|
+
for (const bad of ['', '①②③', '😀', '---', '2nd', '42']) {
|
|
89
|
+
expect(isValidName(normalize(bad))).toBe(false)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('valid goldens DO satisfy NAME_RE', () => {
|
|
94
|
+
for (const good of ['cafe-no-2', 'xiang-mu', 'my-project', 'iapeer', 'artur']) {
|
|
95
|
+
expect(NAME_RE.test(good)).toBe(true)
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// normalize — the SINGLE shared name normalizer (zone Идентичность):
|
|
2
|
+
// normalize(s) = slug(transliterate(s))
|
|
3
|
+
//
|
|
4
|
+
// The whole transliteration engine is isolated behind this one module so it can
|
|
5
|
+
// be swapped (e.g. to a literal WASM-ICU binding) without touching any call site:
|
|
6
|
+
// identity uses `normalizeNameCandidate` exclusively (re-exported from constants).
|
|
7
|
+
//
|
|
8
|
+
// ENGINE CHOICE (see _planning/normalizer-plan.md for the empirical comparison of
|
|
9
|
+
// six engines against the zone goldens). The contract mandates ICU
|
|
10
|
+
// `Any-Latin; Latin-ASCII`. Node/Bun `Intl` does NOT expose an ICU Transliterator,
|
|
11
|
+
// and system `uconv`/`iconv` are forbidden by the zone (OS-nondeterministic) and
|
|
12
|
+
// absent here. `transliteration` is the one pure-JS engine that reproduces ICU
|
|
13
|
+
// across WRITING SYSTEMS — CJK → pinyin WITH syllable spacing (项目 → "Xiang Mu"),
|
|
14
|
+
// Cyrillic (Артур → "Artur"), Greek (Ω → "O"), Latin diacritics (Köln → "Koln") —
|
|
15
|
+
// and it bundles its own CLDR/unidecode-derived tables, so it is deterministic
|
|
16
|
+
// across OSes (exactly the property the zone's iconv ban is protecting). It is the
|
|
17
|
+
// deterministic CLDR-EQUIVALENT of ICU, NOT a literal ICU engine.
|
|
18
|
+
//
|
|
19
|
+
// Where `transliteration` UNDER-maps a SYMBOL versus literal ICU `Latin-ASCII`,
|
|
20
|
+
// `ICU_LATIN_ASCII_SYMBOLS` realigns it (applied BEFORE transliterate). Seeded by
|
|
21
|
+
// the golden `№`: the package maps it to "no" (and "№2" → "no2", GLUING the digit);
|
|
22
|
+
// rewriting to ICU's literal "No." inserts a non-[a-z0-9] char that slug turns into
|
|
23
|
+
// the required separator → "no-2". So the align fixes a missing SEPARATOR, not a
|
|
24
|
+
// dropped punctuation mark (the package never emits one to "lose"). KNOWN LIMITATION:
|
|
25
|
+
// only the writing systems listed above (CJK / Cyrillic / Greek / Latin-diacritics)
|
|
26
|
+
// are verified-faithful to literal ICU; other scripts (Arabic / Hebrew / Korean …)
|
|
27
|
+
// may diverge — no ICU oracle on host to measure — and symbols outside this map may
|
|
28
|
+
// differ too. The map is extend-on-discovery; an unmapped symbol that transliterates
|
|
29
|
+
// to nothing safely drops (→ fail-to-explicit downstream).
|
|
30
|
+
|
|
31
|
+
import { transliterate } from 'transliteration'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ICU `Latin-ASCII` alignment for the symbols where `transliteration` diverges
|
|
35
|
+
* from literal ICU. Deterministic CLDR-equivalent, NOT literal ICU; this map fills
|
|
36
|
+
* the symbol gaps. Extend by adding `<symbol>: <ICU Latin-ASCII output>` as
|
|
37
|
+
* divergences surface.
|
|
38
|
+
*/
|
|
39
|
+
const ICU_LATIN_ASCII_SYMBOLS: Record<string, string> = {
|
|
40
|
+
// № NUMERO SIGN. Package → "no" (and "№2" → "no2", gluing the digit). The literal
|
|
41
|
+
// ICU `Latin-ASCII` value is "No."; what makes the golden pass is the non-[a-z0-9]
|
|
42
|
+
// char between "no" and the digit (slug turns it into the separator → "no-2"). The
|
|
43
|
+
// trailing "." itself is dropped by slug — kept here only because it is ICU's exact
|
|
44
|
+
// output (mapping to "No-" or "no " would normalize identically).
|
|
45
|
+
'№': 'No.',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function alignIcuSymbols(value: string): string {
|
|
49
|
+
let out = value
|
|
50
|
+
for (const [symbol, ascii] of Object.entries(ICU_LATIN_ASCII_SYMBOLS)) {
|
|
51
|
+
if (out.includes(symbol)) out = out.split(symbol).join(ascii)
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// NAME_RE = /^[a-z][a-z0-9-]{0,31}$/ → at most 32 chars. slug bounds the length so a
|
|
57
|
+
// long basename truncates deterministically. The LEADING-LETTER constraint is NOT
|
|
58
|
+
// enforced here (a leading digit/hyphen result is returned as-is and the caller
|
|
59
|
+
// fails-to-explicit) — the normalizer must never mangle a name to force validity.
|
|
60
|
+
const MAX_NAME_LEN = 32
|
|
61
|
+
|
|
62
|
+
/** slug: lowercase → every non-[a-z0-9] run → a single hyphen → trim edge hyphens
|
|
63
|
+
* → cap length → drop a hyphen the length-cap may have left dangling. */
|
|
64
|
+
function slug(value: string): string {
|
|
65
|
+
return value
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
68
|
+
.replace(/^-+|-+$/g, '')
|
|
69
|
+
.slice(0, MAX_NAME_LEN)
|
|
70
|
+
.replace(/-+$/, '')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* normalize(s) = slug(transliterate(s)) — the shared primitive every name
|
|
75
|
+
* derivation/comparison routes through (basename(cwd) → personality, PEER_*
|
|
76
|
+
* comparisons, profile re-validation).
|
|
77
|
+
*
|
|
78
|
+
* IDEMPOTENT on already-valid names: normalize("boris") === "boris",
|
|
79
|
+
* normalize("notifier-timer") === "notifier-timer" — required because
|
|
80
|
+
* validatePersonality re-normalizes stored profile names.
|
|
81
|
+
*
|
|
82
|
+
* A non-transliterable / empty / leading-digit result is returned AS-IS (e.g.
|
|
83
|
+
* "①②③" → "", "2nd" → "2nd"); it will not match NAME_RE, and the caller
|
|
84
|
+
* fails-to-explicit ("create peer-profile.json explicitly") rather than the
|
|
85
|
+
* normalizer silently inventing a name.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeNameCandidate(value: string): string {
|
|
88
|
+
return slug(transliterate(alignIcuSymbols(value)))
|
|
89
|
+
}
|