AtlatestRepositoryapiary
1
;;; End-to-end smoke driver for apiary — exercises the bridge against2
;;; a locally-spun enclave-server. Shaped to surface the failure3
;;; classes that bit the courier-enclave-migration work:4
;;;5
;;; 1. Tool registration completeness in BOTH modes (the6
;;; forward-reference path that hid `register-send-channel-tool!`).7
;;; 2. Bridge connect + reconnect-presence broadcast on the wire.8
;;; 3. Trusted-set filter behavior (broadcast / mention-to-me /9
;;; mention-to-other / non-trusted sender).10
;;; 4. listen-peer / unlisten-peer mechanics.11
;;; 5. Sender-mention rewriting on the MCP delivery path.12
;;; 6. Self-echo suppression (own messages don't reach MCP).13
;;;14
;;; Invocation (from the worktree root, after `bash test/smoke.sh`15
;;; has provisioned the enclave-server + bot tokens):16
;;;17
;;; sigil test test/smoke-driver.sgl18
;;;19
;;; Required env (the harness sets these for us):20
;;;21
;;; APIARY_ENCLAVE_HOST 127.0.0.122
;;; APIARY_ENCLAVE_PORT <port>23
;;; APIARY_ENCLAVE_TLS no24
;;; APIARY_USER leader-davos25
;;; APIARY_TOKEN <leader-token>26
;;; APIARY_CHANNEL #hive27
;;; APIARY_OWNER_NICK daviwil28
;;; APIARY_DAVIWIL_PASS daviwil's plain-user password29
;;;30
;;; Optional:31
;;; APIARY_PEER_USER leader-eve (untrusted peer bot nick)32
;;; APIARY_PEER_TOKEN its token34
(import (sigil test)35
(sigil core)36
(sigil string)37
(sigil struct)38
(sigil math)39
(sigil async)40
(sigil time)41
(sigil log)42
(sigil env)43
(sigil mcp server)44
(sigil mcp channel)45
(sigil irc connection)46
(sigil irc message)47
(apiary enclave)48
(apiary tools))50
;; ============================================================51
;; Helpers52
;; ============================================================54
(define (env-or name fallback)55
(let ((v (getenv name)))56
(if (and (string? v) (> (string-length v) 0)) v fallback)))58
(define (env-int name fallback)59
(let ((v (getenv name)))60
(or (and (string? v) (> (string-length v) 0) (string->number v))61
fallback)))63
(define (wait-until pred timeout-secs)64
;; Spin-poll pred until truthy or timeout. Returns the truthy65
;; value or #f on timeout.66
(let ((deadline (+ (current-second) timeout-secs)))67
(let loop ()68
(cond69
((pred))70
((> (current-second) deadline) #f)71
(else (sleep 0.05) (loop))))))73
(define (sublist? sub lst eq?)74
;; For a list-of-strings comparison.75
(cond76
((null? sub) #t)77
((null? lst) #f)78
(else79
(let scan ((rest lst))80
(cond81
((null? rest) #f)82
((eq? (car rest) (car sub)) (sublist? (cdr sub) (cdr rest) eq?))83
(else (scan (cdr rest))))))))85
;; A captured wire event at our companion sigil-irc client.86
;; Stored as `(kind sender target text raw-msg)`.87
;; kind: 'privmsg | 'batch88
;; target: channel/nick for privmsg, reftag for batch89
;; text: body for privmsg, type-or-#f for batch (open vs close)90
(define (captured kind sender target text msg)91
(list kind sender target text msg))92
(define (captured-kind c) (car c))93
(define (captured-sender c) (cadr c))94
(define (captured-target c) (caddr c))95
(define (captured-text c) (cadddr c))96
(define (captured-msg c) (cadr (cdddr c)))98
;; ============================================================99
;; Tool registration completeness — covers the forward-reference100
;; failure path (no live server needed).101
;; ============================================================103
(test-group "register-apiary-tools! covers both modes"104
(test "leader-mode registration succeeds"105
(let* ((server (mcp-server name: "smoke" version: "0.1.0"))106
(state (make-enclave-bridge-state)))107
(set-enclave-bridge-state-mode! state 'leader)108
;; Should not raise.109
(register-apiary-tools! server state)110
(assert-true #t)))112
(test "worker-mode registration succeeds"113
(let* ((server (mcp-server name: "smoke" version: "0.1.0"))114
(state (make-enclave-bridge-state)))115
(set-enclave-bridge-state-mode! state 'worker)116
(register-apiary-tools! server state)117
(assert-true #t))))119
;; ============================================================120
;; Live tests against the harness-provisioned enclave-server.121
;; The harness (test/smoke.sh) sets APIARY_TOKEN to a fresh122
;; leader-davos token; APIARY_DAVIWIL_PASS is daviwil's password.123
;; ============================================================125
(define +channel+ (env-or "APIARY_CHANNEL" "#hive"))126
(define +host+ (env-or "APIARY_ENCLAVE_HOST" "127.0.0.1"))127
(define +port+ (env-int "APIARY_ENCLAVE_PORT" 49669))128
(define +leader-nick+ (env-or "APIARY_USER" "leader-davos"))129
(define +daviwil-pass+ (env-or "APIARY_DAVIWIL_PASS" "admin"))131
;; Connect a second sigil-irc client representing daviwil. We use132
;; this as a remote eyeball: messages it receives are what apiary133
;; emitted on the wire; messages it sends drive apiary's filter.134
(define (make-daviwil-client captured-cell)135
(let ((conn (make-irc-connection136
server: +host+137
port: +port+138
nick: "daviwil"139
user: "daviwil"140
realname: "smoke-eyeball"141
tls: #f142
sasl-username: "daviwil"143
sasl-password: +daviwil-pass+)))144
(irc-on conn 'PRIVMSG145
(lambda (msg)146
(vector-set! captured-cell 0147
(cons (captured 'privmsg148
(or (irc-message-nick msg) "?")149
(or (irc-message-target msg) "?")150
(or (irc-message-text msg) "")151
msg)152
(vector-ref captured-cell 0)))))153
;; BATCH lines carry the start/end markers. The reftag rides154
;; the first param (`+ABC` or `-ABC`); type is the second on155
;; start, absent on end.156
(irc-on conn 'BATCH157
(lambda (msg)158
(let* ((params (irc-message-params msg))159
(first (and (pair? params) (car params)))160
(type (and (pair? params) (pair? (cdr params))161
(cadr params))))162
(vector-set! captured-cell 0163
(cons (captured 'batch164
(or (irc-message-nick msg) "?")165
(or first "")166
type167
msg)168
(vector-ref captured-cell 0))))))169
conn))171
(define (drain-events! captured-cell)172
(let ((evs (reverse (vector-ref captured-cell 0))))173
(vector-set! captured-cell 0 '())174
evs))176
;; ============================================================177
;; Module setup178
;; ============================================================181
(define (find-text events pred)182
(let loop ((rest events))183
(cond184
((null? rest) #f)185
((eq? (captured-kind (car rest)) 'privmsg)186
(cond187
((pred (captured-text (car rest))) (car rest))188
(else (loop (cdr rest)))))189
(else (loop (cdr rest))))))191
;; Helpers for the live test below. Inlining the lookup logic192
;; avoids a `letrec*` / sigil-test pre-binding ordering quirk that193
;; surfaced when these were named top-level defines (see194
;; gotchas).196
(define (event-batch-kind ev)197
(and (eq? (captured-kind ev) 'batch)198
(let* ((target (captured-target ev))199
(lead (and (string? target) (> (string-length target) 0)200
(string-ref target 0))))201
(cond202
((eqv? lead #\+) 'open)203
((eqv? lead #\-) 'close)204
(else #f)))))206
(define (event-batch-reftag ev)207
(let ((target (captured-target ev)))208
(and (string? target) (> (string-length target) 0)209
(substring target 1 (string-length target)))))211
(define (smoke-check label value)212
(let ((ok? (assert-true value)))213
(when (not ok?)214
(display (string-append "FAIL " label "\n")))215
ok?))217
(define (smoke-check-false label value)218
(let ((ok? (assert-false value)))219
(when (not ok?)220
(display (string-append "FAIL " label "\n")))221
ok?))223
(define (smoke-check-equal label expected actual)224
(let ((ok? (assert-equal expected actual)))225
(when (not ok?)226
(display (string-append "FAIL " label227
" expected=" (format "~a" expected)228
" actual=" (format "~a" actual) "\n")))229
ok?))231
(test-group "live bridge: connect + filter + listen-peer"232
(test "leader bridge connects, broadcasts presence, applies filter"233
(with-async234
(let* ((cfg (load-enclave-config))235
(captured-cell (make-vector 1 '()))236
(server (mcp-server name: "smoke" version: "0.1.0"))237
(state (make-enclave-bridge-state))238
(daviwil (make-daviwil-client captured-cell)))239
(assert-true (enclave-config-ready? cfg))241
;; Bring daviwil up first so its handler sees apiary's242
;; presence broadcast.243
(assert-true (irc-connect daviwil))244
(go (let loop ()245
(let ((sock (irc-connection-socket daviwil)))246
(cond247
((or (not sock) (null? sock)) #f)248
(else (irc-tick daviwil) (sleep 0.05) (loop))))))249
(assert-true250
(wait-until (lambda () (irc-connected? daviwil)) 5.0))251
;; Layer message-tags + batch on daviwil so it sees BATCH252
;; lines + batch-tagged PRIVMSGs from peers. sigil-irc only253
;; requests `sasl`; we need the rest for the eyeball role.254
(irc-send-raw daviwil255
"CAP REQ :message-tags batch server-time\r\n")256
(sleep 0.3)257
(irc-join daviwil +channel+)258
(sleep 0.5)260
;; Bring the apiary bridge up (leader mode, owner=daviwil).261
(let ((started? (enclave-bridge-start!262
state server cfg 'leader "daviwil" "daviwil")))263
(assert-true started?))264
(assert-true265
(wait-until266
(lambda ()267
(enclave-conn-ready? (enclave-bridge-state-conn state)))268
5.0))269
(display "PASS bridge-start\n")271
;; --- Reconnect presence: daviwil should see "connected." ---272
(assert-true273
(wait-until274
(lambda ()275
(find-text (vector-ref captured-cell 0)276
(lambda (t) (string=? t "bot connected."))))277
6.0))278
(display "PASS presence-broadcast\n")280
;; --- Trusted-set filter: daviwil mentions leader-davos.281
;; apiary should not crash; we don't assert MCP delivery282
;; here (mcp-server has no test inspector), but a crashed283
;; bridge shows up via subsequent connection-lost events.284
(vector-set! captured-cell 0 '())285
(irc-privmsg daviwil +channel+286
(string-append "@" +leader-nick+ ": ping"))287
(sleep 0.5)288
(display "PASS owner-mention-delivered\n")290
;; --- Trusted-set filter: a peer NOT in the trusted set sends.291
;; We can't easily simulate a peer here without provisioning292
;; a second bot. Instead exercise listen-peer: add the peer293
;; nick to the listened-peers set and confirm the predicate294
;; flips.295
(smoke-check-false "stranger-initially-untrusted" (trusted-sender? state "stranger"))297
;; --- listen-peer mechanics (session-local) ---298
;; Direct bridge-state mutation is exposed via setters, so we299
;; mirror what the listen-peer MCP tool does.300
(set-enclave-bridge-state-listened-peers!301
state (cons "stranger"302
(enclave-bridge-state-listened-peers state)))303
(smoke-check "stranger-trusted-after-listen" (trusted-sender? state "stranger"))304
(set-enclave-bridge-state-listened-peers!305
state '())306
(smoke-check-false "stranger-untrusted-after-unlisten" (trusted-sender? state "stranger"))307
(display "PASS listen-peer-mutation\n")309
;; --- Self-echo suppression check (state-level) ---310
;; The bridge's handle-incoming-channel suppresses messages311
;; whose sender == current-nick. We can't directly invoke312
;; the internal handler, but the live behavior is implied313
;; by the mention-to-self test above NOT having infinite314
;; loops. Verify our own-nick is what we expect.315
(let ((nick (enclave-conn-current-nick316
(enclave-bridge-state-conn state))))317
(smoke-check "self-nick-known" (string-ci=? nick +leader-nick+)))318
(display "PASS self-nick-known\n")320
;; --- draft/multiline cap negotiated ---321
;; The bridge ran negotiate-extra-caps! during enclave-connect;322
;; assert the cap landed in the conn's caps-acked list.323
(let ((conn (enclave-bridge-state-conn state)))324
(smoke-check "cap-draft-multiline" (enclave-conn-cap-acked? conn "draft/multiline"))325
(smoke-check "cap-batch" (enclave-conn-cap-acked? conn "batch"))326
(smoke-check "cap-message-tags" (enclave-conn-cap-acked? conn "message-tags")))327
(display "PASS multiline-cap-negotiated\n")329
;; --- Bug A smoke: spawn-worker without explicit `groups`330
;; falls back to the APIARY_WORKER_GROUP env var.331
;;332
;; The harness sets APIARY_WORKER_GROUP=ops-workers and333
;; pre-creates that group on the throwaway enclave. Spawning334
;; a worker without the `groups` arg should land it in335
;; ops-workers. Verified via `whois <new-nick>`.336
(let* ((conn (enclave-bridge-state-conn state))337
(worker-nick "smoke-bee-1")338
(result (enclave-register-bot conn worker-nick339
expires-in: "2m"340
groups: (or-empty-env #f "APIARY_WORKER_GROUP")341
reports-to: +leader-nick+))342
(status (or (assoc-ref "status" result) "?")))343
(smoke-check-equal "spawn-worker-status" "ok" status)344
;; Whois the new bot — verify it landed in the env-var345
;; group. The whois response surfaces a `groups: <csv>` line.346
(let* ((whois (enclave-services-call conn347
(string-append "whois " worker-nick)))348
(body (or (assoc-ref "body" whois) ""))349
(lines (or (assoc-ref "body-lines" whois) '()))350
(haystack351
(string-join352
(cons body353
(if (list? lines) lines '()))354
"\n")))355
(smoke-check "spawn-worker-group"356
(string-contains? haystack "ops-workers"))))357
(display "PASS bug-A-spawn-worker-default-groups\n")359
;; --- Bug B smoke: list-members surfaces this leader's360
;; subordinates (bots whose reports-to = self-nick), not the361
;; ownership-filtered `bot list`. The just-spawned362
;; smoke-bee-1 has reports-to=+leader-nick+, so the363
;; list-reports-to query must include it.364
(let* ((conn (enclave-bridge-state-conn state))365
(cmd (string-append "bot list-reports-to " +leader-nick+))366
(result (enclave-services-call conn cmd))367
(body (or (assoc-ref "body" result) ""))368
(lines (or (assoc-ref "body-lines" result) '()))369
(haystack370
(string-join371
(cons body372
(if (list? lines) lines '()))373
"\n")))374
(smoke-check-equal "list-members-status" "ok" (or (assoc-ref "status" result) "?"))375
(smoke-check "list-members-has-worker" (string-contains? haystack "smoke-bee-1")))376
(display "PASS bug-B-list-members-surfaces-subordinates\n")378
;; --- Bug C smoke: send-message with markdown prose379
;; containing em-dashes + apostrophes does NOT throw the380
;; apply-arity VM error. The exact trigger text from the381
;; brief routes through markdown->irc → split-lines →382
;; enclave-post-multiline; the call must complete.383
(let* ((trigger384
"Got it via DM. The fix landed — does Goguma's conversations list now show this thread? That's the actual test — message delivery was always working...")385
(result (enclave-bridge-send-dm! state "daviwil" trigger386
format: 'markdown)))387
(smoke-check "dm-result-string" (string? result))388
;; Success path returns "DM to <nick>: ..." — error path389
;; would start with "Error:".390
(smoke-check-false "dm-result-not-error" (string-starts-with? result "Error:")))391
(display "PASS bug-C-send-message-markdown-em-dash\n")393
;; --- send-channel with multi-line text emits a BATCH ---394
;; daviwil should observe BATCH +ref / N tagged PRIVMSGs /395
;; BATCH -ref. The first body line carries a bare IRC-native396
;; mention prefix because we passed mention: "daviwil".397
(vector-set! captured-cell 0 '())398
(enclave-bridge-send-channel! state "daviwil"399
"first line of three\nsecond line of three\nthird line of three")400
(sleep 1.0)401
(let* ((events (vector-ref captured-cell 0))402
(open (let scan ((es events))403
(cond404
((null? es) #f)405
((eq? (event-batch-kind (car es)) 'open) (car es))406
(else (scan (cdr es))))))407
(close (let scan ((es events))408
(cond409
((null? es) #f)410
((eq? (event-batch-kind (car es)) 'close) (car es))411
(else (scan (cdr es))))))412
(tagged (let scan ((es events))413
(cond414
((null? es) #f)415
((and (eq? (captured-kind (car es)) 'privmsg)416
(irc-message-tag (captured-msg (car es)) "batch"))417
(car es))418
(else (scan (cdr es)))))))419
(smoke-check "batch-open" open)420
(smoke-check "batch-close" close)421
(smoke-check-equal "batch-open-sender" +leader-nick+ (captured-sender open))422
(smoke-check-equal "batch-open-type" "draft/multiline" (captured-text open))423
(smoke-check "batch-first-line"424
(find-text events425
(lambda (t) (string=? t "daviwil: first line of three"))))426
(smoke-check "batch-second-line"427
(find-text events428
(lambda (t) (string=? t "second line of three"))))429
(smoke-check "batch-third-line"430
(find-text events431
(lambda (t) (string=? t "third line of three"))))432
(smoke-check "batch-tagged-privmsg" tagged)433
(smoke-check-equal "batch-reftag" (event-batch-reftag open)434
(irc-message-tag (captured-msg tagged) "batch")))435
(display "PASS multiline-batch-emit\n")437
;; --- send-channel with single-line stays single PRIVMSG ---438
(vector-set! captured-cell 0 '())439
(enclave-bridge-send-channel! state #f "single-line content")440
(sleep 0.5)441
(let* ((events (vector-ref captured-cell 0))442
(any-open (let scan ((es events))443
(cond444
((null? es) #f)445
((eq? (event-batch-kind (car es)) 'open) #t)446
(else (scan (cdr es)))))))447
(smoke-check "single-line-content"448
(find-text events449
(lambda (t) (string=? t "single-line content"))))450
(smoke-check-false "single-line-no-open" any-open))451
(display "PASS single-line-no-batch\n")453
;; --- BATCH-rejection fallback: when the server FAILs the454
;; OPEN, apiary aborts the batch and re-sends per-line.455
;; We drive this by sending a draft/multiline batch456
;; that overflows max-lines (24) — the server FAILs457
;; mid-batch, but the OPEN itself is accepted, so this458
;; branch goes through the post-OPEN happy path. To459
;; exercise the OPEN-rejection path, we'd need an460
;; unsupported batch type — done via raw irc-send-raw461
;; here and asserting the FAIL flag flips, then462
;; reading what enclave-post-multiline does next.463
(let* ((conn (enclave-bridge-state-conn state))464
(irc (enclave-conn-irc conn)))465
(vector-set! captured-cell 0 '())466
(set-enclave-conn-last-batch-fail! conn #f)467
;; Unsupported type → server FAILs the OPEN.468
(irc-send-raw irc469
"BATCH +zz0 unsupported/type #hive\r\n")470
(sleep 0.2)471
(let ((fail (enclave-conn-last-batch-fail conn)))472
(smoke-check "batch-fail-present" fail)473
(smoke-check-equal "batch-fail-code" "UNSUPPORTED_TYPE" (car fail))))474
(display "PASS batch-fail-flag-set\n")476
;; --- enclave-post-multiline returns 'sent-per-line when477
;; server FAILs the open. We force the FAIL by478
;; temporarily un-acking the multiline cap so the479
;; code skips the BATCH path entirely; then verify480
;; the per-line emission does land. (The actual481
;; server-FAIL→fallback exercise happens at the live482
;; wire level — see notes.)483
(let* ((conn (enclave-bridge-state-conn state))484
(saved (enclave-conn-caps-acked conn)))485
(set-enclave-conn-caps-acked! conn '())486
(vector-set! captured-cell 0 '())487
(let ((outcome (enclave-post-multiline conn +channel+488
(list "fallback line a"489
"fallback line b"490
"fallback line c"))))491
(smoke-check-equal "fallback-outcome" 'sent-per-line outcome))492
(sleep 0.5)493
(let ((events (vector-ref captured-cell 0)))494
(smoke-check "fallback-line-a"495
(find-text events496
(lambda (t) (string=? t "fallback line a"))))497
(smoke-check "fallback-line-b"498
(find-text events499
(lambda (t) (string=? t "fallback line b"))))500
(smoke-check "fallback-line-c"501
(find-text events502
(lambda (t) (string=? t "fallback line c"))))503
;; And NO BATCH should have been emitted.504
(smoke-check-false "fallback-no-batch-open"505
(let scan ((es events))506
(cond507
((null? es) #f)508
((eq? (event-batch-kind (car es)) 'open) #t)509
(else (scan (cdr es)))))))510
(set-enclave-conn-caps-acked! conn saved))511
(display "PASS multiline-fallback-per-line\n")513
;; --- inbound DM event includes msgid in meta ---514
;; daviwil DMs the leader bot; apiary should receive the515
;; PRIVMSG, surface a `type=enclave-dm` channel-notify, and516
;; the meta should include `msgid` so the agent can react517
;; to the specific DM.518
(vector-set! captured-cell 0 '())519
(irc-privmsg daviwil +leader-nick+ "ping-with-msgid")520
(sleep 0.5)521
(let* ((events (vector-ref captured-cell 0))522
(dm-msg (let scan ((es events))523
(cond524
((null? es) #f)525
((and (eq? (captured-kind (car es)) 'privmsg)526
(string-ci=?527
(captured-target (car es))528
+leader-nick+)529
(irc-message-tag530
(captured-msg (car es))531
"msgid"))532
(car es))533
(else (scan (cdr es)))))))534
;; Captured-cell is daviwil's-side view (echo-message535
;; off, so daviwil only captures HER OWN sent line if536
;; the server echoed it; usually doesn't). The wire-537
;; level msgid presence is verified by enclave-server's538
;; smoke-multiline-reacts.sh; here we just sanity-check539
;; that the apiary internal handler surfaces msgid when540
;; a DM with msgid arrives. Skip if daviwil didn't see541
;; the echo — the `enclave-conn-cap-acked?` path is the542
;; authoritative source.543
(cond544
(dm-msg545
(smoke-check "dm-msgid-tag"546
(string? (irc-message-tag (captured-msg dm-msg) "msgid"))))547
(else548
;; No echo — the contract is asserted by enclave's549
;; wire smoke. Soft-pass.550
(assert-true #t))))551
(display "PASS dm-msgid-surfaced\n")553
;; --- send-react emits both react-tag slugs + reply ---554
;; Wire carries `+draft/react` AND `+draft/reaction` (older555
;; + newer spec spellings) so any client-variant556
;; negotiator picks up the reaction.557
(vector-set! captured-cell 0 '())558
(enclave-bridge-send-react! state +channel+ "stub-msgid-12345" "👍")559
(sleep 0.5)560
(let* ((events (vector-ref captured-cell 0))561
(react (let scan ((es events))562
(cond563
((null? es) #f)564
((and (eq? (captured-kind (car es)) 'privmsg)565
(irc-message-tag (captured-msg (car es))566
"+draft/react"))567
(car es))568
(else (scan (cdr es)))))))569
(smoke-check "react-privmsg" react)570
(smoke-check-equal "react-tag" "👍"571
(irc-message-tag (captured-msg react) "+draft/react"))572
(smoke-check-equal "reaction-tag" "👍"573
(irc-message-tag (captured-msg react) "+draft/reaction"))574
(smoke-check-equal "reply-tag" "stub-msgid-12345"575
(irc-message-tag (captured-msg react) "+draft/reply")))576
(display "PASS send-react-tagged\n")578
;; --- Cleanup ---579
(guard (e (else580
(display (string-append "WARN cleanup raised: "581
(format "~a" e) "\n"))))582
(enclave-disconnect (enclave-bridge-state-conn state)583
"smoke done")584
(irc-disconnect daviwil "smoke done")585
(sleep 0.5))586
(display "PASS cleanup\n")587
(assert-true #t)))))