@agfpd/iapeer 0.2.38 → 0.2.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.38",
3
+ "version": "0.2.40",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -1339,6 +1339,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1339
1339
  return 1
1340
1340
  }
1341
1341
  out(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
1342
+ // Attach-window observability (killer-hunt 11.06: «была ли смерть под
1343
+ // attach» was answerable only INDIRECTLY via wake cause=attach — i.e. only
1344
+ // when the session was dead at attach time). Bracket the operator's client
1345
+ // window in lifecycle.log: ev=attach at entry, ev=attach-end (with the tmux
1346
+ // client's exit code) when the operator detaches or the server dies under
1347
+ // the client. A death timestamp inside an attach…attach-end window now
1348
+ // reads directly off the log.
1349
+ const attachCfg = loadLifecycleConfig(env)
1350
+ appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach', identity: r.identity, woke: String(r.woke) }, { env })
1342
1351
  // Drop the operator into the session. `env -u TMUX` so a nested attach from
1343
1352
  // inside tmux does not error ("sessions should be nested with care").
1344
1353
  const attachEnv = { ...env }
@@ -1347,6 +1356,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1347
1356
  stdio: 'inherit',
1348
1357
  env: attachEnv as Record<string, string>,
1349
1358
  })
1359
+ appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: a.status ?? -1 }, { env })
1350
1360
  return a.status ?? 0
1351
1361
  }
1352
1362
  default: {
@@ -0,0 +1,94 @@
1
+ // tmux command-log (cmdlog.ts) — the killer-hunt socket-command layer.
2
+ //
3
+ // The live suite encodes boris's acceptance criterion directly: a kill-session
4
+ // issued by an EXTERNAL client must land in the -v server log WITH client
5
+ // attribution (`message: client-<pid> command: …`), file modes never wider than
6
+ // 0600, and the cap keeps volume bounded without losing the tail.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
9
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, existsSync } from 'fs'
10
+ import { tmpdir } from 'os'
11
+ import { join } from 'path'
12
+ import { spawnSync } from 'child_process'
13
+ import {
14
+ CMDLOG_KEEP_BYTES,
15
+ capCmdLogs,
16
+ cmdLogDirFor,
17
+ hardenCmdLogDir,
18
+ prepareCmdLogDir,
19
+ } from './cmdlog.ts'
20
+
21
+ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
22
+
23
+ let root: string
24
+ beforeAll(() => {
25
+ root = mkdtempSync(join(tmpdir(), 'cmdlog-'))
26
+ })
27
+ afterAll(() => {
28
+ rmSync(root, { recursive: true, force: true })
29
+ })
30
+
31
+ describe('prepareCmdLogDir / hardenCmdLogDir / capCmdLogs (hermetic)', () => {
32
+ test('prepare WIPES the previous generation and recreates 0700', () => {
33
+ const dir = prepareCmdLogDir(root, 'claude-x')
34
+ expect(dir).toBe(cmdLogDirFor(root, 'claude-x'))
35
+ writeFileSync(join(dir!, 'tmux-server-1.log'), 'old generation')
36
+ const again = prepareCmdLogDir(root, 'claude-x')
37
+ expect(readdirSync(again!)).toEqual([]) // old log gone — one generation per dir
38
+ expect(statSync(again!).mode & 0o777).toBe(0o700)
39
+ })
40
+
41
+ test('harden clamps every file to 0600', () => {
42
+ const dir = prepareCmdLogDir(root, 'claude-h')!
43
+ writeFileSync(join(dir, 'tmux-server-2.log'), 'x', { mode: 0o644 })
44
+ hardenCmdLogDir(dir)
45
+ expect(statSync(join(dir, 'tmux-server-2.log')).mode & 0o777).toBe(0o600)
46
+ })
47
+
48
+ test('cap tail-keeps an oversized log (KEEP bytes of TAIL survive), leaves small logs alone', () => {
49
+ const dir = prepareCmdLogDir(root, 'claude-c')!
50
+ const big = join(dir, 'tmux-server-3.log')
51
+ const small = join(dir, 'tmux-server-4.log')
52
+ // 2 MB of filler ending in a recognizable tail — cap to 1 KB keep for the test
53
+ writeFileSync(big, Buffer.concat([Buffer.alloc(2 * 1024 * 1024, 0x61), Buffer.from('THE-DEATH-TAIL')]))
54
+ writeFileSync(small, 'tiny')
55
+ const capped = capCmdLogs(root, 1024 * 1024, 1024)
56
+ expect(capped).toEqual([big])
57
+ const after = readFileSync(big)
58
+ expect(after.length).toBe(1024)
59
+ expect(after.toString()).toEndWith('THE-DEATH-TAIL') // the tail is what survives
60
+ expect(readFileSync(small, 'utf8')).toBe('tiny')
61
+ })
62
+
63
+ test('cap on a missing tree is a silent no-op', () => {
64
+ expect(capCmdLogs(join(root, 'absent'))).toEqual([])
65
+ })
66
+
67
+ test('default keep constant stays under the cap', () => {
68
+ expect(CMDLOG_KEEP_BYTES).toBeLessThan(8 * 1024 * 1024)
69
+ })
70
+ })
71
+
72
+ describe.if(tmuxAvailable)('live -v server log (acceptance criterion)', () => {
73
+ test('external client kill-session lands in the server log with client attribution; harden → 0600', () => {
74
+ const dir = prepareCmdLogDir(root, 'claude-live')!
75
+ const sock = join(root, 'live.sock')
76
+ // Start the server FROM the cmdlog dir with -v — exactly the launch wiring.
77
+ const start = spawnSync('tmux', ['-S', sock, '-v', 'new-session', '-d', '-x', '80', '-y', '24', '-s', 'claude-live', 'sleep 60'], {
78
+ cwd: dir,
79
+ encoding: 'utf8',
80
+ })
81
+ expect(start.status).toBe(0)
82
+ hardenCmdLogDir(dir)
83
+ // An EXTERNAL client (separate tmux invocation = separate client pid) kills it.
84
+ const kill = spawnSync('tmux', ['-S', sock, 'kill-session', '-t', 'claude-live'], { encoding: 'utf8' })
85
+ expect(kill.status).toBe(0)
86
+ const serverLog = readdirSync(dir).find(f => f.startsWith('tmux-server-'))
87
+ expect(serverLog).toBeDefined()
88
+ expect(statSync(join(dir, serverLog!)).mode & 0o777).toBe(0o600)
89
+ const text = readFileSync(join(dir, serverLog!), 'utf8')
90
+ // Attribution line: message: client-<pid> command: kill-session -t claude-live
91
+ expect(text).toMatch(/message: client-\d+ command: kill-session -t claude-live/)
92
+ spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
93
+ })
94
+ })
@@ -0,0 +1,130 @@
1
+ // tmux command-log — the SOCKET-COMMAND observability layer of the killer hunt.
2
+ //
3
+ // Why (death class 11.06, boris's session-deaths track): a tmux server killed by a
4
+ // CLIENT COMMAND (`kill-server` / `kill-session` over the unix socket) dies with
5
+ // zero signals — structurally invisible to ALL THREE existing layers: the eslogger
6
+ // trap (no kill(2) anywhere; kernel-originated SIGHUP to the panes is also proven
7
+ // invisible), the pane-died hook (kill-session destroys the pane without retention)
8
+ // and the server-death canary (sees only "server gone"). The tmux server's OWN
9
+ // verbose log is the only witness of that layer: started with `-v`, the server
10
+ // appends `message: client-<pid> command: <verb> <args>` for every client command —
11
+ // the attribution (client pid + exact command + timestamp) that decides the class
12
+ // binarily: "killed by client X" vs "no command ⇒ internal exit".
13
+ //
14
+ // Sensitivity (boris's requirement): the -v log carries FULL send-keys payloads
15
+ // (every delivered envelope, plaintext) — tmux has no commands-only level and the
16
+ // server writes the file itself (name tmux-server-<pid>.log in the STARTING
17
+ // client's cwd — no FIFO/pipe interposition possible). Containment instead:
18
+ // • the per-identity dir is 0700 (others cannot traverse — same protection class
19
+ // as the transcripts that already hold the same texts);
20
+ // • files are chmod'ed 0600 right after the server starts (hardenCmdLogDir);
21
+ // • volume is capped: ~7 KB/s under a 10 fps TUI redraw (measured) — the
22
+ // supervise tick tail-keeps each log over CAP_BYTES down to KEEP_BYTES.
23
+ // tmux writes with O_APPEND (proven live: external truncation is safe, the
24
+ // server keeps appending at the new EOF), and a killer's command is by nature
25
+ // the LAST thing in the log — the death tail always survives capping.
26
+ // • scope: agent-runtime (warm) sessions only — always-on infra sessions relaunch
27
+ // rarely (no rotation point) and are not the dying class.
28
+ // Rotation: prepareCmdLogDir WIPES the identity dir on every launch — one server
29
+ // generation per dir, no unbounded accumulation across relaunches.
30
+ //
31
+ // Best-effort BY CONSTRUCTION: observability must never fail a launch or a
32
+ // supervise tick — every export swallows FS errors.
33
+
34
+ import { chmodSync, mkdirSync, openSync, closeSync, readSync, readdirSync, rmSync, statSync, writeFileSync, fstatSync } from 'fs'
35
+ import { join } from 'path'
36
+
37
+ /** Tail-keep cap: a log over CAP_BYTES is cut down to its last KEEP_BYTES. */
38
+ export const CMDLOG_CAP_BYTES = 8 * 1024 * 1024
39
+ export const CMDLOG_KEEP_BYTES = 1024 * 1024
40
+
41
+ const CMDLOG_SUBDIR = 'tmux-cmdlog'
42
+
43
+ /** The per-identity command-log dir: <eventLogDir>/tmux-cmdlog/<identity>. The
44
+ * session-creating tmux client runs FROM this dir with `-v`, so the server drops
45
+ * tmux-server-<pid>.log (and the client its tmux-client-*.log) here. */
46
+ export function cmdLogDirFor(eventLogDir: string, identity: string): string {
47
+ return join(eventLogDir, CMDLOG_SUBDIR, identity)
48
+ }
49
+
50
+ /** Wipe + recreate the identity's command-log dir (0700) for a fresh server
51
+ * generation. Returns the dir, or undefined when the FS refuses (the caller then
52
+ * launches WITHOUT -v — observability is never load-bearing). */
53
+ export function prepareCmdLogDir(eventLogDir: string, identity: string): string | undefined {
54
+ try {
55
+ const dir = cmdLogDirFor(eventLogDir, identity)
56
+ rmSync(dir, { recursive: true, force: true })
57
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
58
+ return dir
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ /** Clamp every file the freshly-started server/client dropped into the dir to
65
+ * 0600 (the dir's 0700 already blocks traversal; this is the belt to that
66
+ * suspender — boris's acceptance criterion: no file wider than 0600). */
67
+ export function hardenCmdLogDir(dir: string): void {
68
+ try {
69
+ for (const name of readdirSync(dir)) {
70
+ try {
71
+ chmodSync(join(dir, name), 0o600)
72
+ } catch {
73
+ /* per-file best-effort */
74
+ }
75
+ }
76
+ } catch {
77
+ /* dir gone — nothing to harden */
78
+ }
79
+ }
80
+
81
+ /** Tail-keep cap over every command log under <eventLogDir>/tmux-cmdlog/<identity>.
82
+ * A file over capBytes is rewritten as its last keepBytes. Safe against the
83
+ * live writer: tmux logs with O_APPEND, so the server's next write lands at
84
+ * the new EOF (proven by live truncation test). Lines appended between the
85
+ * tail-read and the rewrite are lost — acceptable mid-life noise; the death
86
+ * tail is never here because the peer is alive while being capped. */
87
+ export function capCmdLogs(
88
+ eventLogDir: string,
89
+ capBytes: number = CMDLOG_CAP_BYTES,
90
+ keepBytes: number = CMDLOG_KEEP_BYTES,
91
+ ): string[] {
92
+ const capped: string[] = []
93
+ let identities: string[]
94
+ try {
95
+ identities = readdirSync(join(eventLogDir, CMDLOG_SUBDIR))
96
+ } catch {
97
+ return capped
98
+ }
99
+ for (const identity of identities) {
100
+ const dir = join(eventLogDir, CMDLOG_SUBDIR, identity)
101
+ let files: string[]
102
+ try {
103
+ files = readdirSync(dir)
104
+ } catch {
105
+ continue
106
+ }
107
+ for (const name of files) {
108
+ if (!name.endsWith('.log')) continue
109
+ const path = join(dir, name)
110
+ try {
111
+ if (statSync(path).size <= capBytes) continue
112
+ const fd = openSync(path, 'r')
113
+ let tail: Buffer
114
+ try {
115
+ const size = fstatSync(fd).size
116
+ const start = Math.max(0, size - keepBytes)
117
+ tail = Buffer.alloc(Math.min(keepBytes, size))
118
+ readSync(fd, tail, 0, tail.length, start)
119
+ } finally {
120
+ closeSync(fd)
121
+ }
122
+ writeFileSync(path, tail, { mode: 0o600 })
123
+ capped.push(path)
124
+ } catch {
125
+ /* per-file best-effort */
126
+ }
127
+ }
128
+ }
129
+ return capped
130
+ }
@@ -20,6 +20,7 @@ import { spawnSync } from 'child_process'
20
20
  import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, type Runtime } from '../core/constants.ts'
21
21
  import { readLaunchEnv } from '../storage/index.ts'
22
22
  import { canaryChannel, dismissCanary, ensureServerCanary, exitLogPath, signalCanaryClean } from './canary.ts'
23
+ import { hardenCmdLogDir, prepareCmdLogDir } from './cmdlog.ts'
23
24
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
24
25
  import { claudeAdapter } from './adapters/claude.ts'
25
26
  import { codexAdapter } from './adapters/codex.ts'
@@ -262,14 +263,31 @@ export const launch: LaunchFn = async (
262
263
  PEER_IDENTITY: identity,
263
264
  PATH: env.PATH ?? `${homedir()}/.bun/bin:${homedir()}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`,
264
265
  }
266
+ // (2.4) tmux command-log (killer-hunt socket-command layer, cmdlog.ts): WARM
267
+ // agent sessions start their server with `-v` so every client command —
268
+ // incl. a killer's `kill-server`/`kill-session`, invisible to signal
269
+ // forensics — lands in tmux-server-<pid>.log with client attribution
270
+ // (`message: client-<pid> command: …`). The starting client runs FROM the
271
+ // per-identity 0700 cmdlog dir (that is where tmux drops the logs; the
272
+ // pane's cwd is still `-c cwd`). Gated like the other observability layers
273
+ // on cfg.exitLogDir; skipped for always-on infra (rare relaunch = no
274
+ // rotation point, and not the dying class). Best-effort: a failed dir
275
+ // prepare just launches without -v.
276
+ const cmdLogDir = !cfg.alwaysOn && cfg.exitLogDir ? prepareCmdLogDir(cfg.exitLogDir, identity) : undefined
265
277
  const start = spawnSync(
266
278
  'tmux',
267
- ['-S', sock, 'new-session', '-d', '-s', identity, '-x', '220', '-y', '50', '-c', cwd, runtimeCmd],
268
- { env: childEnv as Record<string, string>, encoding: 'utf8' },
279
+ [
280
+ '-S', sock,
281
+ ...(cmdLogDir ? ['-v'] : []),
282
+ 'new-session', '-d', '-s', identity, '-x', '220', '-y', '50', '-c', cwd, runtimeCmd,
283
+ ],
284
+ { env: childEnv as Record<string, string>, encoding: 'utf8', ...(cmdLogDir ? { cwd: cmdLogDir } : {}) },
269
285
  )
270
286
  if (start.status !== 0) {
271
287
  return fail(identity, `tmux new-session failed: ${(start.stderr ?? '').trim() || 'exit ' + start.status}`)
272
288
  }
289
+ // The server/client logs exist once new-session returns — clamp them to 0600.
290
+ if (cmdLogDir) hardenCmdLogDir(cmdLogDir)
273
291
 
274
292
  // (2.5) Exit-cause observability: a `pane-died` hook that records WHY this
275
293
  // session's process dies (status/signal) at the moment of death into
@@ -26,6 +26,7 @@ import {
26
26
  } from '../core/constants.ts'
27
27
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
28
28
  import { err, ok, type Result } from '../core/errors.ts'
29
+ import { capCmdLogs } from '../launch/cmdlog.ts'
29
30
  import { resolveGlobalRoot } from '../storage/index.ts'
30
31
  import { readPeerProfile, resolveIdentity } from '../identity/index.ts'
31
32
  import {
@@ -1071,6 +1072,11 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1071
1072
  const env = deps.env ?? process.env
1072
1073
  const nowMs = deps.nowMs ?? Date.now()
1073
1074
  const verbose = superviseLogVerbose(env)
1075
+ // tmux command-log volume cap (killer-hunt socket-command layer, launch/cmdlog.ts):
1076
+ // a -v server log under a busy TUI grows ~7 KB/s, so each tick tail-keeps any log
1077
+ // over the cap. Best-effort by construction; the killer's command is the LAST
1078
+ // line of a dying log, so capping a LIVE peer's log never costs the death tail.
1079
+ capCmdLogs(cfg.eventLogDir)
1074
1080
  // Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
1075
1081
  // so a postmortem can answer "when & how did peer X's prior session end" even
1076
1082
  // after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd
@@ -487,6 +487,46 @@ function validateAttachments(value: readonly string[] = []): Result<string[]> {
487
487
  return ok(out)
488
488
  }
489
489
 
490
+ // ─── Telegram-domain sender policy (defect track 11.06, sanctioned by Артур) ─
491
+
492
+ /** A SENDER face in the telegram domain: a non-empty interfaces.telegram.bot (the
493
+ * bot machine-key) in the peer's registry passport. Deliberately bot-ONLY, not
494
+ * bot|user_id — the bridge picks the SENDING bot from the sender's bot key
495
+ * (owner fact, telegram-runtime сверка 11.06): a user_id grants a receive/operator
496
+ * identity but no ability to send, so a user_id-only sender passing the guard
497
+ * would still die at the bridge — a late asynchronous failure where the guard
498
+ * exists to give a synchronous one. The guard passes exactly what the bridge can
499
+ * deliver. */
500
+ export function hasTelegramPresence(record: PeerRecord): boolean {
501
+ const tg = record.interfaces?.telegram
502
+ if (!tg || typeof tg !== 'object' || Array.isArray(tg)) return false
503
+ const t = tg as Record<string, unknown>
504
+ return typeof t.bot === 'string' && t.bot.trim() !== ''
505
+ }
506
+
507
+ /** Sender policy for the telegram domain: a peer with NO declared telegram presence
508
+ * must not write into it — the recipient (a human in Telegram) would face a message
509
+ * from a peer that has no telegram face (identity confusion: who writes, by what
510
+ * right?). Bot channel = declared presence; no presence — no right to the domain.
511
+ * Enforced at the ROUTER, not the bridge: the refusal is synchronous and lands in
512
+ * the sender's tool result; a bridge-side check would fire AFTER the router's
513
+ * ok:true — a false-OK + silent loss, the class this contract forbids. Applied to
514
+ * the INTENDED channel (override / target default — before any wake side-effect,
515
+ * since a wake delivers the envelope as the boot first-message) AND to the RESOLVED
516
+ * channel at both delivery points (the only-live-session fallback can land on
517
+ * telegram without either). */
518
+ function telegramSenderGuard(
519
+ caller: ResolvedCaller,
520
+ targetRuntime: string,
521
+ targetPersonality: string,
522
+ ): Result<void> {
523
+ if (targetRuntime !== 'telegram') return ok(undefined)
524
+ if (hasTelegramPresence(caller.record)) return ok(undefined)
525
+ return err(
526
+ `telegram policy: sender "${caller.personality}" has no telegram face (no interfaces.telegram.bot in its passport) — message to "${targetPersonality}" NOT delivered; route via a telegram-faced peer (e.g. boris) or provision a bot for this sender`,
527
+ )
528
+ }
529
+
490
530
  /**
491
531
  * Route a send from a request-resolved caller to a target peer and deliver it.
492
532
  * hit (live) → build envelope from the CALLER → deliverViaTmux → {woke:false}
@@ -529,6 +569,12 @@ export async function routeSend(
529
569
  return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
530
570
  }
531
571
 
572
+ // Telegram sender policy — INTENDED channel (explicit override, or the target's
573
+ // default). Checked BEFORE any wake side-effect: a wake delivers the envelope as
574
+ // the boot first-message, past the resolved-target guards further down.
575
+ const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
576
+ if (!intendedGuard.ok) return intendedGuard
577
+
532
578
  // Built once — it is both the live-delivery payload and, on a miss, the wake
533
579
  // first-message (the woken session receives it as its boot task).
534
580
  const envelope = buildEnvelope({
@@ -554,6 +600,10 @@ export async function routeSend(
554
600
  const target = resolvePeerDeliveryTarget(personality, runtime, peer)
555
601
  if (target.ok) {
556
602
  if (target.value.address === caller.address) return err('cannot send to self')
603
+ // Telegram sender policy — RESOLVED channel (the only-live-session fallback can
604
+ // land on telegram without an override and without telegram being the default).
605
+ const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
606
+ if (!liveGuard.ok) return liveGuard
557
607
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
558
608
  const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
559
609
  if (!delivered.ok) return delivered
@@ -581,6 +631,11 @@ export async function routeSend(
581
631
  return err(`woke "${personality}" but the session is not live (verify-before-act): ${live.error.message}`)
582
632
  }
583
633
  if (live.value.address === caller.address) return err('cannot send to self')
634
+ // Telegram sender policy — post-wake RESOLVED channel (airtight: a wake should
635
+ // never bring up a telegram session — they are launchd-held — but the policy is
636
+ // enforced on every delivery point, not on an assumption about the wake).
637
+ const postWakeGuard = telegramSenderGuard(caller, live.value.runtime, personality)
638
+ if (!postWakeGuard.ok) return postWakeGuard
584
639
  // taskDelivered:false — the wake took the idempotent live-session fast path (a
585
640
  // CONCURRENT sender's boot won the wake.lock and delivered only ITS envelope as
586
641
  // the boot first-message). OUR envelope was not delivered by anything yet —
@@ -17,7 +17,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { spawnSync } from 'child_process'
20
- import { routeSend, type WakeFn } from './index.ts'
20
+ import { hasTelegramPresence, routeSend, type WakeFn } from './index.ts'
21
21
  import type { ResolvedCaller } from '../identity/index.ts'
22
22
  import type { PeerRecord } from '../registry/index.ts'
23
23
 
@@ -48,6 +48,10 @@ const callerRecord = {
48
48
  description: '',
49
49
  intelligence: 'artificial',
50
50
  cwd: '/tmp/boris',
51
+ // Telegram sender policy: the target of these suites is a telegram peer, so the
52
+ // caller must declare a telegram presence (the bot machine-key) — these suites
53
+ // double as the "bot-faced sender delivers as before" half of the policy criterion.
54
+ interfaces: { telegram: { bot: 'boris' } },
51
55
  } as unknown as PeerRecord
52
56
  const caller: ResolvedCaller = {
53
57
  personality: 'boris',
@@ -213,3 +217,89 @@ describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)'
213
217
  killTargetSession()
214
218
  })
215
219
  })
220
+
221
+ // ─── Telegram sender policy (defect track 11.06) ─────────────────────────────
222
+ // A sender with NO declared telegram presence (interfaces.telegram.bot|user_id)
223
+ // must get a SYNCHRONOUS refusal when the delivery channel is telegram — the
224
+ // incident class: the bridge silently dropped such envelopes while the router
225
+ // reported ok:true. The bot-faced "delivers as before" half of the criterion is
226
+ // carried by the suites above (their caller declares interfaces.telegram.bot).
227
+
228
+ const facelessCaller: ResolvedCaller = {
229
+ personality: 'ghost',
230
+ runtime: 'claude',
231
+ address: 'claude-ghost',
232
+ description: '',
233
+ intelligence: 'artificial',
234
+ cwd: '/tmp/ghost',
235
+ record: {
236
+ personality: 'ghost',
237
+ runtime: 'claude',
238
+ runtimes: ['claude'],
239
+ description: '',
240
+ intelligence: 'artificial',
241
+ cwd: '/tmp/ghost',
242
+ } as unknown as PeerRecord,
243
+ }
244
+
245
+ describe('telegram sender policy — hasTelegramPresence', () => {
246
+ const rec = (tg: unknown): PeerRecord =>
247
+ ({ personality: 'x', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/x', ...(tg !== undefined ? { interfaces: { telegram: tg } } : {}) }) as unknown as PeerRecord
248
+ test('bot machine-key (agent peers) → sender face present', () => {
249
+ expect(hasTelegramPresence(rec({ bot: 'boris' }))).toBe(true)
250
+ })
251
+ test('user_id alone (natural peers — arthur) → NO sender face: the bridge sends FROM the bot key; user_id is a receive/operator identity (owner fact, bot-only predicate)', () => {
252
+ expect(hasTelegramPresence(rec({ user_id: '409502965' }))).toBe(false)
253
+ expect(hasTelegramPresence(rec({ user_id: 409502965 }))).toBe(false)
254
+ })
255
+ test('no interfaces / no telegram / empty bot → absent', () => {
256
+ expect(hasTelegramPresence(rec(undefined))).toBe(false)
257
+ expect(hasTelegramPresence(rec({}))).toBe(false)
258
+ expect(hasTelegramPresence(rec({ bot: '' }))).toBe(false)
259
+ expect(hasTelegramPresence(rec({ bot: ' ' }))).toBe(false)
260
+ expect(hasTelegramPresence(rec({ aliases: { '/alias-new': 'x' } }))).toBe(false)
261
+ })
262
+ })
263
+
264
+ describe('telegram sender policy — routeSend refusal', () => {
265
+ test('INTENDED channel (target default = telegram, peer offline): refused BEFORE the wake — wake never invoked', async () => {
266
+ let wakeCalled = false
267
+ const wake: WakeFn = async () => {
268
+ wakeCalled = true
269
+ return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
270
+ }
271
+ const r = await routeSend(facelessCaller, { personality: TARGET, message: 'm' }, { wake })
272
+ expect(r.ok).toBe(false)
273
+ if (!r.ok) {
274
+ expect(r.error.message).toContain('telegram policy')
275
+ expect(r.error.message).toContain('"ghost"')
276
+ expect(r.error.message).toContain('NOT delivered')
277
+ }
278
+ expect(wakeCalled).toBe(false)
279
+ })
280
+
281
+ test('explicit runtime=telegram override: refused with the policy line, not "offline"', async () => {
282
+ const r = await routeSend(facelessCaller, { personality: TARGET, runtime: 'telegram', message: 'm' })
283
+ expect(r.ok).toBe(false)
284
+ if (!r.ok) expect(r.error.message).toContain('telegram policy')
285
+ })
286
+ })
287
+
288
+ describe.if(tmuxAvailable)('telegram sender policy — live telegram session', () => {
289
+ test('faceless sender + LIVE telegram target → refused, envelope NEVER reaches the pane', async () => {
290
+ startTargetSession()
291
+ const r = await routeSend(facelessCaller, { personality: TARGET, message: 'POLICY-MUST-BLOCK' })
292
+ expect(r.ok).toBe(false)
293
+ if (!r.ok) expect(r.error.message).toContain('telegram policy')
294
+ expect(capturePane()).not.toContain('POLICY-MUST-BLOCK')
295
+ killTargetSession()
296
+ })
297
+
298
+ test('bot-faced sender (boris) + LIVE telegram target → delivered as before', async () => {
299
+ startTargetSession()
300
+ const r = await routeSend(caller, { personality: TARGET, message: 'BOT-FACED-OK' })
301
+ expect(r.ok).toBe(true)
302
+ expect(capturePane()).toContain('BOT-FACED-OK')
303
+ killTargetSession()
304
+ })
305
+ })