fix(tmux): explicit transport-only dispatch and safe remote quoting (#1496)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# A9 — Jason's owner acceptance
|
||||
|
||||
Jason explicitly answered `Q1: accept` in the outstanding-needs interview, then confirmed the commit-and-push wave with `good, go`.
|
||||
|
||||
Accepted scope: the independently approved r6 offline synthetic scope/permission inspector. This records Jason's acceptance, not Darkwing substituting for the owner. Original demo receipt remains unchanged historical evidence of its then-pending acceptance.
|
||||
|
||||
Reviewed r6 manifest SHA-256: a4a4493000aff5905337a643886ca36e7c5377d52deed77b8aeab7174ca73dcf. Filbert verdict: 2026-09-07_foundation-inspector-code-verdict-r6.md. Inspector integration commit: 8ebddd6f.
|
||||
|
||||
Qualifications preserved: synthetic offline previews only; no live permission grants, deployment, runtime enforcement or security certification. Ordering comparison means structural equality; native-parser warm-run anomalies remain unresolved qualifications. Archify C1 stays held. ACT-1 behavioral tests remain deferred.
|
||||
|
||||
Subsequent wave verification: config 24/0, auth 15/0, conductor 17/0, foundation shell selftests 43/0, extension package 18/0, task 90/0 and release 14/0. Jason explicitly lifted the earlier task/release deferral for this wave. Task suite required MOSAIC_AGENT_NAME unset to avoid inheriting the host-dev seat identity; original 88/2 and subsequent 89/1 failures are not erased by the green run. The first mock-gate failure did not recur; its root cause was not established. No deployment acceptance is inferred.
|
||||
|
||||
These are coordinator observations from this conversation, not fresh independent suite reruns. Tmux review/live verification remain separate incomplete gates; no push claimed here.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Issue #53 inclusion — preparation, not closure evidence yet
|
||||
|
||||
Source: https://git.mosaicstack.dev/mosaicstack/stack-v2/issues/53
|
||||
Title: Plan agent/project/workspace sessions and execution audit. Current API response: open, HTTP 200. Client exited 1 despite HTTP 200; parsed response supplied the issue identity/body. Do not count client exit as a clean success or retry the read blindly.
|
||||
|
||||
Issue is planning-only: reusable agents, project membership/RBAC, workspace membership/state, Resume/Fresh sessions and execution audit. Acceptance requires separated agreed requirements/proposals/questions, ownership/coverage boundaries, later independently reviewed mapping/gap-analysis gates, and owner phase review. Implemented runtime enforcement is NOT this issue's acceptance scope.
|
||||
|
||||
Candidate local mapping for detailed verification:
|
||||
- Requirements and owner decisions: docs/plans/2026-09-06_agent-project-workspace-foundation.md.
|
||||
- Proposed contract and coverage boundaries: docs/plans/2026-09-06_foundation-phase2-contract.md and docs/plans/foundation-v1-candidate/.
|
||||
- Later map/gap gates: docs/plans/2026-09-06_foundation-map-handoff.md and docs/plans/2026-09-06_foundation-technical-map.md.
|
||||
- Phase acceptance history: docs/plans/CURRENT.md and append-only BUILD-LOG.md / docs/SESSIONS.md.
|
||||
|
||||
Local acceptance mapping checked:
|
||||
- Agreed requirements: foundation plan section 2, line 31 onward; proposed record types explicitly separated in phase-2 contract section 2 (line 95 onward); open D1–D16 reconciliation in foundation-v1-candidate/REVIEW.md section at line 73.
|
||||
- Ownership/limits: phase-2 contract section 3 (line 132 onward) catalogues record ownership; REVIEW.md D2/D9/D11/D13 explicitly retain loader/context, audit-integrity, command-coverage and privacy enforcement gaps rather than claiming runtime guarantees.
|
||||
- Later gates: foundation plan section 8, lines 226–247, explicitly requires Archify independent review and Jason visual acceptance, a distinct non-author gap analyst and a different report reviewer.
|
||||
- Owner phase review: foundation plan section 8 and phase-2 contract lines 3–4 record phase stops and Jason's explicit `accept phase 2` checkpoint.
|
||||
|
||||
Tracked Git blob identities verified locally:
|
||||
- foundation plan: `466717dc02db0d7c3c3efea98caee4a59ecf5cb6`.
|
||||
- phase-2 contract: `6fb7dca9874c9084a27e25adb4429a24c2b759e1`.
|
||||
- map handoff: `0efc06adaa6d7a041db3c5718d3ad72956ee758e`.
|
||||
- technical map: `6859884d38e3850434b80f7225eccba83a3a0db3`.
|
||||
|
||||
These identities establish tracked local artifacts, not publication. Pushed-ref verification remains pending. A9 is a later inspector acceptance, not a substitute for #53 planning acceptance. Final record must cite exact pushed commit, verified remote ref and acceptance-specific passages. Jason will close the original issue after verification; Darkwing must not close or renumber it.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Confirmation contract blocker after independent R3 rejection
|
||||
|
||||
Filbert's 2026-09-07_tmux-r3-verdict.md is NOT APPROVED. Stable export admission and diagnostics passed independently. Six independent real scratch-pane counterexamples returned delivered while application state retained the complete draft and accepted zero messages. Baseline/new-message visibility does not bind a lookalike region to the actual editor. Darkwing accepts F1 as blocking; no further layout-only tweak is represented as a solution.
|
||||
|
||||
Subsequent working revision's finer shape diagnostics passed 21/0 under both C and UTF-8; all frozen r3 hashes remain unchanged. Those diagnostics do not address F1. No final approval or push readiness.
|
||||
|
||||
## Required owner contract decision
|
||||
|
||||
A. Trusted runtime receipt: design a message-ID-bound acceptance/queue receipt from the actual recipient runtime. This entails runtime integration beyond the current tmux screen parser, with separately bounded authority and independent review. Recommend this if confirmed application acceptance is required.
|
||||
|
||||
B. Explicit transport-only contract: report successful paste/key dispatch as transport success, and report application acceptance as unknown unless a trusted receipt exists. Never call the hidden-draft counterexamples delivered or acknowledged. This changes caller-facing success semantics and requires Jason's explicit agreement and independent contract review; it is not silently adopted.
|
||||
|
||||
No export mutation, blind resend, private-pane investigation, live runtime change, push or issue closure follows from this record. The authorized combined wave is blocked at its tmux gate pending this decision. A9 acceptance is recorded; #53 local planning mapping exists, but remote inclusion verification awaits push. Registry review remains the authorized post-wave next action, not active implementation.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Tmux confirmation — independent review request
|
||||
|
||||
Owner authorization: Jason approved full tmux fix, verification, independent review, commit/push of refactor and 17 milestone tags. Darkwing authors; Filbert requested as independent reviewer. No deployment or trunk merge authorized.
|
||||
|
||||
Candidate: four working-tree files pinned in adjacent `2026-09-07_tmux-confirmation-review.sha256`. Review only; do not modify these files. Write findings/verdict to `docs/plans/reviews/2026-09-07_tmux-confirmation-verdict.md`. No independent approval is currently claimed.
|
||||
|
||||
## Verified
|
||||
|
||||
- Ungrouped UTF-8 rule repetition fails under LC_ALL=C; grouped literals fix the deterministic case.
|
||||
- Verdict fixtures pass 12/0 under LC_ALL=C, including transport failures and historical prompt/banner negatives. Log `/tmp/tmux-history-controls.log`.
|
||||
- Failed paste/load/submission keys refuse; unlocatable observations do not send blind extra Enters.
|
||||
|
||||
## Confirmation contract and unresolved audit findings
|
||||
|
||||
Exit 0 currently means the parser located an input region clear of the message tail after successful transport. It is NOT recipient acknowledgement or proof of model processing. A queued banner alone now remains unconfirmed, rather than reporting queued success. Documentation and dead queued-success branches still need reconciliation before final approval.
|
||||
|
||||
Rule-pair audit remains OPEN: the last two horizontal rules can come from transcript output. There is no proven current-editor boundary or message-specific correlation. Historical rules, wrapped/multiline drafts, hidden editors and unrelated queued banners require hostile controls. Review this limitation explicitly; do not approve solely on the passing fixtures.
|
||||
|
||||
Earlier statements identifying a live trailing-em-dash shape and redraw timing as established root causes were unsupported. The em-dash fixture is synthetic; ten-second patience is a chosen bound, not measured proof. Some test comments still overstate these claims and need correction.
|
||||
|
||||
Latest live ms-test send returned exit 2; its message subsequently appeared in transcript. No live success is claimed for this candidate. No blind resend authorized by an uncertain receipt.
|
||||
|
||||
Please independently inspect exact hashes, reproduce tests as appropriate on isolated scratch sockets only (no private-pane inspection or live-seat mutation), and return blocking findings or a qualified verdict. Flag any false-positive confirmation, safety regression, inaccurate evidence, or coverage gap. Candidate is not ready to commit/push until findings are resolved and the final revision independently approved.
|
||||
@@ -0,0 +1,4 @@
|
||||
37d11216558ff20935e58675691ae1c2c1e7722b69b955ac97b3131dd588259f tools/tmux/agent-send.sh
|
||||
1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda tools/tmux/agent-send.test.sh
|
||||
9941146d88b2d27372815d4150b7f2ad6874f88d556eb67b70417228e3c9b0e2 tools/tmux/send-message.sh
|
||||
ccf8f2c1758a06ad0e9600c78efa487d002afc5c39e0fc22ea834852e84cad3f tools/tmux/test-send-message-verdict.sh
|
||||
@@ -0,0 +1,116 @@
|
||||
# Tmux confirmation — independent verdict: NOT APPROVED / BLOCKED
|
||||
|
||||
Reviewer: filbert. Author/coordinator: darkwing. Date: 2026-09-07.
|
||||
Authority: `2026-09-07_tmux-confirmation-review-request.md`.
|
||||
Review only; no candidate edits, private-pane inspection, live sends, commits or push.
|
||||
No competing assignment is known; I have not authored this implementation.
|
||||
|
||||
## 1. BLOCKING: candidate changed during admission
|
||||
|
||||
The initial `sha256sum -c` passed for all four declared files. I read the source and
|
||||
test commands, then attempted to construct a hash-verified disposable export before
|
||||
execution. That copy verification failed. A fresh shared-tree check confirmed two
|
||||
mismatches while the supplied hash list remained unchanged:
|
||||
|
||||
| File under `tools/tmux/` | Requested SHA-256 | Subsequently measured SHA-256 |
|
||||
|---|---|---|
|
||||
| `agent-send.sh` | `37d11216558ff20935e58675691ae1c2c1e7722b69b955ac97b3131dd588259f` | unchanged |
|
||||
| `agent-send.test.sh` | `1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda` | unchanged |
|
||||
| `send-message.sh` | `9941146d88b2d27372815d4150b7f2ad6874f88d556eb67b70417228e3c9b0e2` | `e01d1e68a3efb10e0b3c7644c472524dbd2f76f1a9c94aeb911f5e1057bd9447` |
|
||||
| `test-send-message-verdict.sh` | `ccf8f2c1758a06ad0e9600c78efa487d002afc5c39e0fc22ea834852e84cad3f` | `d8d95d026f15acc7473b6dbc8a20b24b1e0fde3a8a1fc8eac839ddaf7ed95083` |
|
||||
|
||||
Measured HEAD at the failed recheck:
|
||||
`67eaf6fb47c3b88c72e261a83ab9210d66bd2909`.
|
||||
These are working-tree candidates; HEAD is not a substitute for their identities.
|
||||
|
||||
**Stopped before candidate execution.** I did not follow the moving revision,
|
||||
regenerate the expected hashes, or reconstruct a supposedly frozen candidate from
|
||||
conversation output. The requested candidate cannot receive independent approval
|
||||
from this cycle. Required disposition: stabilize the intended revision, issue a
|
||||
fresh exact-hash request, and preserve the rejected identities/history.
|
||||
|
||||
## 2. Provisional source-review concerns — not executed findings
|
||||
|
||||
The following describe the source displayed during initial inspection. Because the
|
||||
export admission subsequently failed, they are not represented as independently
|
||||
executed, exact-snapshot reproductions or findings against the newer bytes.
|
||||
They should remain explicit targets for the next frozen review.
|
||||
|
||||
### 2.1 Current-editor identity and message correlation remain unproven
|
||||
|
||||
`locate_input_box()` accepts the last two matching horizontal rules without proving
|
||||
that they delimit the current editor. Transcript rules can satisfy that test. A
|
||||
final nonblank transcript line beginning with `>`/`❯` can likewise look like a glyph
|
||||
prompt. Successful transport plus such a region lacking the message tail can take
|
||||
the exit-0 path without observing the actual draft.
|
||||
|
||||
The consequence is not only false confirmation: if a historical region contains the
|
||||
message suffix, the code can classify it as a current draft and send extra Enters.
|
||||
That undermines the claim that flushes require positive current-draft evidence.
|
||||
|
||||
Required hostile controls: transcript-only rule pairs and final prompt-looking
|
||||
output, hidden or displaced editors, historical matching tails, and an independently
|
||||
counted submission-key stream. Ambiguous observations must stay unconfirmed without
|
||||
additional Enter events. Passing the existing simple renderers does not establish
|
||||
these properties.
|
||||
|
||||
### 2.2 Suffix matching does not cover the rendered message domain
|
||||
|
||||
The suffix is derived by flattening newlines/spaces, removing non-printable bytes
|
||||
under the current locale, and taking 32 bytes. The captured editor is searched
|
||||
literally without corresponding multiline/wrap reconstruction. Under LC_ALL=C,
|
||||
a non-ASCII-only body can produce an empty snippet, bypassing the draft test.
|
||||
A wrapped or multiline suffix can be absent as a contiguous rendered substring
|
||||
while the message is still a draft.
|
||||
|
||||
Required controls: Unicode-only messages, empty-after-filter suffixes, multiline and
|
||||
narrow-pane wrapping, repeated/common tails, and retained drafts. Absence of a usable
|
||||
substring must not become positive submission evidence.
|
||||
|
||||
### 2.3 Capture failure is not checked before parsing its output
|
||||
|
||||
The displayed loop assigns `capture-pane` output without checking its exit status.
|
||||
A failed observation with partial success-looking stdout can therefore enter the
|
||||
confirmation parser. A failed capture must not justify success or another Enter.
|
||||
Add fault-injected nonzero capture with empty and nonempty stdout, separately from
|
||||
load/paste/submission-key failures.
|
||||
|
||||
### 2.4 Contract/evidence wording still needs reconciliation
|
||||
|
||||
The displayed sender/wrapper help and comments describe exit 0 as delivered/queued,
|
||||
retain a dead queued-success branch, and in places still describe a queued banner as
|
||||
a success path. The actual branch shown keeps a queued banner unconfirmed. Its
|
||||
unconfirmed diagnostic also calls the input box unlocatable even when a banner alone
|
||||
caused the timeout. Documentation must distinguish transport success, observed editor
|
||||
state, queued status and recipient acknowledgement.
|
||||
|
||||
The displayed verdict-test comments still call the trailing-em-dash shape a measured
|
||||
live cause, contrary to the explicit review request's correction. The ten-second
|
||||
window is a chosen bound, not demonstrated redraw behavior. Correct these claims
|
||||
without rewriting historical receipts. Also audit the adjacent-rule case: the
|
||||
`sed` range constructed for adjacent rules is reversed rather than an explicitly
|
||||
empty range, despite its comment claiming it prints nothing.
|
||||
|
||||
## 3. Actual receipts and limits
|
||||
|
||||
Executed read-only admission checks:
|
||||
|
||||
- Read the request and four-hash list.
|
||||
- `sha256sum -c docs/plans/reviews/2026-09-07_tmux-confirmation-review.sha256`:
|
||||
initially four OK; later sender and verdict-test FAILED.
|
||||
- Read all four displayed source/test files before proposed execution.
|
||||
- Disposable-copy hash verification: failed; no tests started.
|
||||
- Measured all four current hashes and HEAD to identify the drift boundary.
|
||||
|
||||
**NOT RUN:** candidate syntax/tests, supplied verdict fixtures, wrapper fixtures,
|
||||
hostile tmux controls, live-seat sends, remote SSH tests. The coordinator's 12/0
|
||||
receipt remains coordinator evidence, not my independent rerun. No scratch tmux
|
||||
server was started, and no existing pane or socket was inspected.
|
||||
|
||||
This verdict file is the only repository write. No approval, final test coverage,
|
||||
commit/push readiness, deployment or recipient acknowledgement is claimed.
|
||||
|
||||
**Return to darkwing:** the review is blocked on a stable exact candidate. Preserve
|
||||
the open rule-pair audit and the controls above; submit the final corrected frozen
|
||||
revision for independent review. No automatic retry, live resend or further action
|
||||
is authorized by this verdict.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Diagnostic revision live receipt
|
||||
|
||||
Fresh test marker: TMUX-DIAG-01. Target: default socket =ms-test. Sender: dragon-lin:darkwing; class terminal-log; no action/reply requested. One agent-send.sh invocation, not a replay of any prior uncertain message.
|
||||
|
||||
Prerequisite: diagnostic revision verdict suite passed 20/0 under LC_ALL=C.UTF-8 (`/tmp/tmux-reasons-utf8.log`), including real isolated pi-shaped fixtures and diagnostic assertions.
|
||||
|
||||
Live result: exit 0, `✓ delivered to =ms-test`. This proves only the tool observed its correlated-message/cleared-editor criteria in this live test, not recipient acknowledgement or processing. No private-pane inspection, deployment, or model-result claim.
|
||||
|
||||
The diagnostic working revision differs from frozen r2 only as recorded in the working diff; r2 export remains untouched. Prior Filbert/Dewey unconfirmed receipts are not retroactively confirmed by this separate target's success. Their failure cause remains undiagnosed. Independent final-revision approval is still required.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Tmux issue creation receipt
|
||||
|
||||
One authorized POST to repos/mosaicstack/stack/issues returned HTTP 201 and client exit 0.
|
||||
|
||||
Issue: https://git.mosaicstack.dev/mosaicstack/stack/issues/1496
|
||||
Title: Tmux messaging: explicit transport-only result and safe remote quoting
|
||||
State at creation: open.
|
||||
Deduplication marker: DARKWING-TMUX-TRANSPORT-WAVE-20260907.
|
||||
|
||||
No duplicate POST. Scope records Jason's transport-only ruling, R4 remote-injection finding, R5 correction and pending independent final approval. No deployment or main/next merge authorized. This is distinct from old stack-v2 #53, whose closure remains Jason's.
|
||||
@@ -0,0 +1,5 @@
|
||||
# R2 review transport receipt
|
||||
|
||||
One actionable agent-send.sh request sent from dragon-lin:darkwing to default socket =filbert, pointing to 2026-09-07_tmux-r2-review-request.md and the separate frozen r2 export. All seven export hashes verified immediately before send.
|
||||
|
||||
Exit 2: message-correlated editor evidence unavailable within observation window. Delivery unconfirmed, no acknowledgement claimed. Do not blindly resend. Expected reply/artifact: docs/plans/reviews/2026-09-07_tmux-r2-verdict.md. Darkwing owns reconciliation when a reply or artifact arrives. No watch/timer registered. Other authorized wave tasks may proceed independently, but no push before review and live verification.
|
||||
@@ -0,0 +1,7 @@
|
||||
9a1edcafa2b6c575a6afb7fa4b4714b994f8abe2d33a852810ae897b5aca0eb6 tools/tmux/agent-send.sh
|
||||
1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda tools/tmux/agent-send.test.sh
|
||||
86dfbb34321957d025d85441641d272267ecf13604ce22b13968d9561d4a14f8 tools/tmux/README.md
|
||||
76e17e9e777e95e042f90eee95312f06031c3b66e3c4f56e30f02527fe3737cb tools/tmux/send-message.sh
|
||||
ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607 tools/tmux/test-agent-send-socket-live.sh
|
||||
9831d6dbfb8bc87b9dd2cf56cdda57ebef255991c344a767c915b3d2fea070f2 tools/tmux/test-send-message-socket.sh
|
||||
43bd569e32f788e95efb7c9a08b6bdaa937364f71a422a4583ebe3b9d6355f6b tools/tmux/test-send-message-verdict.sh
|
||||
@@ -0,0 +1,118 @@
|
||||
# Inter-Agent tmux Comms — Standard & Tooling
|
||||
|
||||
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||
|
||||
## The addressing standard (required)
|
||||
|
||||
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||
|
||||
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||
|
||||
Example exchange:
|
||||
|
||||
```
|
||||
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||
```
|
||||
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` verbose · `-r N` Enter-flush attempts.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
commands do not fall back to tmux's prefix matching behavior.
|
||||
|
||||
## Named socket isolation
|
||||
|
||||
Durable Mosaic fleets should use a dedicated tmux socket, for example:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-fleet ls
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message"
|
||||
```
|
||||
|
||||
This keeps fleet operations away from the user's default tmux server. It is the
|
||||
safe rollout path on hosts that already have manual tmux sessions.
|
||||
|
||||
## Why a helper exists (the submission gotcha)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke. It does not send automatic extra Enters. The legacy `-r` option
|
||||
is accepted for compatibility but no longer authorizes flushes.
|
||||
|
||||
Exit 0 requires newly visible whole-message evidence relative to a pre-paste
|
||||
capture and a cleared supported editor region. This is a visual heuristic,
|
||||
not recipient acknowledgement or proof of processing. Static/repeated history,
|
||||
a queued banner alone, unsupported editor/footer layouts, failed captures,
|
||||
and ambiguous observations return unconfirmed (exit 2). Do not blindly resend
|
||||
an unconfirmed message: it may already have been accepted.
|
||||
|
||||
Pi rule matching groups UTF-8 literals so C and UTF-8 locales behave consistently.
|
||||
The supported rule-pair shape requires a path/branch footer immediately below it
|
||||
and at most four nonblank footer lines. Prompt-only layouts require a final
|
||||
nonblank prompt line. These narrow shapes can refuse legitimate custom layouts;
|
||||
visual resemblance is not an authenticated editor boundary. Ten seconds is a
|
||||
chosen observation budget, not a guaranteed response/redraw time.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
These live in the installed framework copy at
|
||||
`~/.mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||
to each host, so to propagate permanently, land both files in the framework
|
||||
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||
already works against any reachable host because it ships `send-message.sh` over
|
||||
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||
# with the canonical addressing preamble prepended:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||
# or context-wiped agent always knows who sent a message and to whom — no
|
||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||
#
|
||||
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
|
||||
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
|
||||
# field instead of re-deriving intent from the body.
|
||||
#
|
||||
# WHY A WRAPPER
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
|
||||
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
# Default: local hostname, or (remote) resolved via one ssh.
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -C CLASS triage class for a comms daemon. One of:
|
||||
# terminal-log log-only; never needs the agent's attention
|
||||
# actionable carries a decision/blocker/gate — deliver
|
||||
# human from a human operator — deliver
|
||||
# reaction an emoji/ack reaction
|
||||
# digest machine-wake, coalescible; batched wake/heartbeat signal
|
||||
# Long form: --class CLASS (or --class=CLASS). When SET, the
|
||||
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
|
||||
# [<src> -> <dst> class=terminal-log] <message>
|
||||
# When OMITTED, NO token is emitted and the preamble is
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Legacy compatibility option; no automatic extra Enter
|
||||
# -v verbose: print pane tail after delivery
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 observed correlated editor transition (not ACK) · 1 target not found
|
||||
# 2 unconfirmed or draft · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
||||
# default is the canonical send-message.sh beside this script; production callers
|
||||
# never set AGENT_SEND_SENDER, so behavior is unchanged.
|
||||
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
|
||||
|
||||
# Translate the long option --class[=value] into "-C value" so getopts (which is
|
||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
||||
# so callers that never use --class hit the exact original getopts path.
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
|
||||
args+=(-C "$2"); shift 2 ;;
|
||||
--class=*) args+=(-C "${1#*=}"); shift ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- ${args[@]+"${args[@]}"}
|
||||
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
|
||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
|
||||
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||
C) CLASS=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
# Validate the triage class only when one was given. An absent class emits NO
|
||||
# token (preamble byte-identical to the classic format); the consumer defaults
|
||||
# absent => actionable.
|
||||
CLASS_TOKEN=""
|
||||
if [ -n "$CLASS" ]; then
|
||||
case "$CLASS" in
|
||||
terminal-log|actionable|human|reaction|digest) CLASS_TOKEN=" class=${CLASS}" ;;
|
||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Message body from -f / -m / stdin.
|
||||
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
# Destination host label for the preamble.
|
||||
if [ -z "$DST_HOST" ]; then
|
||||
if [ -n "$SSH_TARGET" ]; then
|
||||
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||
else
|
||||
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||
fi
|
||||
fi
|
||||
|
||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
||||
FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
# Silence BOTH streams: has-session writes nothing to stdout, but a stub
|
||||
# (test fake) may — leaked probe stdout polluted this tool's stdout and
|
||||
# broke callers that read it (measured 2026-09-07, agent-send.test #9b).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" >/dev/null 2>&1 && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
|
||||
#
|
||||
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
|
||||
# base64 payload and prints the FULL message (preamble + body) so we can assert
|
||||
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
|
||||
# the preamble is deterministic across machines.
|
||||
#
|
||||
# Guarantees locked here:
|
||||
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
|
||||
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
|
||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
||||
# 4. -C <c> short form parses identically.
|
||||
# 5. invalid class => exit 3, nothing sent.
|
||||
# 6. --class with no value => exit 3.
|
||||
# 7. the documented consumer regex parses producer output for every class.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
# 11. --class digest is accepted (machine-wake, coalescible canon class).
|
||||
# 12. -C digest short form parses identically.
|
||||
# 13. the documented consumer regex parses producer output for class=digest.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN" "$SCRATCH_TMPDIR"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux — and
|
||||
# TMUX_TMPDIR is pinned to an empty scratch dir so socket discovery never
|
||||
# sees the HOST's sockets (measured 2026-09-07: with default+mosaic-fleet
|
||||
# live, discovery saw the fake answer 'mos' on both and B1-refused rc 4
|
||||
# before the stub ever ran; a one-socket host passed, so this only bites
|
||||
# multi-socket hosts).
|
||||
SCRATCH_TMPDIR=$(mktemp -d)
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX TMUX_TMPDIR="$SCRATCH_TMPDIR" \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$'
|
||||
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
|
||||
|
||||
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
|
||||
got=$(run -s mos -m "hello world")
|
||||
want='[a:src -> dsthost:mos] hello world'
|
||||
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|
||||
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
|
||||
|
||||
# 2. --class space form emits the token.
|
||||
got=$(run -s mos --class terminal-log -m "ACK")
|
||||
want='[a:src -> dsthost:mos class=terminal-log] ACK'
|
||||
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|
||||
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
|
||||
|
||||
# 3. --class=value equals form.
|
||||
got=$(run -s mos --class=actionable -m "decide X")
|
||||
want='[a:src -> dsthost:mos class=actionable] decide X'
|
||||
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|
||||
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 4. -C short form.
|
||||
got=$(run -s mos -C human -m "from a person")
|
||||
want='[a:src -> dsthost:mos class=human] from a person'
|
||||
[ "$got" = "$want" ] && ok "-C human (short form)" \
|
||||
|| no "-C human (short form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 5. invalid class => exit 3, no send.
|
||||
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
|
||||
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|
||||
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
|
||||
fi
|
||||
|
||||
# 6. --class with no value => exit 3.
|
||||
if run -s mos -m "x" --class 2>/dev/null; then
|
||||
no "--class with no value rejected" "expected non-zero exit, got 0"
|
||||
else
|
||||
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
|
||||
fi
|
||||
|
||||
# 11. --class digest (space form) is accepted.
|
||||
got=$(run -s mos --class digest -m "wake payload")
|
||||
want='[a:src -> dsthost:mos class=digest] wake payload'
|
||||
if [ "$got" = "$want" ]; then ok "--class digest emits token"
|
||||
else no "--class digest emits token" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 12. -C digest short form.
|
||||
got=$(run -s mos -C digest -m "coalesced wake")
|
||||
want='[a:src -> dsthost:mos class=digest] coalesced wake'
|
||||
if [ "$got" = "$want" ]; then ok "-C digest (short form)"
|
||||
else no "-C digest (short form)" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 7. consumer grammar parses every class + classic line.
|
||||
for c in terminal-log actionable human reaction digest; do
|
||||
line=$(run -s mos --class "$c" -m "body $c")
|
||||
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
|
||||
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
|
||||
done
|
||||
classic=$(run -s mos -m "plain body")
|
||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||||
# no-op in Claude Code, so the double-Enter is safe.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||
# -r N Legacy compatibility option; no automatic extra Enter is sent
|
||||
# -v verbose: print a short tail of the pane after delivery
|
||||
# -h help
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 observed new message visibility and cleared supported editor (not ACK)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
# 3 usage error
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,/^set -uo pipefail/{ /^set -uo pipefail/d; p; }' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
QUEUED_RE='Press up to edit queued messages'
|
||||
# Compare the whole message without ASCII layout whitespace. Preserve UTF-8
|
||||
# bytes even in LC_ALL=C; never drop Unicode or take a partial-byte suffix.
|
||||
# This tolerates plain whitespace wrapping, not arbitrary terminal rendering.
|
||||
snippet=$(printf '%s' "$MSG" | LC_ALL=C tr -d ' \t\r\n')
|
||||
[ -n "$snippet" ] || { echo "ERROR: message has no usable comparison content" >&2; exit 3; }
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
# Snapshot before any message effect. A later empty-looking editor alone
|
||||
# cannot establish acceptance; require newly visible whole-message evidence.
|
||||
if ! baseline_pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null); then
|
||||
echo "ERROR: baseline capture failed for $TARGET; nothing pasted" >&2
|
||||
exit 2
|
||||
fi
|
||||
baseline_normalized=$(printf '%s' "$baseline_pane" | LC_ALL=C tr -d ' \t\r\n')
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
|
||||
echo "ERROR: could not load message buffer for $TARGET" >&2
|
||||
exit 2
|
||||
fi
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
# FAIL LOUD if neither paste attempt succeeds: a silent continue here sent
|
||||
# bare Enters with no message and could report "delivered" while nothing
|
||||
# was delivered (measured defect, 2026-09-07). Exit 2 = submission NOT
|
||||
# confirmed, which is exactly true when nothing was pasted.
|
||||
if ! { "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET"; }; then
|
||||
"${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
echo "ERROR: paste into $TARGET failed — nothing was delivered (buffer discarded)" >&2
|
||||
exit 2
|
||||
fi
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line. A synthetic trailing
|
||||
# em-dash variant is also tolerated; it is not an established live shape.
|
||||
# Group the literal UTF-8 sequence before repetition: under LC_ALL=C,
|
||||
# an ungrouped quantifier repeats only its last byte, not the whole glyph.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
# A historical prompt anywhere in the transcript is not the current input.
|
||||
# For glyph-only layouts require the final nonblank line to be a prompt.
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -vE '^[[:space:]]*$' | tail -1)
|
||||
if printf '%s\n' "$glyph_line" | grep -qE '^[[:space:]]*(❯|>|│ >)'; then
|
||||
printf '%s\n' "$glyph_line"; return 0
|
||||
fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*(─){4,}(—)?[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
# Adjacent rules have no editor content row; reject rather than constructing
|
||||
# a reversed sed range and mistaking a border for an empty editor.
|
||||
[ "$bottom" -gt "$((top + 1))" ] || return 1
|
||||
# Require the supported pi footer immediately below the lower rule.
|
||||
# Transcript rules followed by arbitrary output are not an editor boundary.
|
||||
# This is a layout heuristic, not an authenticated receipt; unknown layouts
|
||||
# deliberately remain unconfirmed. Limit the suffix to the compact footer.
|
||||
local footer suffix_lines
|
||||
footer=$(printf '%s\n' "$pane" | sed -n "$((bottom + 1))p")
|
||||
printf '%s\n' "$footer" | grep -qE '^(/|~/).+ [(][^()]+[)][[:space:]]*$' || return 1
|
||||
suffix_lines=$(printf '%s\n' "$pane" | sed -n "$((bottom + 1)),\$p" | grep -cve '^[[:space:]]*$')
|
||||
[ "$suffix_lines" -le 4 ] || return 1
|
||||
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||
# empty, which is the delivered shape.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter ONLY
|
||||
# on positive evidence of an unsubmitted draft. Success requires positive
|
||||
# evidence — the queued banner, OR the REPL input box located AND clear of our
|
||||
# message tail. The historical bug was treating ABSENCE of a draft as
|
||||
# delivery: if the input box was never located (wrong pane / prompt-glyph
|
||||
# drift), an unsubmitted message read as "delivered" and worker->lead relays
|
||||
# stalled silently. We now default to UNCONFIRMED and only upgrade to
|
||||
# delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
# Flush Enters are sent ONLY after a located-but-still-draft box. When the
|
||||
# box is merely not locatable, we re-capture WITHOUT another Enter.
|
||||
# A redraw is only one possible explanation, not an established cause.
|
||||
if ! "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter; then
|
||||
echo "ERROR: submission key failed for $TARGET; do not blindly resend" >&2
|
||||
exit 2
|
||||
fi
|
||||
sleep 1.2
|
||||
status="unconfirmed"; pane=""
|
||||
flushes=0
|
||||
# Bound observation-only retries separately from the -r flush budget.
|
||||
# Ten seconds is a chosen patience limit, not a measured rendering guarantee.
|
||||
deadline=$(( SECONDS + 10 ))
|
||||
while :; do
|
||||
if ! pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null); then
|
||||
echo "ERROR: capture failed for $TARGET; submission remains unconfirmed" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# A queued banner alone is not correlated with this message. It may be
|
||||
# historical or belong to an earlier send; never upgrade on that alone.
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
|
||||
status="unconfirmed"; break
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED, keep re-capturing until the patience deadline; never
|
||||
# infer delivery, never re-submit blind.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
|
||||
status="unconfirmed"; break
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush
|
||||
# (evidence-based Enter, capped by -r) + retry. (Submitted messages scroll
|
||||
# up into history; a draft stays in the box.)
|
||||
normalized_input=$(printf '%s' "$inputbox" | LC_ALL=C tr -d ' \t\r\n')
|
||||
if grep -qF -- "$snippet" <<<"$normalized_input"; then
|
||||
status="draft"
|
||||
# A visual matching region may be historical. Until current-editor
|
||||
# identity is established, it cannot authorize another submission key.
|
||||
break
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
# only path to success besides the queued banner.
|
||||
observed_normalized=$(printf '%s' "$pane" | LC_ALL=C tr -d ' \t\r\n')
|
||||
if ! grep -qF -- "$snippet" <<<"$baseline_normalized" &&
|
||||
grep -qF -- "$snippet" <<<"$observed_normalized"; then
|
||||
status="delivered"; break
|
||||
fi
|
||||
# A static historical editor or a message disappearing without a visible
|
||||
# transcript transition is insufficient. Do not resubmit to manufacture it.
|
||||
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
|
||||
status="unconfirmed"; break
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after the initial submission key; no automatic flush attempted" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: message-correlated editor evidence unavailable within the observation window — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5).
|
||||
#
|
||||
# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR
|
||||
# #1466's socket resolution, deliberately not from test-send-message-socket.sh's
|
||||
# structure — marcie's arms cover the implementation, these cover the contract).
|
||||
#
|
||||
# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR:
|
||||
# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing
|
||||
# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included)
|
||||
# invisible to the tested process. Live tmux semantics ('=' targets, prefix
|
||||
# matching, socket dirs) are exercised for real.
|
||||
#
|
||||
# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections):
|
||||
# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is
|
||||
# skipped ENTIRELY (zero has-session probes, not merely zero hits)
|
||||
# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only)
|
||||
# C3 session on multiple sockets with no -L/env -> refusal rc 4, message
|
||||
# names the conflicting sockets and the -L hint; nothing sent
|
||||
# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR)
|
||||
# C5 no unique hit -> default socket (sender invoked with no -L);
|
||||
# remote (-H) sends do NO local discovery and do not forward the env
|
||||
# C6 '=name' targets match exactly (no prefix); explicit '=X' passes
|
||||
# through verbatim; compound 'sess:win.pane' pins the session component
|
||||
# exact ('=sess:win.pane')
|
||||
#
|
||||
# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args;
|
||||
# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH
|
||||
# ssh stub captures the remote command line. Sabotage controls prove the
|
||||
# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red.
|
||||
# Skip rc 77 without a tmux binary. Scratch servers killed via trap.
|
||||
set -uo pipefail
|
||||
|
||||
# NOTE: running a COPY of this suite from another directory resolves TOOL next
|
||||
# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set
|
||||
# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design.
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
|
||||
SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b)
|
||||
DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)"
|
||||
mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
# tmux refuses socket dirs with group/other bits ('unsafe permissions'):
|
||||
# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700
|
||||
chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
BIN="$SCRATCH/bin"; mkdir -p "$BIN"
|
||||
CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured"
|
||||
: > "$PROBES"
|
||||
|
||||
# sender stub: capture args, "send" nothing (socket/target choice is the test)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub"
|
||||
# tmux wrapper: log invocations, exec the real binary (live semantics)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux"
|
||||
# ssh stub: capture the remote command line; swallow stdin (the sender script)
|
||||
printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh"
|
||||
chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh"
|
||||
|
||||
sock_pid_a=""; sock_pid_b=""
|
||||
cleanup() {
|
||||
[ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null
|
||||
for s in sockA sockB sockX decoyD; do
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null
|
||||
TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null
|
||||
done
|
||||
rm -rf "$SCRATCH" "$DECOY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY)
|
||||
TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
run() { # passes through; caller sets env per arm
|
||||
PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \
|
||||
bash "$TOOL" -S test:src "$@"
|
||||
}
|
||||
|
||||
probe_count() { grep -c "has-session" "$PROBES" || true; }
|
||||
cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; }
|
||||
|
||||
fail=0
|
||||
ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
|
||||
probes_reset() { : > "$PROBES"; }
|
||||
cap_reset() { rm -f "$CAP"; }
|
||||
|
||||
# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------
|
||||
mk_server sockA t1 "$SCRATCH"
|
||||
mk_server sockB t1 "$SCRATCH"
|
||||
mk_server sockX t1 "$SCRATCH"
|
||||
|
||||
# --- C1: explicit -L beats env; discovery skipped entirely -----------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP"
|
||||
ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $?
|
||||
|
||||
# --- C2: env applies when no -L; discovery skipped -------------------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L envsock"
|
||||
ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C2: env pin skips discovery (0 probes)" $?
|
||||
|
||||
# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing -------
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 4 ]
|
||||
ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $?
|
||||
echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB"
|
||||
ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $?
|
||||
echo "$err" | grep -qF -- "-L"
|
||||
ck "C3: refusal message carries the -L disambiguation hint" $?
|
||||
[ ! -f "$CAP" ]
|
||||
ck "C3: nothing sent on refusal (sender never invoked)" $?
|
||||
|
||||
# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR -------------------------
|
||||
# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH
|
||||
mk_server decoyD onlydecoy "$DECOY"
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1
|
||||
! cap_has "-L decoyD"
|
||||
ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $?
|
||||
# and a session unique in the TMUX_TMPDIR tree IS discovered there
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -ge 2 ]
|
||||
ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $?
|
||||
|
||||
# --- C5: no unique hit -> default socket; remote sends: no local resolution ------
|
||||
# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties
|
||||
for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $?
|
||||
cap_reset; probes_reset
|
||||
rm -f "$SSHLOG"
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C5: remote send does NO local discovery (0 probes with -H)" $?
|
||||
[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG"
|
||||
ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $?
|
||||
|
||||
# --- C6: '=name' exact matching; verbatim '=X'; compound pinning -----------------
|
||||
mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $?
|
||||
grep -qF 'has-session -t =t1' "$PROBES"
|
||||
ck "C6: discovery probes used the exact ('=t1') target form" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old' "$CAP"
|
||||
ck "C6: already-exact '=X' input passes through verbatim" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old:0.0' "$CAP"
|
||||
ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $?
|
||||
|
||||
# --- red controls: the arms bind --------------------------------------------------
|
||||
SAB="$SCRATCH/agent-send-sabotaged.sh"
|
||||
# (a) move the env-default AFTER discovery: C2 must go red
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
'''
|
||||
assert s.count(envblk) == 1
|
||||
s2 = s.replace(envblk, "")
|
||||
anchor = 'socket_args=()'
|
||||
assert s.count(anchor) == 1
|
||||
s2 = s2.replace(anchor, envblk + anchor)
|
||||
assert s2 != s
|
||||
open(dst, "w").write(s2)
|
||||
PY
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi
|
||||
# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then
|
||||
ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0
|
||||
else
|
||||
ck "red-a validity: sabotage was a NO-OP — control invalid" 1
|
||||
fi
|
||||
# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing)
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
old = " exit 4\n"
|
||||
assert s.count(old) == 1
|
||||
s = s.replace(old, " :\n")
|
||||
open(dst, "w").write(s)
|
||||
PY
|
||||
mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$?
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null
|
||||
if [ "$src_rc" -eq 4 ]; then
|
||||
ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1
|
||||
else
|
||||
ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0
|
||||
fi
|
||||
|
||||
# --- verdict -----------------------------------------------------------------------
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)"
|
||||
exit 0
|
||||
fi
|
||||
echo "agent-send socket contract (live): FAILURES above"
|
||||
exit 1
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live tmux semantics on private sockets only. A caller may run this suite from
|
||||
# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for
|
||||
# every bare tmux command. Clear pane context and keep both the named and
|
||||
# default fixtures below one scratch TMUX_TMPDIR.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
|
||||
SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
TEST_TMUX_TMPDIR="$TMPDIR/tmux"
|
||||
mkdir -p "$TEST_TMUX_TMPDIR"
|
||||
chmod 700 "$TEST_TMUX_TMPDIR"
|
||||
unset TMUX TMUX_PANE
|
||||
export TMUX_TMPDIR="$TEST_TMUX_TMPDIR"
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
cleanup() {
|
||||
local test_rc=$? residue=0
|
||||
trap - EXIT
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default kill-server >/dev/null 2>&1 || true
|
||||
sleep 0.2
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: named scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: default scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR"
|
||||
if [ "$test_rc" -ne 0 ]; then
|
||||
exit "$test_rc"
|
||||
fi
|
||||
exit "$residue"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_tmux() {
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
}
|
||||
|
||||
capture_named() {
|
||||
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
|
||||
}
|
||||
|
||||
capture_default() {
|
||||
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
|
||||
}
|
||||
|
||||
require_tmux
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched
|
||||
# send-message.sh against three real tmux-pane fixtures on a throwaway socket:
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
# 6. DELIVERED — the pi box whose TOP rule transiently renders with a trailing
|
||||
# (em-dash rule) em dash (measured live 2026-09-07): the strict rule regex
|
||||
# rejected the top rule, leaving a single-rule "box not
|
||||
# locatable" => false UNDELIVERED alarm on every such send.
|
||||
# Post-fix: exit 0 ✓ delivered.
|
||||
# 7. UNCONFIRMED — a pane with no locatable box at all: re-captures must NOT
|
||||
# (no re-Enter) re-send blind Enters. Fixture counts received Enters; after
|
||||
# a send with -r 1 the count must be exactly 1 (the single
|
||||
# submit Enter). Pre-fix it was 2 (Enter before every capture).
|
||||
# 8. PASTE FAIL — both paste attempts fail: abort loud (exit 2, "paste ..."
|
||||
# stderr) BEFORE any Enter, buffer discarded — never a bare
|
||||
# Enter sequence that could read as "delivered" while empty.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; }
|
||||
|
||||
# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1).
|
||||
# readline strips bracketed-paste markers just like a real agent REPL; Enter
|
||||
# executes (text -> scrollback), leaving a fresh empty `❯ ` box.
|
||||
tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the
|
||||
# pasted tail sitting on the ❯ line) => draft => exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \
|
||||
'printf "❯ "; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then
|
||||
no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then
|
||||
ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
history=""
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n%s\n' "$history"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else history+="$line"; buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 6: pi box whose top rule carries a trailing em dash (transient
|
||||
# redraw shape measured live on pi, 2026-09-07). Only the top rule differs
|
||||
# from fixture 4's renderer.
|
||||
cat > "$TMP/piboxdash.sh" <<'PIBOXDASH'
|
||||
#!/usr/bin/env bash
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
history=""
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n%s\n' "$history"
|
||||
printf '%s—\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
history+="$line"
|
||||
buf=""
|
||||
draw
|
||||
done
|
||||
PIBOXDASH
|
||||
chmod +x "$TMP/piboxdash.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdash -c "$TMP" "exec bash '$TMP/piboxdash.sh'"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=piboxdash" -m "em dash fixture six delivered ok" 2>"$TMP/e6"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: top rule with trailing em dash => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: top rule with trailing em dash => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e6")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 7: no locatable box anywhere; count Enters the pane receives.
|
||||
# The single submit Enter is expected; re-captures must stay silent.
|
||||
cat > "$TMP/counter.sh" <<'COUNTER'
|
||||
#!/usr/bin/env bash
|
||||
n=0
|
||||
: > "$1"
|
||||
while IFS= read -r _line; do
|
||||
n=$((n + 1))
|
||||
printf '%s' "$n" > "$1"
|
||||
done
|
||||
COUNTER
|
||||
chmod +x "$TMP/counter.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s counter -c "$TMP" "exec bash '$TMP/counter.sh' '$TMP/enters'"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=counter" -r 1 -m "fixture seven unconfirmable" 2>"$TMP/e7"); then
|
||||
no "unconfirmed: unlocatable pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
enters=$(cat "$TMP/enters" 2>/dev/null || echo 0)
|
||||
if [ "$rc" -eq 2 ] && [ "$enters" = "1" ]; then
|
||||
ok "unconfirmed: unlocatable pane => exit 2 with exactly 1 Enter (no blind re-submits)"
|
||||
else
|
||||
no "unconfirmed: unlocatable pane => exit 2 with exactly 1 Enter" "rc=$rc enters=$enters err=[$(cat "$TMP/e7")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 8: paste attempts fail (stubbed tmux refuses paste-buffer, passes
|
||||
# everything else through to the real binary). Must abort BEFORE any Enter:
|
||||
# exit 2, stderr names the paste failure, counter stays at zero.
|
||||
FAKE_BIN8="$TMP/fakebin8"; mkdir -p "$FAKE_BIN8"
|
||||
REAL_TMUX8=$(command -v tmux)
|
||||
cat > "$FAKE_BIN8/tmux" <<TMUX8
|
||||
#!/usr/bin/env bash
|
||||
case " \$* " in
|
||||
*" paste-buffer "*) exit 1 ;; # send-message invokes: tmux -L <sock> paste-buffer ...
|
||||
esac
|
||||
exec "$REAL_TMUX8" "\$@"
|
||||
TMUX8
|
||||
chmod +x "$FAKE_BIN8/tmux"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pastefail -c "$TMP" "exec bash '$TMP/counter.sh' '$TMP/enters8'"
|
||||
sleep 0.3
|
||||
: > "$TMP/enters8"
|
||||
if out=$(PATH="$FAKE_BIN8:$PATH" "$SEND" -L "$SOCKET" -t "=pastefail" -m "fixture eight never pastes" 2>"$TMP/e8"); then
|
||||
no "paste-fail: failed paste must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
enters8=$(cat "$TMP/enters8" 2>/dev/null); enters8=${enters8:-0}
|
||||
if [ "$rc" -eq 2 ] && grep -qF "paste into" "$TMP/e8" && [ "$enters8" = "0" ]; then
|
||||
ok "paste-fail: failed paste => exit 2 loud, zero Enters sent"
|
||||
else
|
||||
no "paste-fail: failed paste => exit 2 loud, zero Enters" "rc=$rc enters8=$enters8 err=[$(cat "$TMP/e8")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Transport failures must not be upgraded by a stale queued banner or prompt.
|
||||
mkdir -p "$TMP/faultbin"
|
||||
cat > "$TMP/faultbin/tmux" <<'FAULTMUX'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$FAULT_LOG"
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null; [ "$FAULT_OP" != load-buffer ]; exit $? ;;
|
||||
*" send-keys "*) [ "$FAULT_OP" != send-keys ]; exit $? ;;
|
||||
*" capture-pane "*) printf 'Press up to edit queued messages\n❯ \n' ;;
|
||||
esac
|
||||
exit 0
|
||||
FAULTMUX
|
||||
chmod +x "$TMP/faultbin/tmux"
|
||||
for op in load-buffer send-keys; do
|
||||
: > "$TMP/fault-log"
|
||||
out=$(PATH="$TMP/faultbin:$PATH" FAULT_LOG="$TMP/fault-log" FAULT_OP="$op" \
|
||||
"$SEND" -L fixture -t '=fault' -m 'transport fault test' 2>"$TMP/fault-err"); rc=$?
|
||||
if [ "$rc" -eq 2 ] && [ -z "$out" ] && [ "$(grep -c capture-pane "$TMP/fault-log")" -eq 1 ]; then
|
||||
ok "$op failure refuses after baseline without post-send confirmation"
|
||||
else
|
||||
no "$op failure must refuse after baseline only" "rc=$rc out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
# Historical success-looking text without a current input must fail closed.
|
||||
for fixture in banner history rules adjacent capture; do
|
||||
mkdir -p "$TMP/historybin"
|
||||
cat > "$TMP/historybin/tmux" <<'HISTORYMUX'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null ;;
|
||||
*" capture-pane "*)
|
||||
if [ "$HISTORY_FIXTURE" = banner ]; then
|
||||
printf 'Press up to edit queued messages\n'
|
||||
elif [ "$HISTORY_FIXTURE" = capture ]; then
|
||||
printf '❯ \n'; exit 1
|
||||
elif [ "$HISTORY_FIXTURE" = adjacent ]; then
|
||||
printf '────────\n────────\n~/fixture (main)\n'
|
||||
elif [ "$HISTORY_FIXTURE" = rules ]; then
|
||||
printf 'historical output\n────────\nold text\n────────\nmore output; no current editor\n'
|
||||
else
|
||||
printf '❯ old prompt\nsubsequent output without an input box\n'
|
||||
fi ;;
|
||||
esac
|
||||
exit 0
|
||||
HISTORYMUX
|
||||
chmod +x "$TMP/historybin/tmux"
|
||||
out=$(PATH="$TMP/historybin:$PATH" HISTORY_FIXTURE="$fixture" \
|
||||
"$SEND" -L fixture -t '=history' -m 'new unrelated message' 2>"$TMP/history-err"); rc=$?
|
||||
if [ "$rc" -eq 2 ] && [ -z "$out" ]; then
|
||||
ok "historical $fixture cannot confirm a new message"
|
||||
else
|
||||
no "historical $fixture must remain unconfirmed" "rc=$rc out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
# Retained Unicode and whitespace-wrapped messages must remain drafts in C locale.
|
||||
mkdir -p "$TMP/unicodebin"
|
||||
cat > "$TMP/unicodebin/tmux" <<'UNICODEMUX'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null ;;
|
||||
*" send-keys "*) printf 'Enter\n' >> "$ENTER_LOG" ;;
|
||||
*" capture-pane "*) printf '────────\n%s\n────────\n~/fixture (main)\n' "$RENDERED_DRAFT" ;;
|
||||
esac
|
||||
exit 0
|
||||
UNICODEMUX
|
||||
chmod +x "$TMP/unicodebin/tmux"
|
||||
for shape in unicode wrapped; do
|
||||
if [ "$shape" = unicode ]; then body='你好世界'; rendered=$'你好\n世界';
|
||||
else body=$'alpha beta\ngamma delta'; rendered=$'alpha\nbeta gamma\ndelta'; fi
|
||||
: > "$TMP/draft-enters"
|
||||
out=$(LC_ALL=C PATH="$TMP/unicodebin:$PATH" RENDERED_DRAFT="$rendered" ENTER_LOG="$TMP/draft-enters" \
|
||||
"$SEND" -L fixture -t '=unicode' -r 0 -m "$body" 2>"$TMP/unicode-err"); rc=$?
|
||||
if [ "$rc" -eq 2 ] && [ -z "$out" ] && [ "$(wc -l < "$TMP/draft-enters")" -eq 1 ]; then
|
||||
ok "retained $shape message refuses with only initial submission key"
|
||||
else
|
||||
no "retained $shape message must not confirm" "rc=$rc out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$TMP/transitionbin"
|
||||
cat > "$TMP/transitionbin/tmux" <<'TRANSITIONMUX'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null ;;
|
||||
*" capture-pane "*)
|
||||
n=$(cat "$CAPTURE_COUNT"); n=$((n + 1)); printf '%s' "$n" > "$CAPTURE_COUNT"
|
||||
if [ "$TRANSITION" = repeated ] || { [ "$TRANSITION" = new ] && [ "$n" -gt 1 ]; }; then
|
||||
printf 'unique-correlation-message\n'
|
||||
fi
|
||||
printf '────────\n\n────────\n~/fixture (main)\n' ;;
|
||||
esac
|
||||
exit 0
|
||||
TRANSITIONMUX
|
||||
chmod +x "$TMP/transitionbin/tmux"
|
||||
for scenario in static repeated new; do
|
||||
printf '0' > "$TMP/capture-count"
|
||||
expected=2; [ "$scenario" = new ] && expected=0
|
||||
out=$(LC_ALL=C PATH="$TMP/transitionbin:$PATH" TRANSITION="$scenario" CAPTURE_COUNT="$TMP/capture-count" \
|
||||
"$SEND" -L fixture -t '=transition' -m unique-correlation-message 2>"$TMP/transition-err"); rc=$?
|
||||
if [ "$rc" -eq "$expected" ]; then
|
||||
ok "$scenario message visibility transition => exit $expected"
|
||||
else
|
||||
no "$scenario transition" "rc=$rc expected=$expected out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,9 @@
|
||||
# R2 bounded live check — Dewey
|
||||
|
||||
Authorized terminal-log test marker: TMUX-R2-LIVE-DW-01. Sender dragon-lin:darkwing, target default socket =dewey. One agent-send.sh invocation; no retry. Message requested no action/reply and changed no assignment.
|
||||
|
||||
Result: exit 2, no stdout. Stderr: `message-correlated editor evidence unavailable within the observation window — message may be UNDELIVERED`.
|
||||
|
||||
No recipient acknowledgement or processing result observed. This is a failed live-confirmation acceptance check, NOT delivery failure proven. Do not resend the same message blindly. No recipient private pane inspected. All seven frozen r2 export hashes verified unchanged after the send.
|
||||
|
||||
Known diagnostic boundary: unit fixtures pass, but the live parser does not expose which condition failed (queued-banner ambiguity, editor/footer recognition, normalization/wrapping, baseline repeated content or new-message visibility). Generic exit 2 cannot distinguish these. Next correction should add content-free diagnostic reason codes and test them in an isolated replica before another authorized live probe; do not weaken success criteria merely to silence the alarm.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Tmux r2 — fresh independent review request
|
||||
|
||||
Reviewer: Filbert. Author: Darkwing. Jason authorized full fix, independent review, bounded live verification, commit/push refactor and 17 tags. No deployment or trunk merge.
|
||||
|
||||
Review ONLY `docs/plans/reviews/2026-09-07_tmux-r2-export/`, whose seven files are pinned by its SHA256SUMS. Verify hashes inside that directory before admission. This read-only export will not be modified. Use a disposable copy for tests; do not review moving working-tree files. Prior NOT APPROVED verdict and original manifest remain unchanged.
|
||||
|
||||
Return verdict to `docs/plans/reviews/2026-09-07_tmux-r2-verdict.md`. Do not edit exported source. Isolated fixtures only; no private-pane inspection, live sends, deployments or commits.
|
||||
|
||||
## Revision
|
||||
|
||||
Grouped UTF-8 rules fix C-locale recognition. Failed captures/transport refuse. Adjacent or unsupported-footer rules refuse. Whole-message whitespace-normalized matching preserves Unicode. Baseline capture plus newly visible message and clear supported editor are required for success. No automatic extra Enter is sent, even for a visible matching draft. Queued banners alone remain unconfirmed. Wrapper/README explain these limits.
|
||||
|
||||
## Coordinator evidence
|
||||
|
||||
Verdict suite 20/0 under C and C.UTF-8, logs `/tmp/tmux-durable-C.log`, `/tmp/tmux-durable-C.UTF-8.log`. Wrapper 19/0, named-socket PASS, isolated live socket contract C1–C6 and sabotage controls PASS, logs `/tmp/review-r2-*.log`. Export hashes verified after copying. No final live verification or independent approval claimed.
|
||||
|
||||
## Explicit remaining review targets
|
||||
|
||||
Visual matching remains heuristic: a forged final prompt or footer is not authenticated editor identity. Determine whether baseline/new-message correlation sufficiently avoids historical/hidden-editor false confirmation for this bounded tool. Probe repeated/common message bodies, Unicode, wraps, capture failures and hidden editors. No extra submission keys should occur. Reject if current success semantics remain unsound.
|
||||
|
||||
Some older source/test introductory comments may retain stale double-Enter, queued-success or unsupported em-dash wording despite newer contract text; identify exact contradictions for correction, not implicit approval. Ten-second patience is a chosen bound, not demonstrated redraw timing. The em-dash test is synthetic, not an established live cause.
|
||||
|
||||
The first review failed because I edited the candidate during admission. That coordination error is acknowledged in `2026-09-07_tmux-review-disposition.md`; this independent export fixes candidate stability, not the other findings by assertion.
|
||||
@@ -0,0 +1,207 @@
|
||||
# Tmux r2 — independent verdict: NOT APPROVED
|
||||
|
||||
Reviewer: filbert. Author/coordinator: darkwing. Date: 2026-09-07.
|
||||
Authority: `2026-09-07_tmux-r2-review-request.md`.
|
||||
No competing assignment is known; I did not author or modify the candidate.
|
||||
|
||||
The export remained stable and the supplied executed suites pass. However,
|
||||
**baseline/new-message correlation still permits false-positive confirmation when
|
||||
the actual draft is retained behind a prompt/footer lookalike.** This was reproduced
|
||||
on real, reviewer-owned scratch tmux panes in both locales, including the wrapper.
|
||||
The no-automatic-extra-Enter change is independently supported and should be retained.
|
||||
|
||||
## 1. Exact reviewed candidate
|
||||
|
||||
Only the immutable export and its disposable copy were reviewed:
|
||||
`docs/plans/reviews/2026-09-07_tmux-r2-export/`.
|
||||
SHA-256 of its `SHA256SUMS`:
|
||||
`12f498a6e32bc3fe55a2c4707e8ce612e1c7e0801652619a78734e05bfce3244`.
|
||||
|
||||
| File under export `tools/tmux/` | SHA-256 |
|
||||
|---|---|
|
||||
| `agent-send.sh` | `9a1edcafa2b6c575a6afb7fa4b4714b994f8abe2d33a852810ae897b5aca0eb6` |
|
||||
| `agent-send.test.sh` | `1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda` |
|
||||
| `README.md` | `86dfbb34321957d025d85441641d272267ecf13604ce22b13968d9561d4a14f8` |
|
||||
| `send-message.sh` | `76e17e9e777e95e042f90eee95312f06031c3b66e3c4f56e30f02527fe3737cb` |
|
||||
| `test-agent-send-socket-live.sh` | `ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607` |
|
||||
| `test-send-message-socket.sh` | `9831d6dbfb8bc87b9dd2cf56cdda57ebef255991c344a767c915b3d2fea070f2` |
|
||||
| `test-send-message-verdict.sh` | `43bd569e32f788e95efb7c9a08b6bdaa937364f71a422a4583ebe3b9d6355f6b` |
|
||||
|
||||
All seven hashes passed before admission, after copying, and after execution in
|
||||
both export and copy; final recheck **2026-09-07T20:02:28Z**. No mutable working-tree
|
||||
source was substituted. The earlier rejection remains untouched.
|
||||
|
||||
Disposable copy: `/tmp/filbert-tmux-r2.0tfori1a`.
|
||||
Evidence: its `evidence/` directory. Script copies were made executable; bytes were
|
||||
unchanged. The export itself was neither edited nor chmodded.
|
||||
|
||||
## 2. Findings
|
||||
|
||||
### F1 — BLOCKING / high: new visibility does not identify the current cleared editor
|
||||
|
||||
Locations: `send-message.sh:139–174` (`locate_input_box`), particularly the final
|
||||
prompt test at 143–145 and footer heuristic at 164–169; confirmation at 228–231;
|
||||
exit-0 message at 242. `agent-send.sh` propagates this verdict.
|
||||
|
||||
The baseline proves only that the normalized body was absent from the earlier
|
||||
capture. The next capture can contain a **draft preview**, while the selected
|
||||
empty-looking region is transcript decoration rather than the current editor.
|
||||
Neither newly visible text nor the path/branch-shaped footer establishes the missing
|
||||
editor identity. The final-glyph branch has the same problem without needing rules.
|
||||
|
||||
#### Independent real-tmux reproduction
|
||||
|
||||
A small raw-TTY synthetic REPL supports bracketed paste and records:
|
||||
`pending`, `accepted`, and `submit_keys` in a separate fixture-state file.
|
||||
It has an honest accepting mode, a visible retained-draft mode, and hidden-editor
|
||||
modes. Hidden modes retain the complete pasted message after the initial Enter;
|
||||
they do not accept it. They render a preview above either:
|
||||
|
||||
```
|
||||
Draft preview (NOT submitted): <complete message>
|
||||
────────
|
||||
|
||||
────────
|
||||
~/fixture (main)
|
||||
```
|
||||
|
||||
or a final `> transcript decoration` line. The baseline contains no message and no
|
||||
such editor-looking region. This is a hostile synthetic layout, not a claim that a
|
||||
particular live application currently renders it.
|
||||
|
||||
Actual results in **both LC_ALL=C and LC_ALL=C.UTF-8**:
|
||||
|
||||
| Fixture | Actual receipt | Independently recorded state |
|
||||
|---|---|---|
|
||||
| Honest accepting REPL | exit 0, delivered | accepted count 1; pending empty |
|
||||
| Visible retained draft | exit 2, draft | accepted count 0; body retained |
|
||||
| Hidden draft + rule/footer lookalike | **exit 0, delivered** | **accepted count 0; entire body retained** |
|
||||
| Hidden draft + final glyph lookalike | **exit 0, delivered** | **accepted count 0; entire body retained** |
|
||||
| Wrapper into hidden rule/footer fixture | **exit 0, delivered** | **accepted count 0; entire prefixed body retained** |
|
||||
|
||||
That is **six false-positive confirmations and four honest controls**. Every run
|
||||
received exactly **one** submission key, even with `-r 100`. Unicode bodies were
|
||||
preserved. This is not merely absence of a model acknowledgement: the fixture
|
||||
positively records the uncleared draft, and the apparent cleared region is not its
|
||||
editor. The “not ACK” qualification does not establish a current editor boundary.
|
||||
|
||||
The supplied transition stub covers static/repeated/new text with the same assumed
|
||||
editor shape; its successful new-transition case does not distinguish this retained-
|
||||
draft counterexample. Those passing fixtures therefore cannot close the rule-pair
|
||||
or hidden-editor audit.
|
||||
|
||||
Required disposition: leave such observations unconfirmed unless the supported
|
||||
runtime boundary supplies sufficient current-editor/message correlation. Do not
|
||||
restore extra Enter events to compensate. Add hostile retained-draft controls with
|
||||
independent state/key-count evidence and honest accepting controls. If the intended
|
||||
result is only “text appeared near a lookalike,” that is a different, weaker contract
|
||||
requiring explicit owner reconciliation—not a successful fix by relabeling.
|
||||
|
||||
### F2 — BLOCKING / medium: source/help/test evidence contradicts the revised contract
|
||||
|
||||
Exact contradictions in this export:
|
||||
|
||||
- `send-message.sh:12–13`: another Enter is recommended and double-Enter called safe;
|
||||
revised behavior sends no automatic extra Enter.
|
||||
- `send-message.sh:39`, 176–186 and 226–227: queued-banner/flush/suffix success
|
||||
descriptions remain, despite the actual banner refusal and whole-body predicate.
|
||||
- `send-message.sh:128–130`: the last rule pair is described as making detection
|
||||
safe; F1 demonstrates the missing boundary.
|
||||
- `send-message.sh:170–171`: adjacent rules are described as a successful empty
|
||||
range, directly after code that now rejects them.
|
||||
- `agent-send.sh:23` still advertises Enter-flush behavior; `README.md:50` still
|
||||
describes `-r N` as Enter-flush attempts, contradicting its later compatibility text.
|
||||
- `test-send-message-verdict.sh:21` and 142–144 describe the synthetic trailing
|
||||
em-dash fixture as measured live redraw behavior, contradicting the request's
|
||||
explicit correction. Its introductory “three” fixtures and flush-related comments
|
||||
are stale as well.
|
||||
|
||||
Required correction: make help, comments, README and fixture claims consistently
|
||||
state the chosen observation limit, no automatic extra keys, unconfirmed queued
|
||||
banners, and the exact qualified success predicate. Keep synthetic fixtures labeled
|
||||
synthetic; do not restore unsupported live-root-cause claims. Preserve prior records
|
||||
and record corrections honestly.
|
||||
|
||||
### F3 — NONBLOCKING test-isolation gap: named-socket suite writes shared /tmp paths
|
||||
|
||||
`test-send-message-socket.sh:76,85` redirect to fixed
|
||||
`/tmp/send-message-named.out` and `/tmp/agent-send-named.out`, outside its private
|
||||
scratch roots. Concurrent runs can overwrite each other's files; those paths can
|
||||
also already belong to another task. I did **not run this suite** under an
|
||||
isolated-only authorization and did not patch it to manufacture a passing receipt.
|
||||
Use per-run output paths in the maintained test. Its coordinator PASS is not an
|
||||
independent rerun here. The separate socket-contract suite was independently run.
|
||||
|
||||
## 3. Verified improvements and remaining limits
|
||||
|
||||
- Grouped UTF-8 rules and retained Unicode/wrapped-message controls pass in the
|
||||
supplied verdict suite under C and C.UTF-8. Unicode is no longer filtered away.
|
||||
- Baseline and post-submission capture failures refuse, including failure with
|
||||
success-looking stdout; post-capture failure sends no additional key.
|
||||
- Load/paste/submission-key failures refuse at the appropriate stage.
|
||||
- Adjacent rules and unsupported-footer/history controls refuse in supplied tests.
|
||||
- Repeated body already in baseline remains unconfirmed even with an empty-looking
|
||||
editor. This is conservative behavior, not a receipt for a second identical send.
|
||||
- Queued banners alone remain unconfirmed; retained wrapped Unicode stays a draft.
|
||||
- No automatic extra submission key occurred in the independent retained/hidden/
|
||||
accepted/fault controls. That fixes the prior flush-safety concern, but not F1.
|
||||
- Wrapper address/triage and scratch socket-selection controls pass within their
|
||||
tested scope. No remote-host delivery, deployment or live-seat acceptance was tested.
|
||||
|
||||
## 4. Actual commands and receipts
|
||||
|
||||
Versions: **tmux 3.7c**, **GNU bash 5.3.15(1)-release**.
|
||||
All source/test commands were read before execution. Test environments used `env -i`
|
||||
semantics (explicit environment dictionaries), PATH `/usr/bin:/bin`, synthetic HOME,
|
||||
TMPDIR and TMUX_TMPDIR beneath the disposable root, with no inherited TMUX/TMUX_PANE
|
||||
or Mosaic socket identity. Only reviewer-created sockets/panes were used. Scratch
|
||||
socket-contract SSH operations use the test's capture stub, never a remote connection.
|
||||
|
||||
| Command/test from disposable copy | Actual result |
|
||||
|---|---|
|
||||
| `bash -n` on all six shell files | exit 0 |
|
||||
| `bash tools/tmux/agent-send.test.sh`, LC_ALL=C | exit 0; **19/0** |
|
||||
| `bash tools/tmux/test-send-message-verdict.sh`, LC_ALL=C | exit 0; **20/0** |
|
||||
| Same verdict suite, LC_ALL=C.UTF-8 | exit 0; **20/0** |
|
||||
| `bash tools/tmux/test-agent-send-socket-live.sh`, LC_ALL=C | exit 0; C1–C6 and effective sabotage controls pass; only private scratch fixtures |
|
||||
| `python3 evidence/hostile-real.py` | exit 0 verifies the **six false positives** and four controls described in F1; not a candidate PASS |
|
||||
| `python3 evidence/fault-probes.py` | exit 0; nine independent stub-transport controls pass |
|
||||
| `bash tools/tmux/test-send-message-socket.sh` | **NOT RUN**, fixed shared output paths (F3) |
|
||||
|
||||
Independent fault controls cover: positive transition; repeated baseline body;
|
||||
post-capture failure; baseline failure; load failure; both paste attempts failing;
|
||||
submission-key failure; retained wrapped Unicode; and queued banner. Stub logs assert
|
||||
zero or one submit-key call as appropriate, with `-r 999`. No actual socket is used
|
||||
by that additional fault harness.
|
||||
|
||||
All seven candidate byte hashes still match in the export and disposable copy.
|
||||
No remaining responding servers were found in the reviewer-owned socket directory;
|
||||
suite-owned scratch servers were cleaned up by their traps. No private pane, default
|
||||
user server or live seat was inspected or mutated.
|
||||
|
||||
Selected evidence hashes, relative to `/tmp/filbert-tmux-r2.0tfori1a/evidence/`:
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
|---|---|
|
||||
| `suite-results.json` | `878348c276ceabf8e454d31ec0f71ff569cc46a1210dedc6b9185aaaf6ca8076` |
|
||||
| `verdict-C.log` and `verdict-UTF8.log` (identical) | `8a775338f565016d2ca5610386a0928822115426632d1be5ab97b8b7eee9a720` |
|
||||
| `wrapper.log` | `2df46e6e4285d1955ce93f1b3a51396ea23facb38b3cabea1e53d3dae8d34017` |
|
||||
| `socket-contract.log` | `3d794abd5ea59ff4b476f0614a186799141e6863f9fa98f6775d6fb6ad31119c` |
|
||||
| `editor-fixture.py` | `4761eed8f12755380be751304f227e1d3e129e7069dab1e7c5e674c9b77fece0` |
|
||||
| `hostile-real.py` | `55e3cf501c44d9041bed4bae360da552ac1360a4eaff8fb343c5e317ab996ebe` |
|
||||
| `hostile-receipts.json` | `c463b5c013436a622a46672dfdc7620d31bf55b4b34c897669fbd55a8ab6ee1b` |
|
||||
| `fault-receipts.json` | `1fad321c1f862a8f549f713596f2d96bc3a23c9a0641b564d6d2ff692d356592` |
|
||||
| `case2.state.json` | `7d86d133a14a50cdeba668b5048f24e9627181044a26d6551f37b2f4cb8a766a` |
|
||||
| `case2.pane.txt` | `c1b44e938f2cdd7a8cc1d23a77e77eb0deb1e91b5c7389271b07e560fbee15ca` |
|
||||
|
||||
Scratch receipts are local/disposable, not durable publication. The described
|
||||
fixture and saved harness permit reproduction without a live agent.
|
||||
|
||||
## 5. Return
|
||||
|
||||
**NOT APPROVED at this exact r2 export.** Candidate stability is resolved; the
|
||||
confirmation-soundness audit is not. Return F1–F3 to darkwing for reconciliation and
|
||||
a newly frozen revision. No code fixes, export edits, dependency installs, private
|
||||
inspection, live sends, deployment, commits or push occurred. This verdict is the
|
||||
only repository write. No automatic resend, acceptance, timer or subsequent action
|
||||
is inferred.
|
||||
@@ -0,0 +1,7 @@
|
||||
# R3 independent-review request transport receipt
|
||||
|
||||
One new revision-specific agent-send.sh request: dragon-lin:darkwing to default socket =filbert. Pointer: 2026-09-07_tmux-r3-review-request.md. Not a replay of earlier uncertain requests. Seven r3 export hashes verified before request.
|
||||
|
||||
Exit 2: `reason=editor-shape-unrecognized`. Delivery unconfirmed; no ACK claimed. Do not blindly resend. Expected artifact: docs/plans/reviews/2026-09-07_tmux-r3-verdict.md. Darkwing owns reconciliation. R2 and R3 snapshots remain unchanged.
|
||||
|
||||
This isolates the failure category to editor recognition in the observed send, not the precise violated shape constraint. No private transcript or pane inspection authorized by this receipt. Use isolated reproduction or recipient-supplied content-free layout diagnostics to distinguish rule matching, footer shape and suffix length.
|
||||
@@ -0,0 +1,7 @@
|
||||
d59b60d601a9076e152830ee769772f7b099aada0d194b6e3844f5fbdcd7f5d0 tools/tmux/agent-send.sh
|
||||
1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda tools/tmux/agent-send.test.sh
|
||||
86dfbb34321957d025d85441641d272267ecf13604ce22b13968d9561d4a14f8 tools/tmux/README.md
|
||||
0efe48969dd52db634b46c99410070811d3be78e2a33a373ab8f1bef128b2703 tools/tmux/send-message.sh
|
||||
ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607 tools/tmux/test-agent-send-socket-live.sh
|
||||
9831d6dbfb8bc87b9dd2cf56cdda57ebef255991c344a767c915b3d2fea070f2 tools/tmux/test-send-message-socket.sh
|
||||
a286cfb1c3bfa4a5a762250f0dbfb66b2b405b4602e739129b201a65e8c189d9 tools/tmux/test-send-message-verdict.sh
|
||||
@@ -0,0 +1,118 @@
|
||||
# Inter-Agent tmux Comms — Standard & Tooling
|
||||
|
||||
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||
|
||||
## The addressing standard (required)
|
||||
|
||||
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||
|
||||
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||
|
||||
Example exchange:
|
||||
|
||||
```
|
||||
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||
```
|
||||
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` verbose · `-r N` Enter-flush attempts.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
commands do not fall back to tmux's prefix matching behavior.
|
||||
|
||||
## Named socket isolation
|
||||
|
||||
Durable Mosaic fleets should use a dedicated tmux socket, for example:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-fleet ls
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message"
|
||||
```
|
||||
|
||||
This keeps fleet operations away from the user's default tmux server. It is the
|
||||
safe rollout path on hosts that already have manual tmux sessions.
|
||||
|
||||
## Why a helper exists (the submission gotcha)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke. It does not send automatic extra Enters. The legacy `-r` option
|
||||
is accepted for compatibility but no longer authorizes flushes.
|
||||
|
||||
Exit 0 requires newly visible whole-message evidence relative to a pre-paste
|
||||
capture and a cleared supported editor region. This is a visual heuristic,
|
||||
not recipient acknowledgement or proof of processing. Static/repeated history,
|
||||
a queued banner alone, unsupported editor/footer layouts, failed captures,
|
||||
and ambiguous observations return unconfirmed (exit 2). Do not blindly resend
|
||||
an unconfirmed message: it may already have been accepted.
|
||||
|
||||
Pi rule matching groups UTF-8 literals so C and UTF-8 locales behave consistently.
|
||||
The supported rule-pair shape requires a path/branch footer immediately below it
|
||||
and at most four nonblank footer lines. Prompt-only layouts require a final
|
||||
nonblank prompt line. These narrow shapes can refuse legitimate custom layouts;
|
||||
visual resemblance is not an authenticated editor boundary. Ten seconds is a
|
||||
chosen observation budget, not a guaranteed response/redraw time.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
These live in the installed framework copy at
|
||||
`~/.mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||
to each host, so to propagate permanently, land both files in the framework
|
||||
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||
already works against any reachable host because it ships `send-message.sh` over
|
||||
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||
# with the canonical addressing preamble prepended:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||
# or context-wiped agent always knows who sent a message and to whom — no
|
||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||
#
|
||||
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
|
||||
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
|
||||
# field instead of re-deriving intent from the body.
|
||||
#
|
||||
# WHY A WRAPPER
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# bracketed paste + one Enter + correlated visual observation. For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
|
||||
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
# Default: local hostname, or (remote) resolved via one ssh.
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -C CLASS triage class for a comms daemon. One of:
|
||||
# terminal-log log-only; never needs the agent's attention
|
||||
# actionable carries a decision/blocker/gate — deliver
|
||||
# human from a human operator — deliver
|
||||
# reaction an emoji/ack reaction
|
||||
# digest machine-wake, coalescible; batched wake/heartbeat signal
|
||||
# Long form: --class CLASS (or --class=CLASS). When SET, the
|
||||
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
|
||||
# [<src> -> <dst> class=terminal-log] <message>
|
||||
# When OMITTED, NO token is emitted and the preamble is
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Legacy compatibility option; no automatic extra Enter
|
||||
# -v verbose: print pane tail after delivery
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 observed correlated editor transition (not ACK) · 1 target not found
|
||||
# 2 unconfirmed or draft · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
||||
# default is the canonical send-message.sh beside this script; production callers
|
||||
# never set AGENT_SEND_SENDER, so behavior is unchanged.
|
||||
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
|
||||
|
||||
# Translate the long option --class[=value] into "-C value" so getopts (which is
|
||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
||||
# so callers that never use --class hit the exact original getopts path.
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
|
||||
args+=(-C "$2"); shift 2 ;;
|
||||
--class=*) args+=(-C "${1#*=}"); shift ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- ${args[@]+"${args[@]}"}
|
||||
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
|
||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
|
||||
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||
C) CLASS=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
# Validate the triage class only when one was given. An absent class emits NO
|
||||
# token (preamble byte-identical to the classic format); the consumer defaults
|
||||
# absent => actionable.
|
||||
CLASS_TOKEN=""
|
||||
if [ -n "$CLASS" ]; then
|
||||
case "$CLASS" in
|
||||
terminal-log|actionable|human|reaction|digest) CLASS_TOKEN=" class=${CLASS}" ;;
|
||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Message body from -f / -m / stdin.
|
||||
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
# Destination host label for the preamble.
|
||||
if [ -z "$DST_HOST" ]; then
|
||||
if [ -n "$SSH_TARGET" ]; then
|
||||
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||
else
|
||||
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||
fi
|
||||
fi
|
||||
|
||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
||||
FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
# Silence BOTH streams: has-session writes nothing to stdout, but a stub
|
||||
# (test fake) may — leaked probe stdout polluted this tool's stdout and
|
||||
# broke callers that read it (measured 2026-09-07, agent-send.test #9b).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" >/dev/null 2>&1 && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
|
||||
#
|
||||
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
|
||||
# base64 payload and prints the FULL message (preamble + body) so we can assert
|
||||
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
|
||||
# the preamble is deterministic across machines.
|
||||
#
|
||||
# Guarantees locked here:
|
||||
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
|
||||
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
|
||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
||||
# 4. -C <c> short form parses identically.
|
||||
# 5. invalid class => exit 3, nothing sent.
|
||||
# 6. --class with no value => exit 3.
|
||||
# 7. the documented consumer regex parses producer output for every class.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
# 11. --class digest is accepted (machine-wake, coalescible canon class).
|
||||
# 12. -C digest short form parses identically.
|
||||
# 13. the documented consumer regex parses producer output for class=digest.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN" "$SCRATCH_TMPDIR"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux — and
|
||||
# TMUX_TMPDIR is pinned to an empty scratch dir so socket discovery never
|
||||
# sees the HOST's sockets (measured 2026-09-07: with default+mosaic-fleet
|
||||
# live, discovery saw the fake answer 'mos' on both and B1-refused rc 4
|
||||
# before the stub ever ran; a one-socket host passed, so this only bites
|
||||
# multi-socket hosts).
|
||||
SCRATCH_TMPDIR=$(mktemp -d)
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX TMUX_TMPDIR="$SCRATCH_TMPDIR" \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$'
|
||||
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
|
||||
|
||||
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
|
||||
got=$(run -s mos -m "hello world")
|
||||
want='[a:src -> dsthost:mos] hello world'
|
||||
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|
||||
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
|
||||
|
||||
# 2. --class space form emits the token.
|
||||
got=$(run -s mos --class terminal-log -m "ACK")
|
||||
want='[a:src -> dsthost:mos class=terminal-log] ACK'
|
||||
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|
||||
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
|
||||
|
||||
# 3. --class=value equals form.
|
||||
got=$(run -s mos --class=actionable -m "decide X")
|
||||
want='[a:src -> dsthost:mos class=actionable] decide X'
|
||||
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|
||||
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 4. -C short form.
|
||||
got=$(run -s mos -C human -m "from a person")
|
||||
want='[a:src -> dsthost:mos class=human] from a person'
|
||||
[ "$got" = "$want" ] && ok "-C human (short form)" \
|
||||
|| no "-C human (short form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 5. invalid class => exit 3, no send.
|
||||
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
|
||||
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|
||||
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
|
||||
fi
|
||||
|
||||
# 6. --class with no value => exit 3.
|
||||
if run -s mos -m "x" --class 2>/dev/null; then
|
||||
no "--class with no value rejected" "expected non-zero exit, got 0"
|
||||
else
|
||||
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
|
||||
fi
|
||||
|
||||
# 11. --class digest (space form) is accepted.
|
||||
got=$(run -s mos --class digest -m "wake payload")
|
||||
want='[a:src -> dsthost:mos class=digest] wake payload'
|
||||
if [ "$got" = "$want" ]; then ok "--class digest emits token"
|
||||
else no "--class digest emits token" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 12. -C digest short form.
|
||||
got=$(run -s mos -C digest -m "coalesced wake")
|
||||
want='[a:src -> dsthost:mos class=digest] coalesced wake'
|
||||
if [ "$got" = "$want" ]; then ok "-C digest (short form)"
|
||||
else no "-C digest (short form)" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 7. consumer grammar parses every class + classic line.
|
||||
for c in terminal-log actionable human reaction digest; do
|
||||
line=$(run -s mos --class "$c" -m "body $c")
|
||||
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
|
||||
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
|
||||
done
|
||||
classic=$(run -s mos -m "plain body")
|
||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||
# then observe. This revision sends one Enter only; an ambiguous visual
|
||||
# region cannot authorize an extra submission key.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||
# -r N Legacy compatibility option; no automatic extra Enter is sent
|
||||
# -v verbose: print a short tail of the pane after delivery
|
||||
# -h help
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 observed new message visibility and cleared supported editor (not ACK)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see new message visibility plus a supported editor clear of it, we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
# 3 usage error
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,/^set -uo pipefail/{ /^set -uo pipefail/d; p; }' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
QUEUED_RE='Press up to edit queued messages'
|
||||
# Compare the whole message without ASCII layout whitespace. Preserve UTF-8
|
||||
# bytes even in LC_ALL=C; never drop Unicode or take a partial-byte suffix.
|
||||
# This tolerates plain whitespace wrapping, not arbitrary terminal rendering.
|
||||
snippet=$(printf '%s' "$MSG" | LC_ALL=C tr -d ' \t\r\n')
|
||||
[ -n "$snippet" ] || { echo "ERROR: message has no usable comparison content" >&2; exit 3; }
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
# Snapshot before any message effect. A later empty-looking editor alone
|
||||
# cannot establish acceptance; require newly visible whole-message evidence.
|
||||
if ! baseline_pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null); then
|
||||
echo "ERROR: baseline capture failed for $TARGET; nothing pasted" >&2
|
||||
exit 2
|
||||
fi
|
||||
baseline_normalized=$(printf '%s' "$baseline_pane" | LC_ALL=C tr -d ' \t\r\n')
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
|
||||
echo "ERROR: could not load message buffer for $TARGET" >&2
|
||||
exit 2
|
||||
fi
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
# FAIL LOUD if neither paste attempt succeeds: a silent continue here sent
|
||||
# bare Enters with no message and could report "delivered" while nothing
|
||||
# was delivered (measured defect, 2026-09-07). Exit 2 = submission NOT
|
||||
# confirmed, which is exactly true when nothing was pasted.
|
||||
if ! { "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET"; }; then
|
||||
"${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
echo "ERROR: paste into $TARGET failed — nothing was delivered (buffer discarded)" >&2
|
||||
exit 2
|
||||
fi
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line. A synthetic trailing
|
||||
# em-dash variant is also tolerated; it is not an established live shape.
|
||||
# Group the literal UTF-8 sequence before repetition: under LC_ALL=C,
|
||||
# an ungrouped quantifier repeats only its last byte, not the whole glyph.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
# A historical prompt anywhere in the transcript is not the current input.
|
||||
# For glyph-only layouts require the final nonblank line to be a prompt.
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -vE '^[[:space:]]*$' | tail -1)
|
||||
if printf '%s\n' "$glyph_line" | grep -qE '^[[:space:]]*(❯|>|│ >)'; then
|
||||
printf '%s\n' "$glyph_line"; return 0
|
||||
fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*(─){4,}(—)?[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
# Adjacent rules have no editor content row; reject rather than constructing
|
||||
# a reversed sed range and mistaking a border for an empty editor.
|
||||
[ "$bottom" -gt "$((top + 1))" ] || return 1
|
||||
# Require the supported pi footer immediately below the lower rule.
|
||||
# Transcript rules followed by arbitrary output are not an editor boundary.
|
||||
# This is a layout heuristic, not an authenticated receipt; unknown layouts
|
||||
# deliberately remain unconfirmed. Limit the suffix to the compact footer.
|
||||
local footer suffix_lines
|
||||
footer=$(printf '%s\n' "$pane" | sed -n "$((bottom + 1))p")
|
||||
printf '%s\n' "$footer" | grep -qE '^(/|~/).+ [(][^()]+[)][[:space:]]*$' || return 1
|
||||
suffix_lines=$(printf '%s\n' "$pane" | sed -n "$((bottom + 1)),\$p" | grep -cve '^[[:space:]]*$')
|
||||
[ "$suffix_lines" -le 4 ] || return 1
|
||||
# Adjacent rules were rejected above. Extract at least one editor row.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2) Submit once, then observe a message-correlated visual transition.
|
||||
# Unknown shapes, repeated baseline content and queued banners cannot confirm.
|
||||
# This is not recipient acknowledgement; see README for heuristic limitations.
|
||||
if ! "${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter; then
|
||||
echo "ERROR: submission key failed for $TARGET; do not blindly resend" >&2
|
||||
exit 2
|
||||
fi
|
||||
sleep 1.2
|
||||
status="unconfirmed"; pane=""; confirmation_reason="not-observed"
|
||||
# Bound observation-only retries; -r is retained only for compatibility.
|
||||
# Ten seconds is a chosen patience limit, not a measured rendering guarantee.
|
||||
deadline=$(( SECONDS + 10 ))
|
||||
while :; do
|
||||
if ! pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null); then
|
||||
echo "ERROR: capture failed for $TARGET; submission remains unconfirmed" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# A queued banner alone is not correlated with this message. It may be
|
||||
# historical or belong to an earlier send; never upgrade on that alone.
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
confirmation_reason="queued-banner-ambiguous"
|
||||
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
|
||||
status="unconfirmed"; break
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED, keep re-capturing until the patience deadline; never
|
||||
# infer delivery, never re-submit blind.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
confirmation_reason="editor-shape-unrecognized"
|
||||
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
|
||||
status="unconfirmed"; break
|
||||
fi
|
||||
# A matching message in the visual input region remains draft/unconfirmed.
|
||||
# Never issue another Enter on this visual evidence.
|
||||
normalized_input=$(printf '%s' "$inputbox" | LC_ALL=C tr -d ' \t\r\n')
|
||||
if grep -qF -- "$snippet" <<<"$normalized_input"; then
|
||||
status="draft"
|
||||
# A visual matching region may be historical. Until current-editor
|
||||
# identity is established, it cannot authorize another submission key.
|
||||
break
|
||||
fi
|
||||
# Clear input alone is insufficient: require newly visible whole-message
|
||||
# evidence relative to the pre-paste baseline.
|
||||
observed_normalized=$(printf '%s' "$pane" | LC_ALL=C tr -d ' \t\r\n')
|
||||
if ! grep -qF -- "$snippet" <<<"$baseline_normalized" &&
|
||||
grep -qF -- "$snippet" <<<"$observed_normalized"; then
|
||||
status="delivered"; break
|
||||
fi
|
||||
if grep -qF -- "$snippet" <<<"$baseline_normalized"; then
|
||||
confirmation_reason="message-present-in-baseline"
|
||||
else
|
||||
confirmation_reason="new-message-not-visible"
|
||||
fi
|
||||
# A static historical editor or a message disappearing without a visible
|
||||
# transcript transition is insufficient. Do not resubmit to manufacture it.
|
||||
if [ "$SECONDS" -lt "$deadline" ]; then sleep 1.0; continue; fi
|
||||
status="unconfirmed"; break
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after the initial submission key; no automatic flush attempted" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: message-correlated editor evidence unavailable within the observation window (reason=$confirmation_reason) — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5).
|
||||
#
|
||||
# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR
|
||||
# #1466's socket resolution, deliberately not from test-send-message-socket.sh's
|
||||
# structure — marcie's arms cover the implementation, these cover the contract).
|
||||
#
|
||||
# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR:
|
||||
# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing
|
||||
# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included)
|
||||
# invisible to the tested process. Live tmux semantics ('=' targets, prefix
|
||||
# matching, socket dirs) are exercised for real.
|
||||
#
|
||||
# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections):
|
||||
# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is
|
||||
# skipped ENTIRELY (zero has-session probes, not merely zero hits)
|
||||
# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only)
|
||||
# C3 session on multiple sockets with no -L/env -> refusal rc 4, message
|
||||
# names the conflicting sockets and the -L hint; nothing sent
|
||||
# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR)
|
||||
# C5 no unique hit -> default socket (sender invoked with no -L);
|
||||
# remote (-H) sends do NO local discovery and do not forward the env
|
||||
# C6 '=name' targets match exactly (no prefix); explicit '=X' passes
|
||||
# through verbatim; compound 'sess:win.pane' pins the session component
|
||||
# exact ('=sess:win.pane')
|
||||
#
|
||||
# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args;
|
||||
# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH
|
||||
# ssh stub captures the remote command line. Sabotage controls prove the
|
||||
# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red.
|
||||
# Skip rc 77 without a tmux binary. Scratch servers killed via trap.
|
||||
set -uo pipefail
|
||||
|
||||
# NOTE: running a COPY of this suite from another directory resolves TOOL next
|
||||
# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set
|
||||
# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design.
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
|
||||
SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b)
|
||||
DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)"
|
||||
mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
# tmux refuses socket dirs with group/other bits ('unsafe permissions'):
|
||||
# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700
|
||||
chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
BIN="$SCRATCH/bin"; mkdir -p "$BIN"
|
||||
CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured"
|
||||
: > "$PROBES"
|
||||
|
||||
# sender stub: capture args, "send" nothing (socket/target choice is the test)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub"
|
||||
# tmux wrapper: log invocations, exec the real binary (live semantics)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux"
|
||||
# ssh stub: capture the remote command line; swallow stdin (the sender script)
|
||||
printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh"
|
||||
chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh"
|
||||
|
||||
sock_pid_a=""; sock_pid_b=""
|
||||
cleanup() {
|
||||
[ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null
|
||||
for s in sockA sockB sockX decoyD; do
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null
|
||||
TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null
|
||||
done
|
||||
rm -rf "$SCRATCH" "$DECOY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY)
|
||||
TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
run() { # passes through; caller sets env per arm
|
||||
PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \
|
||||
bash "$TOOL" -S test:src "$@"
|
||||
}
|
||||
|
||||
probe_count() { grep -c "has-session" "$PROBES" || true; }
|
||||
cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; }
|
||||
|
||||
fail=0
|
||||
ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
|
||||
probes_reset() { : > "$PROBES"; }
|
||||
cap_reset() { rm -f "$CAP"; }
|
||||
|
||||
# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------
|
||||
mk_server sockA t1 "$SCRATCH"
|
||||
mk_server sockB t1 "$SCRATCH"
|
||||
mk_server sockX t1 "$SCRATCH"
|
||||
|
||||
# --- C1: explicit -L beats env; discovery skipped entirely -----------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP"
|
||||
ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $?
|
||||
|
||||
# --- C2: env applies when no -L; discovery skipped -------------------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L envsock"
|
||||
ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C2: env pin skips discovery (0 probes)" $?
|
||||
|
||||
# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing -------
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 4 ]
|
||||
ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $?
|
||||
echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB"
|
||||
ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $?
|
||||
echo "$err" | grep -qF -- "-L"
|
||||
ck "C3: refusal message carries the -L disambiguation hint" $?
|
||||
[ ! -f "$CAP" ]
|
||||
ck "C3: nothing sent on refusal (sender never invoked)" $?
|
||||
|
||||
# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR -------------------------
|
||||
# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH
|
||||
mk_server decoyD onlydecoy "$DECOY"
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1
|
||||
! cap_has "-L decoyD"
|
||||
ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $?
|
||||
# and a session unique in the TMUX_TMPDIR tree IS discovered there
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -ge 2 ]
|
||||
ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $?
|
||||
|
||||
# --- C5: no unique hit -> default socket; remote sends: no local resolution ------
|
||||
# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties
|
||||
for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $?
|
||||
cap_reset; probes_reset
|
||||
rm -f "$SSHLOG"
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C5: remote send does NO local discovery (0 probes with -H)" $?
|
||||
[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG"
|
||||
ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $?
|
||||
|
||||
# --- C6: '=name' exact matching; verbatim '=X'; compound pinning -----------------
|
||||
mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $?
|
||||
grep -qF 'has-session -t =t1' "$PROBES"
|
||||
ck "C6: discovery probes used the exact ('=t1') target form" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old' "$CAP"
|
||||
ck "C6: already-exact '=X' input passes through verbatim" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old:0.0' "$CAP"
|
||||
ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $?
|
||||
|
||||
# --- red controls: the arms bind --------------------------------------------------
|
||||
SAB="$SCRATCH/agent-send-sabotaged.sh"
|
||||
# (a) move the env-default AFTER discovery: C2 must go red
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
'''
|
||||
assert s.count(envblk) == 1
|
||||
s2 = s.replace(envblk, "")
|
||||
anchor = 'socket_args=()'
|
||||
assert s.count(anchor) == 1
|
||||
s2 = s2.replace(anchor, envblk + anchor)
|
||||
assert s2 != s
|
||||
open(dst, "w").write(s2)
|
||||
PY
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi
|
||||
# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then
|
||||
ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0
|
||||
else
|
||||
ck "red-a validity: sabotage was a NO-OP — control invalid" 1
|
||||
fi
|
||||
# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing)
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
old = " exit 4\n"
|
||||
assert s.count(old) == 1
|
||||
s = s.replace(old, " :\n")
|
||||
open(dst, "w").write(s)
|
||||
PY
|
||||
mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$?
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null
|
||||
if [ "$src_rc" -eq 4 ]; then
|
||||
ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1
|
||||
else
|
||||
ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0
|
||||
fi
|
||||
|
||||
# --- verdict -----------------------------------------------------------------------
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)"
|
||||
exit 0
|
||||
fi
|
||||
echo "agent-send socket contract (live): FAILURES above"
|
||||
exit 1
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live tmux semantics on private sockets only. A caller may run this suite from
|
||||
# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for
|
||||
# every bare tmux command. Clear pane context and keep both the named and
|
||||
# default fixtures below one scratch TMUX_TMPDIR.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
|
||||
SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
TEST_TMUX_TMPDIR="$TMPDIR/tmux"
|
||||
mkdir -p "$TEST_TMUX_TMPDIR"
|
||||
chmod 700 "$TEST_TMUX_TMPDIR"
|
||||
unset TMUX TMUX_PANE
|
||||
export TMUX_TMPDIR="$TEST_TMUX_TMPDIR"
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
cleanup() {
|
||||
local test_rc=$? residue=0
|
||||
trap - EXIT
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default kill-server >/dev/null 2>&1 || true
|
||||
sleep 0.2
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: named scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: default scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR"
|
||||
if [ "$test_rc" -ne 0 ]; then
|
||||
exit "$test_rc"
|
||||
fi
|
||||
exit "$residue"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_tmux() {
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
}
|
||||
|
||||
capture_named() {
|
||||
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
|
||||
}
|
||||
|
||||
capture_default() {
|
||||
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
|
||||
}
|
||||
|
||||
require_tmux
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched
|
||||
# send-message.sh against three real tmux-pane fixtures on a throwaway socket:
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
# 6. DELIVERED — synthetic pi-like box with a trailing em dash on its top
|
||||
# (em-dash rule) rule (not an established live rendering): the strict regex
|
||||
# rejected the top rule, leaving a single-rule "box not
|
||||
# locatable" => false UNDELIVERED alarm on every such send.
|
||||
# Post-fix: exit 0 ✓ delivered.
|
||||
# 7. UNCONFIRMED — a pane with no locatable box at all: re-captures must NOT
|
||||
# (no re-Enter) re-send blind Enters. Fixture counts received Enters; after
|
||||
# a send with -r 1 the count must be exactly 1 (the single
|
||||
# submit Enter). Pre-fix it was 2 (Enter before every capture).
|
||||
# 8. PASTE FAIL — both paste attempts fail: abort loud (exit 2, "paste ..."
|
||||
# stderr) BEFORE any Enter, buffer discarded — never a bare
|
||||
# Enter sequence that could read as "delivered" while empty.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; }
|
||||
|
||||
# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1).
|
||||
# readline strips bracketed-paste markers just like a real agent REPL; Enter
|
||||
# executes (text -> scrollback), leaving a fresh empty `❯ ` box.
|
||||
tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the
|
||||
# pasted tail sitting on the ❯ line) => draft => exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \
|
||||
'printf "❯ "; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then
|
||||
no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then
|
||||
ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
history=""
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n%s\n' "$history"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else history+="$line"; buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 6: synthetic pi-like box with a trailing em dash on its top rule.
|
||||
# This does not establish a live rendering cause. Only the top rule differs
|
||||
# from fixture 4's renderer.
|
||||
cat > "$TMP/piboxdash.sh" <<'PIBOXDASH'
|
||||
#!/usr/bin/env bash
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
history=""
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n%s\n' "$history"
|
||||
printf '%s—\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
history+="$line"
|
||||
buf=""
|
||||
draw
|
||||
done
|
||||
PIBOXDASH
|
||||
chmod +x "$TMP/piboxdash.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdash -c "$TMP" "exec bash '$TMP/piboxdash.sh'"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=piboxdash" -m "em dash fixture six delivered ok" 2>"$TMP/e6"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: top rule with trailing em dash => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: top rule with trailing em dash => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e6")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 7: no locatable box anywhere; count Enters the pane receives.
|
||||
# The single submit Enter is expected; re-captures must stay silent.
|
||||
cat > "$TMP/counter.sh" <<'COUNTER'
|
||||
#!/usr/bin/env bash
|
||||
n=0
|
||||
: > "$1"
|
||||
while IFS= read -r _line; do
|
||||
n=$((n + 1))
|
||||
printf '%s' "$n" > "$1"
|
||||
done
|
||||
COUNTER
|
||||
chmod +x "$TMP/counter.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s counter -c "$TMP" "exec bash '$TMP/counter.sh' '$TMP/enters'"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=counter" -r 1 -m "fixture seven unconfirmable" 2>"$TMP/e7"); then
|
||||
no "unconfirmed: unlocatable pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
enters=$(cat "$TMP/enters" 2>/dev/null || echo 0)
|
||||
if [ "$rc" -eq 2 ] && [ "$enters" = "1" ]; then
|
||||
ok "unconfirmed: unlocatable pane => exit 2 with exactly 1 Enter (no blind re-submits)"
|
||||
else
|
||||
no "unconfirmed: unlocatable pane => exit 2 with exactly 1 Enter" "rc=$rc enters=$enters err=[$(cat "$TMP/e7")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 8: paste attempts fail (stubbed tmux refuses paste-buffer, passes
|
||||
# everything else through to the real binary). Must abort BEFORE any Enter:
|
||||
# exit 2, stderr names the paste failure, counter stays at zero.
|
||||
FAKE_BIN8="$TMP/fakebin8"; mkdir -p "$FAKE_BIN8"
|
||||
REAL_TMUX8=$(command -v tmux)
|
||||
cat > "$FAKE_BIN8/tmux" <<TMUX8
|
||||
#!/usr/bin/env bash
|
||||
case " \$* " in
|
||||
*" paste-buffer "*) exit 1 ;; # send-message invokes: tmux -L <sock> paste-buffer ...
|
||||
esac
|
||||
exec "$REAL_TMUX8" "\$@"
|
||||
TMUX8
|
||||
chmod +x "$FAKE_BIN8/tmux"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pastefail -c "$TMP" "exec bash '$TMP/counter.sh' '$TMP/enters8'"
|
||||
sleep 0.3
|
||||
: > "$TMP/enters8"
|
||||
if out=$(PATH="$FAKE_BIN8:$PATH" "$SEND" -L "$SOCKET" -t "=pastefail" -m "fixture eight never pastes" 2>"$TMP/e8"); then
|
||||
no "paste-fail: failed paste must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
enters8=$(cat "$TMP/enters8" 2>/dev/null); enters8=${enters8:-0}
|
||||
if [ "$rc" -eq 2 ] && grep -qF "paste into" "$TMP/e8" && [ "$enters8" = "0" ]; then
|
||||
ok "paste-fail: failed paste => exit 2 loud, zero Enters sent"
|
||||
else
|
||||
no "paste-fail: failed paste => exit 2 loud, zero Enters" "rc=$rc enters8=$enters8 err=[$(cat "$TMP/e8")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Transport failures must not be upgraded by a stale queued banner or prompt.
|
||||
mkdir -p "$TMP/faultbin"
|
||||
cat > "$TMP/faultbin/tmux" <<'FAULTMUX'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$FAULT_LOG"
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null; [ "$FAULT_OP" != load-buffer ]; exit $? ;;
|
||||
*" send-keys "*) [ "$FAULT_OP" != send-keys ]; exit $? ;;
|
||||
*" capture-pane "*) printf 'Press up to edit queued messages\n❯ \n' ;;
|
||||
esac
|
||||
exit 0
|
||||
FAULTMUX
|
||||
chmod +x "$TMP/faultbin/tmux"
|
||||
for op in load-buffer send-keys; do
|
||||
: > "$TMP/fault-log"
|
||||
out=$(PATH="$TMP/faultbin:$PATH" FAULT_LOG="$TMP/fault-log" FAULT_OP="$op" \
|
||||
"$SEND" -L fixture -t '=fault' -m 'transport fault test' 2>"$TMP/fault-err"); rc=$?
|
||||
if [ "$rc" -eq 2 ] && [ -z "$out" ] && [ "$(grep -c capture-pane "$TMP/fault-log")" -eq 1 ]; then
|
||||
ok "$op failure refuses after baseline without post-send confirmation"
|
||||
else
|
||||
no "$op failure must refuse after baseline only" "rc=$rc out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
# Historical success-looking text without a current input must fail closed.
|
||||
for fixture in banner history rules adjacent capture; do
|
||||
mkdir -p "$TMP/historybin"
|
||||
cat > "$TMP/historybin/tmux" <<'HISTORYMUX'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null ;;
|
||||
*" capture-pane "*)
|
||||
if [ "$HISTORY_FIXTURE" = banner ]; then
|
||||
printf 'Press up to edit queued messages\n'
|
||||
elif [ "$HISTORY_FIXTURE" = capture ]; then
|
||||
printf '❯ \n'; exit 1
|
||||
elif [ "$HISTORY_FIXTURE" = adjacent ]; then
|
||||
printf '────────\n────────\n~/fixture (main)\n'
|
||||
elif [ "$HISTORY_FIXTURE" = rules ]; then
|
||||
printf 'historical output\n────────\nold text\n────────\nmore output; no current editor\n'
|
||||
else
|
||||
printf '❯ old prompt\nsubsequent output without an input box\n'
|
||||
fi ;;
|
||||
esac
|
||||
exit 0
|
||||
HISTORYMUX
|
||||
chmod +x "$TMP/historybin/tmux"
|
||||
out=$(PATH="$TMP/historybin:$PATH" HISTORY_FIXTURE="$fixture" \
|
||||
"$SEND" -L fixture -t '=history' -m 'new unrelated message' 2>"$TMP/history-err"); rc=$?
|
||||
if [ "$rc" -eq 2 ] && [ -z "$out" ]; then
|
||||
ok "historical $fixture cannot confirm a new message"
|
||||
else
|
||||
no "historical $fixture must remain unconfirmed" "rc=$rc out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
# Retained Unicode and whitespace-wrapped messages must remain drafts in C locale.
|
||||
mkdir -p "$TMP/unicodebin"
|
||||
cat > "$TMP/unicodebin/tmux" <<'UNICODEMUX'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null ;;
|
||||
*" send-keys "*) printf 'Enter\n' >> "$ENTER_LOG" ;;
|
||||
*" capture-pane "*) printf '────────\n%s\n────────\n~/fixture (main)\n' "$RENDERED_DRAFT" ;;
|
||||
esac
|
||||
exit 0
|
||||
UNICODEMUX
|
||||
chmod +x "$TMP/unicodebin/tmux"
|
||||
for shape in unicode wrapped; do
|
||||
if [ "$shape" = unicode ]; then body='你好世界'; rendered=$'你好\n世界';
|
||||
else body=$'alpha beta\ngamma delta'; rendered=$'alpha\nbeta gamma\ndelta'; fi
|
||||
: > "$TMP/draft-enters"
|
||||
out=$(LC_ALL=C PATH="$TMP/unicodebin:$PATH" RENDERED_DRAFT="$rendered" ENTER_LOG="$TMP/draft-enters" \
|
||||
"$SEND" -L fixture -t '=unicode' -r 0 -m "$body" 2>"$TMP/unicode-err"); rc=$?
|
||||
if [ "$rc" -eq 2 ] && [ -z "$out" ] && [ "$(wc -l < "$TMP/draft-enters")" -eq 1 ]; then
|
||||
ok "retained $shape message refuses with only initial submission key"
|
||||
else
|
||||
no "retained $shape message must not confirm" "rc=$rc out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$TMP/transitionbin"
|
||||
cat > "$TMP/transitionbin/tmux" <<'TRANSITIONMUX'
|
||||
#!/usr/bin/env bash
|
||||
case " $* " in
|
||||
*" load-buffer "*) cat >/dev/null ;;
|
||||
*" capture-pane "*)
|
||||
n=$(cat "$CAPTURE_COUNT"); n=$((n + 1)); printf '%s' "$n" > "$CAPTURE_COUNT"
|
||||
if [ "$TRANSITION" = repeated ] || { [ "$TRANSITION" = new ] && [ "$n" -gt 1 ]; }; then
|
||||
printf 'unique-correlation-message\n'
|
||||
fi
|
||||
printf '────────\n\n────────\n~/fixture (main)\n' ;;
|
||||
esac
|
||||
exit 0
|
||||
TRANSITIONMUX
|
||||
chmod +x "$TMP/transitionbin/tmux"
|
||||
for scenario in static repeated new; do
|
||||
printf '0' > "$TMP/capture-count"
|
||||
expected=2; [ "$scenario" = new ] && expected=0
|
||||
out=$(LC_ALL=C PATH="$TMP/transitionbin:$PATH" TRANSITION="$scenario" CAPTURE_COUNT="$TMP/capture-count" \
|
||||
"$SEND" -L fixture -t '=transition' -m unique-correlation-message 2>"$TMP/transition-err"); rc=$?
|
||||
reason_ok=0
|
||||
case "$scenario" in
|
||||
static) grep -qF 'reason=new-message-not-visible' "$TMP/transition-err" && reason_ok=1 ;;
|
||||
repeated) grep -qF 'reason=message-present-in-baseline' "$TMP/transition-err" && reason_ok=1 ;;
|
||||
new) reason_ok=1 ;;
|
||||
esac
|
||||
if [ "$rc" -eq "$expected" ] && [ "$reason_ok" -eq 1 ]; then
|
||||
ok "$scenario message visibility transition => exit $expected with expected content-free diagnostic"
|
||||
else
|
||||
no "$scenario transition" "rc=$rc expected=$expected out=[$out]"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tmux r3 final-candidate independent review request
|
||||
|
||||
Filbert: review ONLY `docs/plans/reviews/2026-09-07_tmux-r3-export/`, verifying its SHA256SUMS first. Seven files, separate read-only snapshot. Neither r2 nor r3 exports will be modified. Preserve original NOT APPROVED verdict. This new revision supersedes r2 as proposed integration candidate; it is not a resend of an earlier message.
|
||||
|
||||
Author Darkwing; reviewer Filbert. Jason authorized this bounded correction/review/commit-and-push wave. Review in disposable isolated fixtures only. No private-pane inspection, live sends, deployments, source edits or commits. Return exact-hash findings/verdict to `docs/plans/reviews/2026-09-07_tmux-r3-verdict.md`.
|
||||
|
||||
Changes since r2: content-free unconfirmed reason diagnostics and exact diagnostic tests; corrected contradictory source/help comments, removed unused flush counter, and removed unsupported em-dash/redraw claims. Core confirmation requires a successful pre-paste baseline, successful transport and capture, supported clear visual editor, and whole-message visibility newly absent from baseline. No extra Enter is emitted. Queued banners alone and ambiguous layouts remain unconfirmed.
|
||||
|
||||
Coordinator checks: C verdict 20/0 before final wrapper comment edit; UTF-8 verdict 20/0 after; wrapper 19/0; named socket PASS; isolated live socket contract C1–C6 plus both sabotage controls PASS. Logs `/tmp/r3-*.log`. Seven export hashes verified. Fresh ms-test diagnostic live check returned exit 0, separately recorded in 2026-09-07_tmux-diagnostic-live-receipt.md. Earlier Filbert/Dewey live confirmation failed; not retroactively upgraded.
|
||||
|
||||
Please independently challenge current-editor identity, baseline/new-message correlation, hidden editor/forged footer/final prompt controls, Unicode/wrap/repeated-body handling, failed captures with partial stdout, absence of extra Enters, and accurate contract/receipt wording. Visual evidence is not recipient ACK or authenticated runtime state. Reject unsound confirmation semantics; do not approve solely because coordinator fixtures pass.
|
||||
|
||||
First review failed admission because I edited working files. These stable exports resolve that process defect only, not substantive findings by assertion. A reply artifact proves request receipt even if transport confirmation is uncertain. No approval currently claimed.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Tmux R3 — independent verdict: NOT APPROVED
|
||||
|
||||
Reviewer: filbert. Author/coordinator: darkwing. Date: 2026-09-07.
|
||||
Authority: `2026-09-07_tmux-r3-review-request.md`.
|
||||
No competing assignment is known. I did not author or modify the implementation.
|
||||
|
||||
**The R2 confirmation-soundness blocker remains.** R3 adds useful, tested reason
|
||||
diagnostics and corrects several claims, but its success predicate still reports
|
||||
success for a retained hidden draft behind a prompt/footer lookalike. Six independent
|
||||
real-scratch-pane counterexamples reproduce, including the wrapper, in both locales.
|
||||
A successful coordinator live send cannot establish that these negatives are safe.
|
||||
|
||||
## 1. Exact candidate and stable admission
|
||||
|
||||
Reviewed only the frozen `docs/plans/reviews/2026-09-07_tmux-r3-export/` and its
|
||||
verified disposable copy. SHA-256 of export `SHA256SUMS`:
|
||||
`12e30fde5f8a6d35e01f6125e4963792d97f08172291e97b859999a47392d1d9`.
|
||||
|
||||
| File under export `tools/tmux/` | SHA-256 |
|
||||
|---|---|
|
||||
| `agent-send.sh` | `d59b60d601a9076e152830ee769772f7b099aada0d194b6e3844f5fbdcd7f5d0` |
|
||||
| `agent-send.test.sh` | `1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda` |
|
||||
| `README.md` | `86dfbb34321957d025d85441641d272267ecf13604ce22b13968d9561d4a14f8` |
|
||||
| `send-message.sh` | `0efe48969dd52db634b46c99410070811d3be78e2a33a373ab8f1bef128b2703` |
|
||||
| `test-agent-send-socket-live.sh` | `ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607` |
|
||||
| `test-send-message-socket.sh` | `9831d6dbfb8bc87b9dd2cf56cdda57ebef255991c344a767c915b3d2fea070f2` |
|
||||
| `test-send-message-verdict.sh` | `a286cfb1c3bfa4a5a762250f0dbfb66b2b405b4602e739129b201a65e8c189d9` |
|
||||
|
||||
All seven hashes match before copying/execution and afterwards in both export and
|
||||
copy. Final recheck: **2026-09-07T20:15:16Z**. Mutable working-tree source was not
|
||||
used. Earlier exports and verdicts remain untouched.
|
||||
|
||||
Disposable root: `/tmp/filbert-tmux-r3.4cyawppa`; evidence is under `evidence/`.
|
||||
Only disposable script permissions were made executable; candidate bytes and export
|
||||
permissions were preserved. Source/test changes were inspected before execution.
|
||||
|
||||
## 2. Findings and prior dispositions
|
||||
|
||||
### F1 — BLOCKING / high, unchanged: newly visible text is not a cleared current editor
|
||||
|
||||
Locations: `send-message.sh:139–174`, especially final-glyph acceptance at 143–145
|
||||
and rule/footer recognition at 164–169; success predicate at 217–222 and exit 0 at
|
||||
238. The wrapper propagates that status.
|
||||
|
||||
R3 still checks that the normalized complete message was absent from the baseline,
|
||||
is present somewhere in the new capture, and is absent from a region selected by
|
||||
visual resemblance. It does not establish that this region is the current editor.
|
||||
A newly displayed draft preview satisfies the first two conditions while a transcript
|
||||
prompt or footer satisfies the third. Reason diagnostics affect unconfirmed cases;
|
||||
they do not prevent this erroneous success path.
|
||||
|
||||
I reran the independent raw-TTY scratch REPL from R2. It handles bracketed paste and
|
||||
records `pending`, `accepted`, and `submit_keys` separately from the display. Hidden
|
||||
modes retain the complete body after Enter and render its preview above either:
|
||||
|
||||
```
|
||||
Draft preview (NOT submitted): <complete body>
|
||||
────────
|
||||
|
||||
────────
|
||||
~/fixture (main)
|
||||
```
|
||||
|
||||
or a final `> transcript decoration` line. The baseline contains neither the message
|
||||
nor that editor-looking region. These are intentionally hostile synthetic layouts,
|
||||
not claims about an observed live application's rendering.
|
||||
|
||||
Actual results, independently repeated under **LC_ALL=C and LC_ALL=C.UTF-8**:
|
||||
|
||||
| Mode | Tool outcome | Recorded application state |
|
||||
|---|---|---|
|
||||
| Honest acceptance control | exit 0 | accepted count 1, pending empty |
|
||||
| Visible retained-draft control | exit 2 / draft | accepted count 0, entire body retained |
|
||||
| Hidden draft + rule/footer lookalike | **exit 0 / delivered** | **accepted count 0, entire body retained** |
|
||||
| Hidden draft + final glyph lookalike | **exit 0 / delivered** | **accepted count 0, entire body retained** |
|
||||
| Wrapper into hidden rule/footer fixture | **exit 0 / delivered** | **accepted count 0, entire prefixed body retained** |
|
||||
|
||||
Six false positives and four honest controls. Every run receives **exactly one**
|
||||
submission key, including direct sends with `-r 100`. Unicode bodies survive intact.
|
||||
The failure is not merely lack of a model ACK: the editor remains uncleared, and the
|
||||
region treated as cleared is not the editor. The weaker “not ACK” qualification does
|
||||
not resolve that distinction.
|
||||
|
||||
Required disposition: retain unconfirmed status when current-editor/message binding
|
||||
is unsupported; preserve the one-Enter safety property. Provide a bounded, justified
|
||||
runtime boundary and regression controls that distinguish acceptance from previewed
|
||||
retained drafts, or obtain explicit reconciliation of a weaker contract. Passing
|
||||
positive fixtures or another successful live send is not a substitute. Do not
|
||||
reintroduce extra submission keys or simply relabel these counterexamples as success.
|
||||
|
||||
### F2 — partially corrected; remaining contract/evidence wording must be reconciled
|
||||
|
||||
R3 correctly removes the double-Enter recommendation, dead flush counter and several
|
||||
queued-success/flush comments. It now labels the em-dash renderer synthetic, not a
|
||||
measured live cause. Those corrections are credited.
|
||||
|
||||
Remaining contradictions in the frozen bytes:
|
||||
|
||||
- `send-message.sh:128–130` still says anchoring on the last rule pair is what makes
|
||||
it safe, notwithstanding F1 and the later heuristic qualification.
|
||||
- Unchanged `README.md:50` still describes `-r N` as Enter-flush attempts; its later
|
||||
contract and script help say it is compatibility-only with no extra Enter.
|
||||
- `test-send-message-verdict.sh:17–18` and the retained-renderer comment still describe
|
||||
behavior across flush Enters; the introductory “three” fixtures remains stale.
|
||||
|
||||
Do not claim all contradictory wording is closed. Reconcile these statements in the
|
||||
next authorized revision. They do not erase the independent F1 code blocker.
|
||||
|
||||
### F3 — NONBLOCKING test-isolation gap remains
|
||||
|
||||
Unchanged `test-send-message-socket.sh:76,85` writes fixed shared paths
|
||||
`/tmp/send-message-named.out` and `/tmp/agent-send-named.out`. I did not execute this
|
||||
suite under an isolated-only mandate or patch the test to manufacture a pass. Move
|
||||
these outputs under its private per-run scratch in a future revision. The separate
|
||||
C1–C6 scratch socket-contract suite was independently executed and passes.
|
||||
|
||||
## 3. R3 diagnostic and safety verification
|
||||
|
||||
Eleven additional independent fake-transport controls verified:
|
||||
|
||||
- Positive new-message/clear-region control: exit 0, one key.
|
||||
- Repeated baseline body: exit 2, `reason=message-present-in-baseline`.
|
||||
- Clear-looking region without new message: exit 2, `reason=new-message-not-visible`.
|
||||
- Unrecognized editor: exit 2, `reason=editor-shape-unrecognized`.
|
||||
- Queued banner: exit 2, `reason=queued-banner-ambiguous`.
|
||||
- Baseline and post-send capture failures, including success-looking partial stdout:
|
||||
exit 2; no extra key. Baseline failure causes no submission.
|
||||
- Load, paste and submission-key failures: exit 2 at the appropriate stage.
|
||||
- Retained wrapped Unicode: exit 2 / draft, one key.
|
||||
|
||||
The four reason identifiers are fixed and do not echo the test message. Counted
|
||||
submit-key calls are zero or one as appropriate even with `-r 999`. Repeated/common
|
||||
content already visible in the baseline conservatively prevents confirmation; it
|
||||
cannot prove acceptance of a new identical send. These passing controls support the
|
||||
diagnostics and no-extra-Enter improvements, **not** F1's false-positive path.
|
||||
|
||||
## 4. Actual execution and receipts
|
||||
|
||||
Versions: **tmux 3.7c**, **GNU bash 5.3.15(1)-release**.
|
||||
Tests used explicit clean environments: PATH `/usr/bin:/bin`; HOME, TMPDIR and
|
||||
TMUX_TMPDIR beneath the disposable root; no inherited TMUX/TMUX_PANE or Mosaic socket
|
||||
identity. Only reviewer-created scratch panes/sockets were used. The socket-contract
|
||||
suite's SSH is stubbed; no remote host was contacted.
|
||||
|
||||
| Command in disposable copy | Actual result |
|
||||
|---|---|
|
||||
| `bash -n` on all six shell files | exit 0 |
|
||||
| `bash tools/tmux/agent-send.test.sh`, C locale | exit 0; **19/0** |
|
||||
| `bash tools/tmux/test-send-message-verdict.sh`, C locale | exit 0; **20/0**, including updated diagnostic assertions |
|
||||
| Same verdict suite, C.UTF-8 locale | exit 0; **20/0** |
|
||||
| `bash tools/tmux/test-agent-send-socket-live.sh`, C locale | exit 0; C1–C6 and effective sabotage controls pass, private fixtures only |
|
||||
| `python3 evidence/hostile-real.py` | exit 0 verifies six false positives plus four controls; **not** a candidate PASS |
|
||||
| `python3 evidence/fault-probes.py` | exit 0; eleven independent transport/diagnostic controls pass |
|
||||
| `bash tools/tmux/test-send-message-socket.sh` | **NOT RUN**, shared output paths (F3) |
|
||||
|
||||
No remaining responding servers were found in the reviewer-owned socket directory;
|
||||
suite-created scratch servers were cleaned up by their traps. Candidate hashes remain
|
||||
unchanged. No private pane/default user server/live seat was inspected or mutated.
|
||||
|
||||
The request's coordinator live ms-test success is not my independent live measurement.
|
||||
I performed no live verification, inspected no private pane and did not treat that
|
||||
receipt as proof against the demonstrated negatives. Earlier failed live receipts
|
||||
are not upgraded. Ten seconds remains a chosen observation budget, not a proved
|
||||
rendering guarantee.
|
||||
|
||||
Evidence hashes, relative to `/tmp/filbert-tmux-r3.4cyawppa/evidence/`:
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
|---|---|
|
||||
| `suite-results.json` | `878348c276ceabf8e454d31ec0f71ff569cc46a1210dedc6b9185aaaf6ca8076` |
|
||||
| `wrapper.log` | `2df46e6e4285d1955ce93f1b3a51396ea23facb38b3cabea1e53d3dae8d34017` |
|
||||
| `verdict-C.log` and `verdict-UTF8.log` (identical) | `69c3819b6fa362684e4b401c0a6aa9cce95f599451e42343d41f9c4965808abd` |
|
||||
| `socket-contract.log` | `3d794abd5ea59ff4b476f0614a186799141e6863f9fa98f6775d6fb6ad31119c` |
|
||||
| `editor-fixture.py` | `4761eed8f12755380be751304f227e1d3e129e7069dab1e7c5e674c9b77fece0` |
|
||||
| `hostile-real.py` | `08e95356ae96fffe2d64e27005ef103642fe03eeec0f9c50f630beb04d6e6ac2` |
|
||||
| `hostile-receipts.json` | `c463b5c013436a622a46672dfdc7620d31bf55b4b34c897669fbd55a8ab6ee1b` |
|
||||
| `fault-receipts.json` | `52a79d7a2eb62da3563bab4edb357b681de129745d26027f29229d9dec3fe738` |
|
||||
|
||||
Equal R2/R3 hostile receipt hashes reflect identical measured outcomes, not reuse
|
||||
instead of execution: the harness was rerun against the newly verified R3 copy.
|
||||
Scratch is local/disposable evidence; no durable publication is claimed.
|
||||
|
||||
## 5. Return
|
||||
|
||||
**NOT APPROVED at the exact R3 export.** Stable admission and reason diagnostics
|
||||
are verified improvements. Confirmation soundness remains unresolved. Return the
|
||||
findings to darkwing for substantive reconciliation and a newly frozen candidate.
|
||||
No source/export edits, dependency installs, live sends, deployment, commits or push
|
||||
occurred. This verdict is the only repository write. No automatic acceptance,
|
||||
resend, timer, commit/push readiness or subsequent action is inferred.
|
||||
@@ -0,0 +1,11 @@
|
||||
# R5 bounded live transport verification
|
||||
|
||||
One fresh owner-authorized terminal-log test, marker TMUX-TRANSPORT-R5-LIVE-01, sender dragon-lin:darkwing to default socket =dewey. No assignment change, action or reply requested. No replay of earlier uncertain messages.
|
||||
|
||||
Exit 0. Stdout exactly `transport dispatched; application acceptance unknown`. Stderr empty. This verifies checked transport dispatch under Jason's revised contract, NOT application submission, queued status, processing or acknowledgement. No private-pane capture or additional Enter/retry performed by the sender.
|
||||
|
||||
Measured source SHA-256:
|
||||
- agent-send.sh: fd2265d9859402338c3575b46bf65ebed6de0a54ffd99a9475ca00742d10dab4
|
||||
- send-message.sh: 71337c934837466006362556e0bcedffecf5c18415b48e35274842e16e07554a
|
||||
|
||||
All nine working files previously matched frozen R5 in 2026-09-07_tmux-r5-working-identity.json. R5 independent verdict absent at this check; no approval inferred from live success. #1496 tracks the correction. No push or deployment occurred.
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"candidate": "transport-r5",
|
||||
"workingFiles": [
|
||||
{
|
||||
"path": "tools/tmux/agent-send.sh",
|
||||
"expected": "fd2265d9859402338c3575b46bf65ebed6de0a54ffd99a9475ca00742d10dab4",
|
||||
"actual": "fd2265d9859402338c3575b46bf65ebed6de0a54ffd99a9475ca00742d10dab4",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/agent-send.test.sh",
|
||||
"expected": "1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda",
|
||||
"actual": "1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/README.md",
|
||||
"expected": "0b6f1738d6635197289f7d97409a93f8c090d63fc36ef7a744a8a157886ca543",
|
||||
"actual": "0b6f1738d6635197289f7d97409a93f8c090d63fc36ef7a744a8a157886ca543",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/send-message.sh",
|
||||
"expected": "71337c934837466006362556e0bcedffecf5c18415b48e35274842e16e07554a",
|
||||
"actual": "71337c934837466006362556e0bcedffecf5c18415b48e35274842e16e07554a",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/test-agent-send-remote.py",
|
||||
"expected": "39be0f21bfe7a4f690b9c7f2d488a3a7ddffed7bbe197b884e51d3a96b455928",
|
||||
"actual": "39be0f21bfe7a4f690b9c7f2d488a3a7ddffed7bbe197b884e51d3a96b455928",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/test-agent-send-socket-live.sh",
|
||||
"expected": "ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607",
|
||||
"actual": "ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/test-send-message-socket.sh",
|
||||
"expected": "43ea6ae9af0c20e85a70743a7034d3b079a64d8465df2b7e0b95836e701740c1",
|
||||
"actual": "43ea6ae9af0c20e85a70743a7034d3b079a64d8465df2b7e0b95836e701740c1",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/test-send-message-transport.sh",
|
||||
"expected": "f00078442e7a779abc312745c403cdf3796053e583623d4b906f8604935c15fa",
|
||||
"actual": "f00078442e7a779abc312745c403cdf3796053e583623d4b906f8604935c15fa",
|
||||
"matches": true
|
||||
},
|
||||
{
|
||||
"path": "tools/tmux/test-send-message-verdict.sh",
|
||||
"expected": "ca4d940dea2d18d81112606438bbb625681accf5c0b97deb6c374abf5ccccd34",
|
||||
"actual": "ca4d940dea2d18d81112606438bbb625681accf5c0b97deb6c374abf5ccccd34",
|
||||
"matches": true
|
||||
}
|
||||
],
|
||||
"allMatch": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Review request transport receipt
|
||||
|
||||
Sender: dragon-lin:darkwing. Recipient: default socket, =filbert.
|
||||
Request: 2026-09-07_tmux-confirmation-review-request.md; candidate hash file: 2026-09-07_tmux-confirmation-review.sha256.
|
||||
|
||||
One agent-send.sh invocation returned exit 2:
|
||||
`could not confirm submission on =filbert: REPL input box not locatable within the observation window`.
|
||||
|
||||
Delivery is unconfirmed. No acknowledgement or review completion claimed. Do not blindly resend. Expected result artifact: docs/plans/reviews/2026-09-07_tmux-confirmation-verdict.md. Darkwing owns reconciliation of that artifact or a direct reply; no automatic watch registered.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Tmux independent-review disposition
|
||||
|
||||
Filbert's `2026-09-07_tmux-confirmation-verdict.md` is NOT APPROVED / BLOCKED. Darkwing accepts the admission finding: the requested working-tree files changed during review. The original request, hashes, and verdict remain historical evidence, not overwritten pins. Review request receipt was unconfirmed; the returned artifact now proves the request was received and acted upon. No resend needed.
|
||||
|
||||
## Required correction sequence
|
||||
|
||||
1. Fail closed on unsuccessful captures, including success-looking partial stdout; explicitly reject adjacent rules.
|
||||
2. Add Unicode-only, empty suffix, multiline/wrapped draft and historical matching-tail controls. Do not infer submission from an absent or unusable suffix.
|
||||
3. Resolve historical final prompts and forged rule/footer layouts. Visual resemblance alone does not establish current editor identity, nor authorize flush Enters.
|
||||
4. Reconcile all sender/wrapper help, comments, and README. Exit 0 is at most observed editor-state evidence after transport, not acknowledgement. A banner alone remains unconfirmed. Remove dead queued-success claims, unsupported em-dash/redraw claims, and misleading timeout reason.
|
||||
5. Freeze a disposable review export with its own exact manifest after fixes/tests. Do not mutate the export during review. Publish a NEW revision request; preserve old hashes and verdict.
|
||||
|
||||
Live verification and commit/push remain gated. Current 13/0 coordinator suites do not satisfy the hostile controls above or independent approval. No revised candidate has been submitted or independently approved. Registry review is the authorized post-wave next action, not resumed implementation.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Owner ruling — transport-only tmux contract
|
||||
|
||||
Jason selected option `2` after the coordinator explained the independent confirmation-soundness blocker and recommended transport-only results for this bounded wave.
|
||||
|
||||
## Approved revised semantics
|
||||
|
||||
- Exit 0 means tmux accepted the message-buffer load, paste and exactly one Enter command. Output must explicitly say transport dispatched; application acceptance is unknown.
|
||||
- Do not report delivered, queued, acknowledged, processed, or editor cleared based on pane appearance. Remove capture/parser heuristics from the success predicate.
|
||||
- Failed target resolution or transport commands remain nonzero. A partial or failed transport sequence does not justify blindly replaying a message.
|
||||
- No automatic extra Enter, polling of private panes, or runtime receipt integration. Legacy -r remains compatibility-only; verbose output must not expose private transcripts.
|
||||
- Preserve earlier rejected candidates/verdicts. Their false positives remain failures of the earlier confirmation contract; do not reclassify them as successful delivery.
|
||||
|
||||
## Next delivery gates
|
||||
|
||||
Implement the smaller transport-only sender and reconcile wrapper/help/README. Tests must count load/paste/key calls and distinguish zero/partial/completed dispatch from application state. Hidden retained drafts may coexist with successful transport, but the output must always leave application acceptance unknown. Test load, paste and Enter failures, Unicode/multiline payload integrity, socket routing and one-key behavior.
|
||||
|
||||
Freeze a new exact export and request independent review under this explicit revised contract. No approval inferred from Jason choosing scope. Applicable suites, bounded live transport verification and independent approval precede the authorized push of refactor and 17 tags. A9 and #53 records remain part of the wave; Jason retains #53 closure.
|
||||
|
||||
The previous /goal process was reported paused. This owner ruling authorizes the contract correction but does not itself claim that the extension lifecycle was resumed. Do not report to the paused goal unless it is resumed.
|
||||
@@ -0,0 +1,7 @@
|
||||
# R4 review request transport receipt
|
||||
|
||||
Sender dragon-lin:darkwing; target default socket =filbert; one actionable agent-send.sh invocation pointing to 2026-09-07_tmux-transport-r4-review-request.md. Eight export hashes verified before request.
|
||||
|
||||
Exit 0: `transport dispatched; application acceptance unknown`.
|
||||
|
||||
This confirms tmux transport operations only. No reviewer acknowledgement, accepted assignment, completed review or approval inferred. Expected verdict: docs/plans/reviews/2026-09-07_tmux-transport-r4-verdict.md. Darkwing owns reconciliation of reply/artifact; no resend or timer. Export remains immutable.
|
||||
@@ -0,0 +1,8 @@
|
||||
6897560a40e068b20f7bcd1c47a24cf5605cfa8e54a960e70b4e6d4434d1fa87 tools/tmux/agent-send.sh
|
||||
1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda tools/tmux/agent-send.test.sh
|
||||
b383743f9ddefdb9afcb72e1665cc89811c5dc61f2627a0e17dff4d581c24f14 tools/tmux/README.md
|
||||
71337c934837466006362556e0bcedffecf5c18415b48e35274842e16e07554a tools/tmux/send-message.sh
|
||||
ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607 tools/tmux/test-agent-send-socket-live.sh
|
||||
43ea6ae9af0c20e85a70743a7034d3b079a64d8465df2b7e0b95836e701740c1 tools/tmux/test-send-message-socket.sh
|
||||
f00078442e7a779abc312745c403cdf3796053e583623d4b906f8604935c15fa tools/tmux/test-send-message-transport.sh
|
||||
ca4d940dea2d18d81112606438bbb625681accf5c0b97deb6c374abf5ccccd34 tools/tmux/test-send-message-verdict.sh
|
||||
@@ -0,0 +1,118 @@
|
||||
# Inter-Agent tmux Comms — Standard & Tooling
|
||||
|
||||
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||
|
||||
## The addressing standard (required)
|
||||
|
||||
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||
|
||||
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||
|
||||
Example exchange:
|
||||
|
||||
```
|
||||
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||
```
|
||||
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
dispatches transport to local OR remote panes; application acceptance remains unknown.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` transport metadata only · `-r N` compatibility-only, no retries.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
commands do not fall back to tmux's prefix matching behavior.
|
||||
|
||||
## Named socket isolation
|
||||
|
||||
Durable Mosaic fleets should use a dedicated tmux socket, for example:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-fleet ls
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message"
|
||||
```
|
||||
|
||||
This keeps fleet operations away from the user's default tmux server. It is the
|
||||
safe rollout path on hosts that already have manual tmux sessions.
|
||||
|
||||
## Why a helper exists (the submission gotcha)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke. It does not send automatic extra Enters. The legacy `-r` option
|
||||
is accepted for compatibility but no longer authorizes flushes.
|
||||
|
||||
Jason approved the **transport-only contract** after independent review demonstrated
|
||||
that screen layouts cannot prove application acceptance. Exit 0 means tmux accepted
|
||||
buffer load, one paste and one Enter command, not that the application submitted,
|
||||
queued or processed the message. Output explicitly says:
|
||||
`transport dispatched; application acceptance unknown`.
|
||||
|
||||
No capture-pane or editor/footer parser participates in transport success. Hidden
|
||||
retained drafts can coexist with successful transport; they are never called
|
||||
confirmed delivery. Failure exits remain nonzero (1 target resolution, 2 transport
|
||||
failed/partial/uncertain, 3 usage; wrapper 4 ambiguous socket). Failed paste is not
|
||||
retried with another mode. Never blindly replay a partial/uncertain operation.
|
||||
Verbose output is content-free transport metadata. Runtime-bound receipts are
|
||||
separate future work, not implemented by this tool.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
These live in the installed framework copy at
|
||||
`~/.mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||
to each host, so to propagate permanently, land both files in the framework
|
||||
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||
already works against any reachable host because it ships `send-message.sh` over
|
||||
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||
# with the canonical addressing preamble prepended:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||
# or context-wiped agent always knows who sent a message and to whom — no
|
||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||
#
|
||||
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
|
||||
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
|
||||
# field instead of re-deriving intent from the body.
|
||||
#
|
||||
# WHY A WRAPPER
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# checked bracketed paste + one Enter (transport only). For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
|
||||
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
# Default: local hostname, or (remote) resolved via one ssh.
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -C CLASS triage class for a comms daemon. One of:
|
||||
# terminal-log log-only; never needs the agent's attention
|
||||
# actionable carries a decision/blocker/gate — deliver
|
||||
# human from a human operator — deliver
|
||||
# reaction an emoji/ack reaction
|
||||
# digest machine-wake, coalescible; batched wake/heartbeat signal
|
||||
# Long form: --class CLASS (or --class=CLASS). When SET, the
|
||||
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
|
||||
# [<src> -> <dst> class=terminal-log] <message>
|
||||
# When OMITTED, NO token is emitted and the preamble is
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Legacy compatibility option; no automatic extra Enter
|
||||
# -v verbose: transport metadata only, no private pane contents
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 transport dispatched; application acceptance unknown · 1 target not found
|
||||
# 2 transport failed/partial/uncertain · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
||||
# default is the canonical send-message.sh beside this script; production callers
|
||||
# never set AGENT_SEND_SENDER, so behavior is unchanged.
|
||||
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
|
||||
|
||||
# Translate the long option --class[=value] into "-C value" so getopts (which is
|
||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
||||
# so callers that never use --class hit the exact original getopts path.
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
|
||||
args+=(-C "$2"); shift 2 ;;
|
||||
--class=*) args+=(-C "${1#*=}"); shift ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- ${args[@]+"${args[@]}"}
|
||||
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
|
||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
|
||||
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||
C) CLASS=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
# Validate the triage class only when one was given. An absent class emits NO
|
||||
# token (preamble byte-identical to the classic format); the consumer defaults
|
||||
# absent => actionable.
|
||||
CLASS_TOKEN=""
|
||||
if [ -n "$CLASS" ]; then
|
||||
case "$CLASS" in
|
||||
terminal-log|actionable|human|reaction|digest) CLASS_TOKEN=" class=${CLASS}" ;;
|
||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Message body from -f / -m / stdin.
|
||||
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
# Destination host label for the preamble.
|
||||
if [ -z "$DST_HOST" ]; then
|
||||
if [ -n "$SSH_TARGET" ]; then
|
||||
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||
else
|
||||
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||
fi
|
||||
fi
|
||||
|
||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
||||
FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
# Silence BOTH streams: has-session writes nothing to stdout, but a stub
|
||||
# (test fake) may — leaked probe stdout polluted this tool's stdout and
|
||||
# broke callers that read it (measured 2026-09-07, agent-send.test #9b).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" >/dev/null 2>&1 && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
|
||||
#
|
||||
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
|
||||
# base64 payload and prints the FULL message (preamble + body) so we can assert
|
||||
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
|
||||
# the preamble is deterministic across machines.
|
||||
#
|
||||
# Guarantees locked here:
|
||||
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
|
||||
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
|
||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
||||
# 4. -C <c> short form parses identically.
|
||||
# 5. invalid class => exit 3, nothing sent.
|
||||
# 6. --class with no value => exit 3.
|
||||
# 7. the documented consumer regex parses producer output for every class.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
# 11. --class digest is accepted (machine-wake, coalescible canon class).
|
||||
# 12. -C digest short form parses identically.
|
||||
# 13. the documented consumer regex parses producer output for class=digest.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN" "$SCRATCH_TMPDIR"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux — and
|
||||
# TMUX_TMPDIR is pinned to an empty scratch dir so socket discovery never
|
||||
# sees the HOST's sockets (measured 2026-09-07: with default+mosaic-fleet
|
||||
# live, discovery saw the fake answer 'mos' on both and B1-refused rc 4
|
||||
# before the stub ever ran; a one-socket host passed, so this only bites
|
||||
# multi-socket hosts).
|
||||
SCRATCH_TMPDIR=$(mktemp -d)
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX TMUX_TMPDIR="$SCRATCH_TMPDIR" \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$'
|
||||
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
|
||||
|
||||
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
|
||||
got=$(run -s mos -m "hello world")
|
||||
want='[a:src -> dsthost:mos] hello world'
|
||||
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|
||||
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
|
||||
|
||||
# 2. --class space form emits the token.
|
||||
got=$(run -s mos --class terminal-log -m "ACK")
|
||||
want='[a:src -> dsthost:mos class=terminal-log] ACK'
|
||||
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|
||||
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
|
||||
|
||||
# 3. --class=value equals form.
|
||||
got=$(run -s mos --class=actionable -m "decide X")
|
||||
want='[a:src -> dsthost:mos class=actionable] decide X'
|
||||
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|
||||
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 4. -C short form.
|
||||
got=$(run -s mos -C human -m "from a person")
|
||||
want='[a:src -> dsthost:mos class=human] from a person'
|
||||
[ "$got" = "$want" ] && ok "-C human (short form)" \
|
||||
|| no "-C human (short form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 5. invalid class => exit 3, no send.
|
||||
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
|
||||
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|
||||
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
|
||||
fi
|
||||
|
||||
# 6. --class with no value => exit 3.
|
||||
if run -s mos -m "x" --class 2>/dev/null; then
|
||||
no "--class with no value rejected" "expected non-zero exit, got 0"
|
||||
else
|
||||
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
|
||||
fi
|
||||
|
||||
# 11. --class digest (space form) is accepted.
|
||||
got=$(run -s mos --class digest -m "wake payload")
|
||||
want='[a:src -> dsthost:mos class=digest] wake payload'
|
||||
if [ "$got" = "$want" ]; then ok "--class digest emits token"
|
||||
else no "--class digest emits token" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 12. -C digest short form.
|
||||
got=$(run -s mos -C digest -m "coalesced wake")
|
||||
want='[a:src -> dsthost:mos class=digest] coalesced wake'
|
||||
if [ "$got" = "$want" ]; then ok "-C digest (short form)"
|
||||
else no "-C digest (short form)" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 7. consumer grammar parses every class + classic line.
|
||||
for c in terminal-log actionable human reaction digest; do
|
||||
line=$(run -s mos --class "$c" -m "body $c")
|
||||
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
|
||||
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
|
||||
done
|
||||
classic=$(run -s mos -m "plain body")
|
||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — dispatch text through tmux; application acceptance unknown.
|
||||
#
|
||||
# Usage: send-message.sh [-L socket] -t target {-m message|-f file|-b base64}
|
||||
# With no message option, reads stdin. Requires bash, tmux and base64.
|
||||
# -r N is compatibility-only: no automatic retries or extra Enter presses.
|
||||
# -v prints transport metadata only, never a captured private transcript.
|
||||
# Exit 0: tmux accepted buffer load, paste and one Enter command.
|
||||
# Exit 1: target resolution failed. Exit 2: transport failed/partial/uncertain.
|
||||
# Exit 3: invalid usage/input. No exit establishes application acknowledgement.
|
||||
set -uo pipefail
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; VERBOSE=0
|
||||
usage() { printf '%s\n' 'Usage: send-message.sh [-L socket] -t target [-m message|-f file|-b base64] [-r N] [-v]' 'Exit 0 = transport dispatched; application acceptance unknown. No automatic retries.'; exit "${1:-3}"; }
|
||||
while getopts 'L:t:m:f:b:r:vh' o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;; t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;;
|
||||
f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) [[ "$OPTARG" =~ ^[0-9]+$ ]] || usage 3 ;;
|
||||
v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
shift "$((OPTIND - 1))"
|
||||
[ "$#" -eq 0 ] && [ -n "$TARGET" ] || usage 3
|
||||
if [ -n "$B64" ]; then
|
||||
MSG=$(printf '%s' "$B64" | base64 -d) || { echo 'ERROR: invalid base64' >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then
|
||||
MSG=$(cat -- "$FILE") || { echo 'ERROR: cannot read message file' >&2; exit 3; }
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then
|
||||
MSG=$(cat) || exit 3
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo 'ERROR: empty message' >&2; exit 3; }
|
||||
tmux_cmd=(tmux)
|
||||
[ -z "$SOCKET_NAME" ] || tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then EFFECTIVE_TARGET="${TARGET}:0.0"; fi
|
||||
# Pin one pane ID for all subsequent commands rather than resolving a moving
|
||||
# session/window target independently at every transport step.
|
||||
PANE=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" '#{pane_id}' 2>/dev/null) || {
|
||||
echo 'ERROR: tmux target resolution failed' >&2; exit 1;
|
||||
}
|
||||
[[ "$PANE" =~ ^%[0-9]+$ ]] || { echo 'ERROR: invalid resolved pane identity' >&2; exit 1; }
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
cleanup() { "${tmux_cmd[@]}" delete-buffer -b "$BUF" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
|
||||
echo 'ERROR: buffer load failed; transport incomplete' >&2; exit 2
|
||||
fi
|
||||
# Do not retry a failed paste: failure may be partial. Bracketed paste is
|
||||
# requested once; changing paste mode after failure could duplicate effects.
|
||||
if ! "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$PANE"; then
|
||||
echo 'ERROR: paste failed; transport uncertain; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
sleep 0.5
|
||||
if ! "${tmux_cmd[@]}" send-keys -t "$PANE" Enter; then
|
||||
echo 'ERROR: submission key failed; transport partial; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
[ "$VERBOSE" -eq 0 ] || printf 'transport pane=%s; paste_calls=1; submission_keys=1\n' "$PANE"
|
||||
printf '%s\n' 'transport dispatched; application acceptance unknown'
|
||||
exit 0
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5).
|
||||
#
|
||||
# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR
|
||||
# #1466's socket resolution, deliberately not from test-send-message-socket.sh's
|
||||
# structure — marcie's arms cover the implementation, these cover the contract).
|
||||
#
|
||||
# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR:
|
||||
# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing
|
||||
# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included)
|
||||
# invisible to the tested process. Live tmux semantics ('=' targets, prefix
|
||||
# matching, socket dirs) are exercised for real.
|
||||
#
|
||||
# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections):
|
||||
# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is
|
||||
# skipped ENTIRELY (zero has-session probes, not merely zero hits)
|
||||
# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only)
|
||||
# C3 session on multiple sockets with no -L/env -> refusal rc 4, message
|
||||
# names the conflicting sockets and the -L hint; nothing sent
|
||||
# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR)
|
||||
# C5 no unique hit -> default socket (sender invoked with no -L);
|
||||
# remote (-H) sends do NO local discovery and do not forward the env
|
||||
# C6 '=name' targets match exactly (no prefix); explicit '=X' passes
|
||||
# through verbatim; compound 'sess:win.pane' pins the session component
|
||||
# exact ('=sess:win.pane')
|
||||
#
|
||||
# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args;
|
||||
# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH
|
||||
# ssh stub captures the remote command line. Sabotage controls prove the
|
||||
# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red.
|
||||
# Skip rc 77 without a tmux binary. Scratch servers killed via trap.
|
||||
set -uo pipefail
|
||||
|
||||
# NOTE: running a COPY of this suite from another directory resolves TOOL next
|
||||
# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set
|
||||
# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design.
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
|
||||
SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b)
|
||||
DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)"
|
||||
mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
# tmux refuses socket dirs with group/other bits ('unsafe permissions'):
|
||||
# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700
|
||||
chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
BIN="$SCRATCH/bin"; mkdir -p "$BIN"
|
||||
CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured"
|
||||
: > "$PROBES"
|
||||
|
||||
# sender stub: capture args, "send" nothing (socket/target choice is the test)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub"
|
||||
# tmux wrapper: log invocations, exec the real binary (live semantics)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux"
|
||||
# ssh stub: capture the remote command line; swallow stdin (the sender script)
|
||||
printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh"
|
||||
chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh"
|
||||
|
||||
sock_pid_a=""; sock_pid_b=""
|
||||
cleanup() {
|
||||
[ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null
|
||||
for s in sockA sockB sockX decoyD; do
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null
|
||||
TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null
|
||||
done
|
||||
rm -rf "$SCRATCH" "$DECOY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY)
|
||||
TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
run() { # passes through; caller sets env per arm
|
||||
PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \
|
||||
bash "$TOOL" -S test:src "$@"
|
||||
}
|
||||
|
||||
probe_count() { grep -c "has-session" "$PROBES" || true; }
|
||||
cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; }
|
||||
|
||||
fail=0
|
||||
ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
|
||||
probes_reset() { : > "$PROBES"; }
|
||||
cap_reset() { rm -f "$CAP"; }
|
||||
|
||||
# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------
|
||||
mk_server sockA t1 "$SCRATCH"
|
||||
mk_server sockB t1 "$SCRATCH"
|
||||
mk_server sockX t1 "$SCRATCH"
|
||||
|
||||
# --- C1: explicit -L beats env; discovery skipped entirely -----------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP"
|
||||
ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $?
|
||||
|
||||
# --- C2: env applies when no -L; discovery skipped -------------------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L envsock"
|
||||
ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C2: env pin skips discovery (0 probes)" $?
|
||||
|
||||
# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing -------
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 4 ]
|
||||
ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $?
|
||||
echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB"
|
||||
ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $?
|
||||
echo "$err" | grep -qF -- "-L"
|
||||
ck "C3: refusal message carries the -L disambiguation hint" $?
|
||||
[ ! -f "$CAP" ]
|
||||
ck "C3: nothing sent on refusal (sender never invoked)" $?
|
||||
|
||||
# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR -------------------------
|
||||
# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH
|
||||
mk_server decoyD onlydecoy "$DECOY"
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1
|
||||
! cap_has "-L decoyD"
|
||||
ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $?
|
||||
# and a session unique in the TMUX_TMPDIR tree IS discovered there
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -ge 2 ]
|
||||
ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $?
|
||||
|
||||
# --- C5: no unique hit -> default socket; remote sends: no local resolution ------
|
||||
# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties
|
||||
for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $?
|
||||
cap_reset; probes_reset
|
||||
rm -f "$SSHLOG"
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C5: remote send does NO local discovery (0 probes with -H)" $?
|
||||
[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG"
|
||||
ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $?
|
||||
|
||||
# --- C6: '=name' exact matching; verbatim '=X'; compound pinning -----------------
|
||||
mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $?
|
||||
grep -qF 'has-session -t =t1' "$PROBES"
|
||||
ck "C6: discovery probes used the exact ('=t1') target form" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old' "$CAP"
|
||||
ck "C6: already-exact '=X' input passes through verbatim" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old:0.0' "$CAP"
|
||||
ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $?
|
||||
|
||||
# --- red controls: the arms bind --------------------------------------------------
|
||||
SAB="$SCRATCH/agent-send-sabotaged.sh"
|
||||
# (a) move the env-default AFTER discovery: C2 must go red
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
'''
|
||||
assert s.count(envblk) == 1
|
||||
s2 = s.replace(envblk, "")
|
||||
anchor = 'socket_args=()'
|
||||
assert s.count(anchor) == 1
|
||||
s2 = s2.replace(anchor, envblk + anchor)
|
||||
assert s2 != s
|
||||
open(dst, "w").write(s2)
|
||||
PY
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi
|
||||
# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then
|
||||
ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0
|
||||
else
|
||||
ck "red-a validity: sabotage was a NO-OP — control invalid" 1
|
||||
fi
|
||||
# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing)
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
old = " exit 4\n"
|
||||
assert s.count(old) == 1
|
||||
s = s.replace(old, " :\n")
|
||||
open(dst, "w").write(s)
|
||||
PY
|
||||
mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$?
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null
|
||||
if [ "$src_rc" -eq 4 ]; then
|
||||
ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1
|
||||
else
|
||||
ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0
|
||||
fi
|
||||
|
||||
# --- verdict -----------------------------------------------------------------------
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)"
|
||||
exit 0
|
||||
fi
|
||||
echo "agent-send socket contract (live): FAILURES above"
|
||||
exit 1
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live tmux semantics on private sockets only. A caller may run this suite from
|
||||
# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for
|
||||
# every bare tmux command. Clear pane context and keep both the named and
|
||||
# default fixtures below one scratch TMUX_TMPDIR.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
|
||||
SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
TEST_TMUX_TMPDIR="$TMPDIR/tmux"
|
||||
mkdir -p "$TEST_TMUX_TMPDIR"
|
||||
chmod 700 "$TEST_TMUX_TMPDIR"
|
||||
unset TMUX TMUX_PANE
|
||||
export TMUX_TMPDIR="$TEST_TMUX_TMPDIR"
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
cleanup() {
|
||||
local test_rc=$? residue=0
|
||||
trap - EXIT
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default kill-server >/dev/null 2>&1 || true
|
||||
sleep 0.2
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: named scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: default scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR"
|
||||
if [ "$test_rc" -ne 0 ]; then
|
||||
exit "$test_rc"
|
||||
fi
|
||||
exit "$residue"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_tmux() {
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
}
|
||||
|
||||
capture_named() {
|
||||
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
|
||||
}
|
||||
|
||||
capture_default() {
|
||||
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
|
||||
}
|
||||
|
||||
require_tmux
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >"$TMPDIR/send-message-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/send-message-named.out" || fail 'missing transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >"$TMPDIR/agent-send-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/agent-send-named.out" || fail 'wrapper changed transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deterministic transport-only contract tests; no live pane or model access.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir "$TMP/bin"
|
||||
cat > "$TMP/bin/tmux" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
[ "${1:-}" != -L ] || shift 2
|
||||
printf '%s\n' "$1" >> "$CALLS"
|
||||
case "$1" in
|
||||
display-message) [ "$FAIL_OP" != target ] || exit 1; printf '%%7\n' ;;
|
||||
load-buffer) cat > "$PAYLOAD"; [ "$FAIL_OP" != load ] || exit 1 ;;
|
||||
paste-buffer) [ "$FAIL_OP" != paste ] || exit 1 ;;
|
||||
send-keys) [ "$FAIL_OP" != key ] || exit 1 ;;
|
||||
capture-pane) echo 'ERROR: transport must not inspect application display' >&2; exit 99 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod +x "$TMP/bin/tmux"
|
||||
body=$'你好 transport\nsecond line'
|
||||
for mode in none target load paste key; do
|
||||
: > "$TMP/calls"
|
||||
set +e
|
||||
PATH="$TMP/bin:$PATH" CALLS="$TMP/calls" PAYLOAD="$TMP/payload" FAIL_OP="$mode" \
|
||||
bash "$HERE/send-message.sh" -L isolated -t '=fixture' -r 999 -v -m "$body" > "$TMP/out" 2> "$TMP/err"
|
||||
rc=$?
|
||||
set -e
|
||||
expected=2; [ "$mode" != none ] || expected=0; [ "$mode" != target ] || expected=1
|
||||
[ "$rc" -eq "$expected" ]
|
||||
! grep -q capture-pane "$TMP/calls"
|
||||
keys=$(grep -c '^send-keys$' "$TMP/calls" || true)
|
||||
pastes=$(grep -c '^paste-buffer$' "$TMP/calls" || true)
|
||||
[ "$keys" -le 1 ] && [ "$pastes" -le 1 ]
|
||||
if [ "$mode" = none ]; then
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMP/out"
|
||||
[ "$keys" -eq 1 ] && [ "$pastes" -eq 1 ]
|
||||
[ "$(<"$TMP/payload")" = "$body" ]
|
||||
else
|
||||
! grep -q 'transport dispatched' "$TMP/out"
|
||||
fi
|
||||
echo "PASS transport $mode exit=$rc paste=$pastes keys=$keys"
|
||||
done
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compatibility entry point. Screen-verdict semantics were rejected by
|
||||
# independent review and superseded by Jason's explicit transport-only ruling.
|
||||
# Historical fixtures and verdicts remain in frozen r2/r3 review exports.
|
||||
# Do not reinterpret their false confirmations as delivery successes.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec bash "$HERE/test-send-message-transport.sh"
|
||||
@@ -0,0 +1,11 @@
|
||||
# R4 transport-only independent review — Filbert
|
||||
|
||||
Jason explicitly selected option 2, transport-only semantics; see `2026-09-07_tmux-transport-only-owner-ruling.md`. This is a changed contract, not a claim that R2/R3 false delivery confirmations passed. Preserve their NOT APPROVED verdicts.
|
||||
|
||||
Review ONLY frozen `docs/plans/reviews/2026-09-07_tmux-transport-r4-export/`, verifying its SHA256SUMS. Eight files; immutable review export. Canonical checkout /mnt/storage/src/mosaic-stack. Return verdict to `docs/plans/reviews/2026-09-07_tmux-transport-r4-verdict.md`. Reviewer Filbert, author Darkwing; no source edits, deployment, live-seat sends or commits. Use disposable isolated tests.
|
||||
|
||||
Exit 0 means checked tmux buffer load, one paste, one Enter to a pinned pane ID. Output must always leave application acceptance unknown; no screen capture/parser or extra submission key. Nonzero target/transport failures must not claim dispatch. Test retained hidden drafts as transport-only, never as accepted application messages. Probe buffer cleanup, target identity, Unicode/multiline bodies, partial failure, socket routing, -r 999, -v privacy, and caller-facing documentation.
|
||||
|
||||
Coordinator tests pass: transport five cases, wrapper 19/0, named/private socket integration, C1–C6 live scratch socket contract plus sabotage controls. Former screen-verdict entry point now delegates to transport tests, preserving old rejected fixtures in earlier exports. Fixed shared output paths in socket suite. Evidence `/tmp/transport-*.log`; all eight export hashes verified. No independent approval inferred.
|
||||
|
||||
Please request changes for any unsound transport claim, target/cross-socket error, extra effect, or contract contradiction. R4 does not implement a trusted runtime receipt. Jason retains #53 closure; push remains gated on independent approval and applicable verification.
|
||||
@@ -0,0 +1,38 @@
|
||||
# R4 independent verdict
|
||||
|
||||
Reviewer: Filbert. Author: Darkwing.
|
||||
|
||||
Verdict: NOT APPROVED. Request changes.
|
||||
|
||||
Reviewed only the frozen `2026-09-07_tmux-transport-r4-export/` candidate under Jason's transport-only contract. R2/R3 remain NOT APPROVED; their application-delivery failures are not reclassified as successes.
|
||||
|
||||
Manifest SHA-256: `343099f890ad4e3db14ffae7603bf49b9383aa8f40c5123b524361371874026f`. All eight listed hashes passed before and after testing. Tests used a disposable copy, fake commands, and private scratch tmux sockets. No live-seat sends, source edits, deployment, or commits.
|
||||
|
||||
## Blocking finding R4-F1: remote argument injection defeats the transport contract
|
||||
|
||||
The final remote `ssh` invocation in `tools/tmux/agent-send.sh` interpolates `DST_TARGET` and `RETRIES` inside literal single quotes without escaping embedded quotes. Neither input is restricted enough to make that safe. Local validation of `-r` in the shipped sender cannot protect shell parsing that occurs before the sender runs.
|
||||
|
||||
Independent reproductions replaced ssh with a local stub that executes its final command using `bash -c`, with the shipped script still on stdin. Tmux was a recording fake, so no remote connection or real pane was involved.
|
||||
|
||||
- Session argument `x'; printf 'UNAUTHORIZED_EXTRA_EFFECT\n'; #` returned exit 0 and printed the injected marker. There were zero tmux calls and no application-acceptance-unknown output. Exit 0 therefore did not establish even the approved transport sequence.
|
||||
- Retry argument `2'; printf 'UNAUTHORIZED_EXTRA_EFFECT\n'; #` returned exit 0, performed the normal transport calls, then executed the extra command. The compatibility option can cause effects beyond one paste and one Enter.
|
||||
|
||||
This is a transport failure and extra-effect defect, not a demand for application confirmation. README's claim that base64 avoids all shell-quoting hazards does not hold for these non-body arguments.
|
||||
|
||||
Required correction: shell-quote every remote argument independently, validate retry syntax before invoking ssh, and add executable remote-shell regression tests for quote-bearing arguments. A stub that merely records the ssh string misses this defect. Keep the frozen R4 export unchanged; corrections need a new candidate.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
Evidence directory: `/tmp/filbert-r4-CPbHGs/`. Reproducer: `probe.py`; observed results: `probe.log`. Run with `python3 /tmp/filbert-r4-CPbHGs/probe.py`.
|
||||
|
||||
All five supplied entry points independently returned 0, with separate logs named after each script:
|
||||
|
||||
- `test-send-message-transport.sh`, target/load/paste/key failures and success, Unicode/multiline body, `-r 999`, verbose metadata, no capture calls, at most one paste/key.
|
||||
- `agent-send.test.sh`, wrapper grammar and sender identity.
|
||||
- `test-send-message-socket.sh`, real private named/default sockets, concurrent buffers, exact/prefix targets, ambiguity, and routing.
|
||||
- `test-agent-send-socket-live.sh`, C1-C6 and sabotage controls.
|
||||
- `test-send-message-verdict.sh`, compatibility delegation to transport tests, not an additional screen-verdict test.
|
||||
|
||||
The direct sender checks each transport operation, pins the resolved pane ID, attempts named-buffer cleanup on exit, and does not parse a screen or issue retry keys. Its normal success wording correctly leaves application acceptance unknown. No application acknowledgement is inferred. A separate real hidden-draft application fixture, forced cleanup failure, and pane replacement race were not run in this review; the remote injection already blocks approval. Supplied passing suites do not cover the demonstrated remote-shell execution defect.
|
||||
|
||||
Jason retains issue closure and user acceptance. This verdict grants no push or deployment authority.
|
||||
@@ -0,0 +1,5 @@
|
||||
# R5 review request transport receipt
|
||||
|
||||
One revision-specific actionable agent-send.sh invocation from dragon-lin:darkwing to default socket =filbert, pointing to 2026-09-07_tmux-transport-r5-review-request.md and its frozen nine-file export.
|
||||
|
||||
Exit 0: `transport dispatched; application acceptance unknown`. This is NOT reviewer acknowledgement, completed review or approval. No resend. Expected verdict: docs/plans/reviews/2026-09-07_tmux-transport-r5-verdict.md. Darkwing owns reconciliation; exports remain unchanged.
|
||||
@@ -0,0 +1,9 @@
|
||||
fd2265d9859402338c3575b46bf65ebed6de0a54ffd99a9475ca00742d10dab4 tools/tmux/agent-send.sh
|
||||
1916b48df4c0924d4d99892904ff8fef2bed50723ff7d3b0cd4accdd9b903cda tools/tmux/agent-send.test.sh
|
||||
0b6f1738d6635197289f7d97409a93f8c090d63fc36ef7a744a8a157886ca543 tools/tmux/README.md
|
||||
71337c934837466006362556e0bcedffecf5c18415b48e35274842e16e07554a tools/tmux/send-message.sh
|
||||
39be0f21bfe7a4f690b9c7f2d488a3a7ddffed7bbe197b884e51d3a96b455928 tools/tmux/test-agent-send-remote.py
|
||||
ab2b8fd4f8b3e800f887c5552f935178e032582a449e8f1296aadf567498d607 tools/tmux/test-agent-send-socket-live.sh
|
||||
43ea6ae9af0c20e85a70743a7034d3b079a64d8465df2b7e0b95836e701740c1 tools/tmux/test-send-message-socket.sh
|
||||
f00078442e7a779abc312745c403cdf3796053e583623d4b906f8604935c15fa tools/tmux/test-send-message-transport.sh
|
||||
ca4d940dea2d18d81112606438bbb625681accf5c0b97deb6c374abf5ccccd34 tools/tmux/test-send-message-verdict.sh
|
||||
@@ -0,0 +1,121 @@
|
||||
# Inter-Agent tmux Comms — Standard & Tooling
|
||||
|
||||
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||
|
||||
## The addressing standard (required)
|
||||
|
||||
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||
|
||||
```
|
||||
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
```
|
||||
|
||||
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||
|
||||
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||
|
||||
Example exchange:
|
||||
|
||||
```
|
||||
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||
```
|
||||
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
dispatches transport to local OR remote panes; application acceptance remains unknown.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
# From a file / stdin
|
||||
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` transport metadata only · `-r N` compatibility-only, no retries.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
commands do not fall back to tmux's prefix matching behavior.
|
||||
|
||||
## Named socket isolation
|
||||
|
||||
Durable Mosaic fleets should use a dedicated tmux socket, for example:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-fleet ls
|
||||
agent-send.sh -L mosaic-fleet -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-fleet -t '=coder0' -m "raw pane message"
|
||||
```
|
||||
|
||||
This keeps fleet operations away from the user's default tmux server. It is the
|
||||
safe rollout path on hosts that already have manual tmux sessions.
|
||||
|
||||
## Why a helper exists (the submission gotcha)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke. It does not send automatic extra Enters. The legacy `-r` option
|
||||
is accepted for compatibility but no longer authorizes flushes.
|
||||
|
||||
Jason approved the **transport-only contract** after independent review demonstrated
|
||||
that screen layouts cannot prove application acceptance. Exit 0 means tmux accepted
|
||||
buffer load, one paste and one Enter command, not that the application submitted,
|
||||
queued or processed the message. Output explicitly says:
|
||||
`transport dispatched; application acceptance unknown`.
|
||||
|
||||
No capture-pane or editor/footer parser participates in transport success. Hidden
|
||||
retained drafts can coexist with successful transport; they are never called
|
||||
confirmed delivery. Failure exits remain nonzero (1 target resolution, 2 transport
|
||||
failed/partial/uncertain, 3 usage; wrapper 4 ambiguous socket). Failed paste is not
|
||||
retried with another mode. Never blindly replay a partial/uncertain operation.
|
||||
Verbose output is content-free transport metadata. Runtime-bound receipts are
|
||||
separate future work, not implemented by this tool.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`). Every remote command argument is
|
||||
independently POSIX-shell quoted; retry syntax is validated before invoking SSH.
|
||||
The executing-shell regression suite checks quote-bearing target/socket arguments
|
||||
and rejects an invalid retry before any SSH call.
|
||||
|
||||
## Files
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
|
||||
drafts on a coordinator pane (default target `mos-claude`); run it as a
|
||||
long-lived process alongside the coordinator session.
|
||||
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
These live in the installed framework copy at
|
||||
`~/.mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||
to each host, so to propagate permanently, land both files in the framework
|
||||
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||
already works against any reachable host because it ships `send-message.sh` over
|
||||
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||
# with the canonical addressing preamble prepended:
|
||||
#
|
||||
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||
#
|
||||
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||
# or context-wiped agent always knows who sent a message and to whom — no
|
||||
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||
#
|
||||
# Optionally tags the message with a TRIAGE CLASS (see -C / --class) so a
|
||||
# comms daemon can route it (deliver-to-agent vs log-and-drop) from an exact
|
||||
# field instead of re-deriving intent from the body.
|
||||
#
|
||||
# WHY A WRAPPER
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# checked bracketed paste + one Enter (transport only). For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# agent-send.sh -s mos-claude --class terminal-log -m "ACK — received"
|
||||
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
#
|
||||
# Exit 4: local target session exists on multiple socket servers and no
|
||||
# -L / MOSAIC_TMUX_SOCKET disambiguated it (B1 stale-twin guard).
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
# Default: local hostname, or (remote) resolved via one ssh.
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -C CLASS triage class for a comms daemon. One of:
|
||||
# terminal-log log-only; never needs the agent's attention
|
||||
# actionable carries a decision/blocker/gate — deliver
|
||||
# human from a human operator — deliver
|
||||
# reaction an emoji/ack reaction
|
||||
# digest machine-wake, coalescible; batched wake/heartbeat signal
|
||||
# Long form: --class CLASS (or --class=CLASS). When SET, the
|
||||
# preamble carries a ` class=<CLASS>` token INSIDE the bracket:
|
||||
# [<src> -> <dst> class=terminal-log] <message>
|
||||
# When OMITTED, NO token is emitted and the preamble is
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Legacy compatibility option; no automatic extra Enter
|
||||
# -v verbose: transport metadata only, no private pane contents
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
# ^\[(\S+) -> (\S+?)(?: class=(terminal-log|actionable|human|reaction|digest))?\] (.*)$
|
||||
# group 1 = src label group 2 = dst host:session
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 transport dispatched; application acceptance unknown · 1 target not found
|
||||
# 2 transport failed/partial/uncertain · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
# Sender is overridable via env purely for testing (inject a capture stub). The
|
||||
# default is the canonical send-message.sh beside this script; production callers
|
||||
# never set AGENT_SEND_SENDER, so behavior is unchanged.
|
||||
SENDER="${AGENT_SEND_SENDER:-$SELF_DIR/send-message.sh}"
|
||||
|
||||
# Translate the long option --class[=value] into "-C value" so getopts (which is
|
||||
# short-option-only) can parse it. Every other argument passes through untouched,
|
||||
# so callers that never use --class hit the exact original getopts path.
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--class) [ $# -ge 2 ] || { echo "ERROR: --class requires a value" >&2; exit 3; }
|
||||
args+=(-C "$2"); shift 2 ;;
|
||||
--class=*) args+=(-C "${1#*=}"); shift ;;
|
||||
*) args+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- ${args[@]+"${args[@]}"}
|
||||
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
|
||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0; CLASS=""
|
||||
usage() { sed -n '2,/^set -uo pipefail/{/^set -uo pipefail/d;p}' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||
C) CLASS=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$RETRIES" =~ ^[0-9]+$ ]] || { echo 'ERROR: -r requires a nonnegative integer' >&2; exit 3; }
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
# Validate the triage class only when one was given. An absent class emits NO
|
||||
# token (preamble byte-identical to the classic format); the consumer defaults
|
||||
# absent => actionable.
|
||||
CLASS_TOKEN=""
|
||||
if [ -n "$CLASS" ]; then
|
||||
case "$CLASS" in
|
||||
terminal-log|actionable|human|reaction|digest) CLASS_TOKEN=" class=${CLASS}" ;;
|
||||
*) echo "ERROR: invalid --class '$CLASS' (allowed: terminal-log, actionable, human, reaction, digest)" >&2; exit 3 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Message body from -f / -m / stdin.
|
||||
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=${MOSAIC_AGENT_NAME:-}
|
||||
if [ -z "$src_sess" ]; then
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# Inside tmux: display-message resolves against this client's own session.
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
else
|
||||
# Outside tmux with no name: display-message reports the LAST-ACTIVE
|
||||
# session — someone else's identity (measured 2026-08-20: a nameless
|
||||
# non-tmux sender was stamped "peggy", a live seat, forged silently).
|
||||
# Stamp an explicit unverified label instead; deliberate senders use -S.
|
||||
src_sess="unverified"
|
||||
fi
|
||||
fi
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
# Destination host label for the preamble.
|
||||
if [ -z "$DST_HOST" ]; then
|
||||
if [ -n "$SSH_TARGET" ]; then
|
||||
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||
else
|
||||
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||
fi
|
||||
fi
|
||||
|
||||
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}${CLASS_TOKEN}]"
|
||||
FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
|
||||
# Exact session matching for the sender target (codex PR #1466): without
|
||||
# '=', tmux target syntax accepts an unambiguous PREFIX, so a delivery
|
||||
# aimed at session X can land in X-old. Compound targets (session:win.pane)
|
||||
# and already-exact ('=...') forms pass through untouched. Computed BEFORE
|
||||
# socket discovery so the discovery probes use the same target semantics
|
||||
# (probing '==name' for an already-exact input was a false-negative hit).
|
||||
DST_TARGET="$DST_SESSION"
|
||||
case "$DST_SESSION" in
|
||||
=*) ;;
|
||||
*:*)
|
||||
# Compound target (session:win.pane): pin the SESSION component exact
|
||||
# (=session:win.pane); unpinned, the session part still prefix-matches
|
||||
# (codex PR #1466: 'agent:0.0' can resolve into 'agent-old').
|
||||
DST_TARGET="=${DST_SESSION%%:*}:${DST_SESSION#*:}"
|
||||
;;
|
||||
*) DST_TARGET="=$DST_SESSION" ;;
|
||||
esac
|
||||
|
||||
# Socket default resolution (B1, 2026-08-29). Precedence: explicit -L >
|
||||
# launcher-exported MOSAIC_TMUX_SOCKET > unique socket hit > refusal on
|
||||
# ambiguity > tmux default socket. The ambiguity refusal fires ONLY when
|
||||
# no explicit or env choice exists and the session name lives on multiple
|
||||
# servers (measured 2026-08-28/29: tasking sends landed in a stale
|
||||
# default-socket twin; rc 0 reported honest delivery to the wrong pane).
|
||||
# Socket discovery scans tmux's own socket dir, ${TMUX_TMPDIR:-/tmp}/tmux-UID
|
||||
# (codex PR #1466: TMPDIR is not where tmux keeps -L sockets).
|
||||
# MOSAIC_TMUX_SOCKET is LOCAL-host state (launcher-exported): it must not
|
||||
# leak into remote sends, where -L would target a socket on the remote
|
||||
# host (codex PR #1466).
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
socket_dir="${TMUX_TMPDIR:-/tmp}/tmux-$(id -u)"
|
||||
hits=""
|
||||
for sf in "$socket_dir"/*; do
|
||||
[ -S "$sf" ] || continue
|
||||
sname="${sf##*/}"
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
# Silence BOTH streams: has-session writes nothing to stdout, but a stub
|
||||
# (test fake) may — leaked probe stdout polluted this tool's stdout and
|
||||
# broke callers that read it (measured 2026-09-07, agent-send.test #9b).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" >/dev/null 2>&1 && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
echo "agent-send.sh: REFUSING - session '$DST_SESSION' exists on multiple sockets:" >&2
|
||||
printf ' %s\n' $hits >&2
|
||||
echo " Pass -L <socket> explicitly (or export MOSAIC_TMUX_SOCKET to disambiguate)." >&2
|
||||
exit 4
|
||||
elif [ "$hit_count" -eq 1 ]; then
|
||||
SOCKET_NAME="$(printf '%s' "$hits")"
|
||||
fi
|
||||
fi
|
||||
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
# SSH passes a command string through the remote login shell. Quote EACH
|
||||
# argument with POSIX single-quote escaping before that shell parses it.
|
||||
remote_args=(bash -s -- "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES")
|
||||
[ "$VERBOSE" = 0 ] || remote_args+=(-v)
|
||||
remote_command=""
|
||||
for arg in "${remote_args[@]}"; do
|
||||
escaped=${arg//\'/\'\\\'\'}
|
||||
remote_command+=" '$escaped'"
|
||||
done
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" "$remote_command" < "$SENDER"
|
||||
fi
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
# agent-send.test.sh — regression + grammar lock for agent-send.sh --class.
|
||||
#
|
||||
# Strategy: inject a capture stub via AGENT_SEND_SENDER that decodes the -b
|
||||
# base64 payload and prints the FULL message (preamble + body) so we can assert
|
||||
# the exact bytes on the wire. Local path only (no ssh), -n pins the dst host so
|
||||
# the preamble is deterministic across machines.
|
||||
#
|
||||
# Guarantees locked here:
|
||||
# 1. REGRESSION BAR — no --class => preamble byte-for-byte identical to classic.
|
||||
# 2. --class <c> => ` class=<c>` token emitted inside the bracket.
|
||||
# 3. --class=<c> (equals form) parses identically to the space form.
|
||||
# 4. -C <c> short form parses identically.
|
||||
# 5. invalid class => exit 3, nothing sent.
|
||||
# 6. --class with no value => exit 3.
|
||||
# 7. the documented consumer regex parses producer output for every class.
|
||||
# 8. MOSAIC_AGENT_NAME is authoritative for sender identity.
|
||||
# 9. sender fallback queries local tmux, never the destination -L socket.
|
||||
# 10. an undeterminable sender is stamped as "?".
|
||||
# 11. --class digest is accepted (machine-wake, coalescible canon class).
|
||||
# 12. -C digest short form parses identically.
|
||||
# 13. the documented consumer regex parses producer output for class=digest.
|
||||
set -uo pipefail
|
||||
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TOOL="$HERE/agent-send.sh"
|
||||
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN" "$SCRATCH_TMPDIR"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
b64=""
|
||||
while getopts "L:t:b:r:v" o; do case "$o" in b) b64=$OPTARG ;; *) : ;; esac; done
|
||||
printf '%s' "$b64" | base64 -d
|
||||
STUB_EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# Fake tmux distinguishes the sender's default socket from a destination socket.
|
||||
cat >"$FAKE_BIN/tmux" <<'TMUX_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
case "${FAKE_TMUX_MODE:-sessions}" in
|
||||
unavailable) exit 1 ;;
|
||||
sessions)
|
||||
if [ "${1:-}" = "-L" ]; then
|
||||
printf '%s\n' 'destination-holder'
|
||||
else
|
||||
printf '%s\n' 'local-agent'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
TMUX_EOF
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf 'ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux — and
|
||||
# TMUX_TMPDIR is pinned to an empty scratch dir so socket discovery never
|
||||
# sees the HOST's sockets (measured 2026-09-07: with default+mosaic-fleet
|
||||
# live, discovery saw the fake answer 'mos' on both and B1-refused rc 4
|
||||
# before the stub ever ran; a one-socket host passed, so this only bites
|
||||
# multi-socket hosts).
|
||||
SCRATCH_TMPDIR=$(mktemp -d)
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX TMUX_TMPDIR="$SCRATCH_TMPDIR" \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
|
||||
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
# Documented consumer grammar — the daemon will mirror exactly this.
|
||||
GRAMMAR='^\[(\S+) -> (\S+) class=(terminal-log|actionable|human|reaction|digest)\] (.*)$'
|
||||
GRAMMAR_NOCLASS='^\[(\S+) -> (\S+)\] (.*)$'
|
||||
|
||||
# 1. REGRESSION BAR: classic preamble, byte-for-byte.
|
||||
got=$(run -s mos -m "hello world")
|
||||
want='[a:src -> dsthost:mos] hello world'
|
||||
[ "$got" = "$want" ] && ok "regression: no --class is byte-identical" \
|
||||
|| no "regression: no --class is byte-identical" "got=[$got] want=[$want]"
|
||||
|
||||
# 2. --class space form emits the token.
|
||||
got=$(run -s mos --class terminal-log -m "ACK")
|
||||
want='[a:src -> dsthost:mos class=terminal-log] ACK'
|
||||
[ "$got" = "$want" ] && ok "--class terminal-log emits token" \
|
||||
|| no "--class terminal-log emits token" "got=[$got] want=[$want]"
|
||||
|
||||
# 3. --class=value equals form.
|
||||
got=$(run -s mos --class=actionable -m "decide X")
|
||||
want='[a:src -> dsthost:mos class=actionable] decide X'
|
||||
[ "$got" = "$want" ] && ok "--class=actionable (equals form)" \
|
||||
|| no "--class=actionable (equals form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 4. -C short form.
|
||||
got=$(run -s mos -C human -m "from a person")
|
||||
want='[a:src -> dsthost:mos class=human] from a person'
|
||||
[ "$got" = "$want" ] && ok "-C human (short form)" \
|
||||
|| no "-C human (short form)" "got=[$got] want=[$want]"
|
||||
|
||||
# 5. invalid class => exit 3, no send.
|
||||
if out=$(run -s mos --class bogus -m "x" 2>/dev/null); then
|
||||
no "invalid class rejected" "expected non-zero exit, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
[ "$rc" = 3 ] && [ -z "$out" ] && ok "invalid class => exit 3, nothing sent" \
|
||||
|| no "invalid class => exit 3, nothing sent" "rc=$rc out=[$out]"
|
||||
fi
|
||||
|
||||
# 6. --class with no value => exit 3.
|
||||
if run -s mos -m "x" --class 2>/dev/null; then
|
||||
no "--class with no value rejected" "expected non-zero exit, got 0"
|
||||
else
|
||||
[ "$?" = 3 ] && ok "--class with no value => exit 3" || no "--class with no value => exit 3" "wrong rc"
|
||||
fi
|
||||
|
||||
# 11. --class digest (space form) is accepted.
|
||||
got=$(run -s mos --class digest -m "wake payload")
|
||||
want='[a:src -> dsthost:mos class=digest] wake payload'
|
||||
if [ "$got" = "$want" ]; then ok "--class digest emits token"
|
||||
else no "--class digest emits token" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 12. -C digest short form.
|
||||
got=$(run -s mos -C digest -m "coalesced wake")
|
||||
want='[a:src -> dsthost:mos class=digest] coalesced wake'
|
||||
if [ "$got" = "$want" ]; then ok "-C digest (short form)"
|
||||
else no "-C digest (short form)" "got=[$got] want=[$want]"
|
||||
fi
|
||||
|
||||
# 7. consumer grammar parses every class + classic line.
|
||||
for c in terminal-log actionable human reaction digest; do
|
||||
line=$(run -s mos --class "$c" -m "body $c")
|
||||
[[ "$line" =~ $GRAMMAR ]] && [ "${BASH_REMATCH[3]}" = "$c" ] && [ "${BASH_REMATCH[4]}" = "body $c" ] \
|
||||
&& ok "grammar parses class=$c" || no "grammar parses class=$c" "line=[$line]"
|
||||
done
|
||||
classic=$(run -s mos -m "plain body")
|
||||
[[ "$classic" =~ $GRAMMAR_NOCLASS ]] && [ "${BASH_REMATCH[3]}" = "plain body" ] \
|
||||
&& ok "grammar (no-class) parses classic line" || no "grammar (no-class) parses classic line" "line=[$classic]"
|
||||
|
||||
# 8. Exported pane identity wins even when dispatch targets another tmux socket.
|
||||
src_host=$(hostname -s)
|
||||
got=$(MOSAIC_AGENT_NAME=authoritative-agent FAKE_TMUX_MODE=sessions \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -L destination-socket -n dsthost -s mos -m "env identity")
|
||||
want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|
||||
[ "$got" = "$want" ] && ok "MOSAIC_AGENT_NAME is authoritative across sockets" \
|
||||
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
|
||||
|
||||
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
|
||||
# Sender is INSIDE tmux: the only context where display-message self-lookup
|
||||
# is safe (it resolves against this client's own session).
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
|
||||
want="[$src_host:local-agent -> dsthost:mos] local fallback"
|
||||
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|
||||
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
|
||||
[[ "$got" != *":destination-holder ->"* ]] \
|
||||
&& ok "cross-socket fallback rejects destination holder identity" \
|
||||
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
|
||||
|
||||
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
|
||||
# someone else's identity (forgery vector). The label must be `unverified`,
|
||||
# never a borrowed name, even though a tmux server exists here and the fake
|
||||
# would confidently answer `local-agent`.
|
||||
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
|
||||
want="[$src_host:unverified -> dsthost:mos] no tmux context"
|
||||
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|
||||
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
|
||||
|
||||
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
|
||||
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
|
||||
want="[$src_host:? -> dsthost:mos] unknown fallback"
|
||||
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|
||||
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — dispatch text through tmux; application acceptance unknown.
|
||||
#
|
||||
# Usage: send-message.sh [-L socket] -t target {-m message|-f file|-b base64}
|
||||
# With no message option, reads stdin. Requires bash, tmux and base64.
|
||||
# -r N is compatibility-only: no automatic retries or extra Enter presses.
|
||||
# -v prints transport metadata only, never a captured private transcript.
|
||||
# Exit 0: tmux accepted buffer load, paste and one Enter command.
|
||||
# Exit 1: target resolution failed. Exit 2: transport failed/partial/uncertain.
|
||||
# Exit 3: invalid usage/input. No exit establishes application acknowledgement.
|
||||
set -uo pipefail
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; VERBOSE=0
|
||||
usage() { printf '%s\n' 'Usage: send-message.sh [-L socket] -t target [-m message|-f file|-b base64] [-r N] [-v]' 'Exit 0 = transport dispatched; application acceptance unknown. No automatic retries.'; exit "${1:-3}"; }
|
||||
while getopts 'L:t:m:f:b:r:vh' o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;; t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;;
|
||||
f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) [[ "$OPTARG" =~ ^[0-9]+$ ]] || usage 3 ;;
|
||||
v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
shift "$((OPTIND - 1))"
|
||||
[ "$#" -eq 0 ] && [ -n "$TARGET" ] || usage 3
|
||||
if [ -n "$B64" ]; then
|
||||
MSG=$(printf '%s' "$B64" | base64 -d) || { echo 'ERROR: invalid base64' >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then
|
||||
MSG=$(cat -- "$FILE") || { echo 'ERROR: cannot read message file' >&2; exit 3; }
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then
|
||||
MSG=$(cat) || exit 3
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo 'ERROR: empty message' >&2; exit 3; }
|
||||
tmux_cmd=(tmux)
|
||||
[ -z "$SOCKET_NAME" ] || tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then EFFECTIVE_TARGET="${TARGET}:0.0"; fi
|
||||
# Pin one pane ID for all subsequent commands rather than resolving a moving
|
||||
# session/window target independently at every transport step.
|
||||
PANE=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" '#{pane_id}' 2>/dev/null) || {
|
||||
echo 'ERROR: tmux target resolution failed' >&2; exit 1;
|
||||
}
|
||||
[[ "$PANE" =~ ^%[0-9]+$ ]] || { echo 'ERROR: invalid resolved pane identity' >&2; exit 1; }
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
cleanup() { "${tmux_cmd[@]}" delete-buffer -b "$BUF" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
|
||||
echo 'ERROR: buffer load failed; transport incomplete' >&2; exit 2
|
||||
fi
|
||||
# Do not retry a failed paste: failure may be partial. Bracketed paste is
|
||||
# requested once; changing paste mode after failure could duplicate effects.
|
||||
if ! "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$PANE"; then
|
||||
echo 'ERROR: paste failed; transport uncertain; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
sleep 0.5
|
||||
if ! "${tmux_cmd[@]}" send-keys -t "$PANE" Enter; then
|
||||
echo 'ERROR: submission key failed; transport partial; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
[ "$VERBOSE" -eq 0 ] || printf 'transport pane=%s; paste_calls=1; submission_keys=1\n' "$PANE"
|
||||
printf '%s\n' 'transport dispatched; application acceptance unknown'
|
||||
exit 0
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the SSH command through a shell; no network or real tmux access."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
here = Path(__file__).resolve().parent
|
||||
with tempfile.TemporaryDirectory(prefix="tmux-remote-contract-") as temp:
|
||||
root = Path(temp)
|
||||
bindir = root / "bin"
|
||||
bindir.mkdir()
|
||||
(bindir / "ssh").write_text("#!/usr/bin/env python3\nimport os,subprocess,sys\nopen(os.environ['SSH_CALLS'],'a').write('ssh\\n')\nraise SystemExit(subprocess.call(['/bin/sh','-c',sys.argv[-1]]))\n")
|
||||
(bindir / "tmux").write_text("#!/usr/bin/env python3\nimport json,os,sys\na=sys.argv[1:]\nwith open(os.environ['CALLS'],'a') as f: f.write(json.dumps(a)+'\\n')\nif a and a[0]=='-L': a=a[2:]\nif a[0]=='display-message': print('%7')\nif a[0]=='load-buffer': open(os.environ['PAYLOAD'],'wb').write(sys.stdin.buffer.read())\n")
|
||||
for path in bindir.iterdir():
|
||||
path.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(bindir) + ':' + os.environ['PATH'], CALLS=str(root/'calls'), SSH_CALLS=str(root/'ssh-calls'), PAYLOAD=str(root/'payload'))
|
||||
env.pop('AGENT_SEND_SENDER', None)
|
||||
attack = "x'; printf 'UNAUTHORIZED_EXTRA_EFFECT\\n'; #"
|
||||
cases = [('target', ['-s', attack], 0), ('socket', ['-s', 'fixture', '-L', attack], 0), ('retry', ['-s', 'fixture', '-r', attack], 3)]
|
||||
for label, args, expected in cases:
|
||||
(root/'calls').write_text('')
|
||||
(root/'ssh-calls').write_text('')
|
||||
result = subprocess.run(['bash', str(here/'agent-send.sh'), '-S', 'review:src', '-n', 'fake', '-H', 'fake', '-m', 'safe\n你好'] + args, env=env, text=True, capture_output=True)
|
||||
assert result.returncode == expected, (label, result)
|
||||
assert 'UNAUTHORIZED_EXTRA_EFFECT' not in result.stdout, (label, result.stdout)
|
||||
calls = [json.loads(line) for line in (root/'calls').read_text().splitlines()]
|
||||
if expected == 3:
|
||||
assert not calls and not (root/'ssh-calls').read_text()
|
||||
else:
|
||||
assert result.stdout.strip() == 'transport dispatched; application acceptance unknown'
|
||||
command_calls = [a[2:] if a[0] == '-L' else a for a in calls]
|
||||
assert sum(a[0] == 'paste-buffer' for a in command_calls) == 1
|
||||
assert sum(a[0] == 'send-keys' for a in command_calls) == 1
|
||||
assert b'safe\n' in (root/'payload').read_bytes()
|
||||
if label == 'socket': assert all(a[:2] == ['-L', attack] for a in calls)
|
||||
if label == 'target': assert command_calls[0][3] == '=' + attack + ':0.0'
|
||||
print('PASS remote', label)
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-agent-send-socket-live.sh — S2 v2 INDEPENDENT contract validation (P5).
|
||||
#
|
||||
# Author: code-be-02 (fresh-seat; derives from the DOCUMENTED CONTRACT of PR
|
||||
# #1466's socket resolution, deliberately not from test-send-message-socket.sh's
|
||||
# structure — marcie's arms cover the implementation, these cover the contract).
|
||||
#
|
||||
# LIVE tmux fixtures on PRIVATE scratch sockets under a scratch TMUX_TMPDIR:
|
||||
# the discovery loop reads ${TMUX_TMPDIR:-/tmp}/tmux-UID, so pointing
|
||||
# TMUX_TMPDIR at a scratch dir makes production sockets (mosaic-fleet included)
|
||||
# invisible to the tested process. Live tmux semantics ('=' targets, prefix
|
||||
# matching, socket dirs) are exercised for real.
|
||||
#
|
||||
# Contract under test (agent-send.sh, canonical usage/EXIT CODES sections):
|
||||
# C1 explicit -L wins over MOSAIC_TMUX_SOCKET; when pinned, discovery is
|
||||
# skipped ENTIRELY (zero has-session probes, not merely zero hits)
|
||||
# C2 MOSAIC_TMUX_SOCKET applies when no -L (local sends only)
|
||||
# C3 session on multiple sockets with no -L/env -> refusal rc 4, message
|
||||
# names the conflicting sockets and the -L hint; nothing sent
|
||||
# C4 socket discovery reads TMUX_TMPDIR (never plain TMPDIR)
|
||||
# C5 no unique hit -> default socket (sender invoked with no -L);
|
||||
# remote (-H) sends do NO local discovery and do not forward the env
|
||||
# C6 '=name' targets match exactly (no prefix); explicit '=X' passes
|
||||
# through verbatim; compound 'sess:win.pane' pins the session component
|
||||
# exact ('=sess:win.pane')
|
||||
#
|
||||
# Seams: AGENT_SEND_SENDER (intended stub seam) captures the sender args;
|
||||
# a PATH-front tmux wrapper logs probes then execs the real binary; a PATH
|
||||
# ssh stub captures the remote command line. Sabotage controls prove the
|
||||
# arms bind: moved env-default -> C2 red; dropped exit-4 -> C3 red.
|
||||
# Skip rc 77 without a tmux binary. Scratch servers killed via trap.
|
||||
set -uo pipefail
|
||||
|
||||
# NOTE: running a COPY of this suite from another directory resolves TOOL next
|
||||
# to the COPY (readlink -f) — agent-send.sh must sit beside it, or set
|
||||
# AGENT_SEND_TOOL_OVERRIDE. Debugging artifact of the here-relative design.
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
TOOL="${AGENT_SEND_TOOL_OVERRIDE:-$HERE/agent-send.sh}"
|
||||
REAL_TMUX="$(command -v tmux 2>/dev/null || true)"
|
||||
[ -n "$REAL_TMUX" ] || { echo "SKIP: no tmux binary (live fixtures impossible)"; exit 77; }
|
||||
|
||||
SCRATCH="$(mktemp -d)"; SCRATCH="$(cd "$SCRATCH" && pwd)" # absolute (marcie input b)
|
||||
DECOY="$(mktemp -d)"; DECOY="$(cd "$DECOY" && pwd)"
|
||||
mkdir -p "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
# tmux refuses socket dirs with group/other bits ('unsafe permissions'):
|
||||
# mktemp -d is 0700 but mkdir'd children default to umask (0755) — pin 0700
|
||||
chmod 700 "$SCRATCH/tmux-$(id -u)" "$DECOY/tmux-$(id -u)"
|
||||
BIN="$SCRATCH/bin"; mkdir -p "$BIN"
|
||||
CAP="$SCRATCH/sender-captured"; PROBES="$SCRATCH/tmux-probes"; SSHLOG="$SCRATCH/ssh-captured"
|
||||
: > "$PROBES"
|
||||
|
||||
# sender stub: capture args, "send" nothing (socket/target choice is the test)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" > %s\nexit 0\n' "$CAP" > "$BIN/sender-stub"
|
||||
# tmux wrapper: log invocations, exec the real binary (live semantics)
|
||||
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %s\nexec %s "$@"\n' "$PROBES" "$REAL_TMUX" > "$BIN/tmux"
|
||||
# ssh stub: capture the remote command line; swallow stdin (the sender script)
|
||||
printf '#!/usr/bin/env bash\nprintf "SSH:%%s\\n" "$*" >> %s\ncat > /dev/null\nexit 0\n' "$SSHLOG" > "$BIN/ssh"
|
||||
chmod +x "$BIN/sender-stub" "$BIN/tmux" "$BIN/ssh"
|
||||
|
||||
sock_pid_a=""; sock_pid_b=""
|
||||
cleanup() {
|
||||
[ -n "$sock_pid_a" ] && kill "$sock_pid_a" 2>/dev/null
|
||||
for s in sockA sockB sockX decoyD; do
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null
|
||||
TMUX_TMPDIR="$DECOY" "$REAL_TMUX" -L "decoyD" kill-server 2>/dev/null
|
||||
done
|
||||
rm -rf "$SCRATCH" "$DECOY"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mk_server() { # $1 socket, $2 session-name, $3 dir (SCRATCH|DECOY)
|
||||
TMUX_TMPDIR="${3:?}" "$REAL_TMUX" -L "$1" new-session -d -s "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
run() { # passes through; caller sets env per arm
|
||||
PATH="$BIN:$PATH" AGENT_SEND_SENDER="$BIN/sender-stub" MOSAIC_AGENT_NAME=code-be-02 \
|
||||
bash "$TOOL" -S test:src "$@"
|
||||
}
|
||||
|
||||
probe_count() { grep -c "has-session" "$PROBES" || true; }
|
||||
cap_has() { grep -qF -e "$1" "$CAP" 2>/dev/null; }
|
||||
|
||||
fail=0
|
||||
ck() { if [ "$2" -eq 0 ]; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi; }
|
||||
probes_reset() { : > "$PROBES"; }
|
||||
cap_reset() { rm -f "$CAP"; }
|
||||
|
||||
# --- fixtures: sockA=t1, sockB=t1 (same name, two sockets), sockX=t1 ------------
|
||||
mk_server sockA t1 "$SCRATCH"
|
||||
mk_server sockB t1 "$SCRATCH"
|
||||
mk_server sockX t1 "$SCRATCH"
|
||||
|
||||
# --- C1: explicit -L beats env; discovery skipped entirely -----------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -L sockX -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L sockX" && ! grep -qF -- "-L envsock" "$CAP"
|
||||
ck "C1: explicit -L wins over MOSAIC_TMUX_SOCKET (sender got -L sockX, not envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C1: pinned -L skips discovery ENTIRELY (0 has-session probes, not 0 hits)" $?
|
||||
|
||||
# --- C2: env applies when no -L; discovery skipped -------------------------------
|
||||
cap_reset; probes_reset
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
cap_has "-L envsock"
|
||||
ck "C2: MOSAIC_TMUX_SOCKET used when no -L (sender got -L envsock)" $?
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C2: env pin skips discovery (0 probes)" $?
|
||||
|
||||
# --- C3: multi-socket ambiguity refuses rc 4, names sockets, sends nothing -------
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
err="$(TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi 2>&1)"; rc=$?
|
||||
[ "$rc" -eq 4 ]
|
||||
ck "C3: ambiguous session (no -L/env) refuses with rc 4 (contract-stable)" $?
|
||||
echo "$err" | grep -q "multiple sockets" && echo "$err" | grep -qF "sockA" && echo "$err" | grep -qF "sockB"
|
||||
ck "C3: refusal message names BOTH conflicting sockets (sockA, sockB)" $?
|
||||
echo "$err" | grep -qF -- "-L"
|
||||
ck "C3: refusal message carries the -L disambiguation hint" $?
|
||||
[ ! -f "$CAP" ]
|
||||
ck "C3: nothing sent on refusal (sender never invoked)" $?
|
||||
|
||||
# --- C4: discovery reads TMUX_TMPDIR, never plain TMPDIR -------------------------
|
||||
# decoy server lives under $DECOY/tmux-UID; TMPDIR points there, TMUX_TMPDIR at $SCRATCH
|
||||
mk_server decoyD onlydecoy "$DECOY"
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s onlydecoy -m hi >/dev/null 2>&1
|
||||
! cap_has "-L decoyD"
|
||||
ck "C4: a TMPDIR-only socket is NOT consulted (no -L decoyD despite TMPDIR=decoy)" $?
|
||||
# and a session unique in the TMUX_TMPDIR tree IS discovered there
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" TMPDIR="$DECOY" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -ge 2 ]
|
||||
ck "C4: TMUX_TMPDIR tree probed when unpinned (discovery active; ambiguous name exercises the probe loop)" $?
|
||||
|
||||
# --- C5: no unique hit -> default socket; remote sends: no local resolution ------
|
||||
# kill sockA/sockB/sockX so the scratch tree holds only decoy-free empties
|
||||
for s in sockA sockB sockX; do TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L "$s" kill-server 2>/dev/null; done
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C5: zero unique hit -> default socket (sender invoked with NO -L)" $?
|
||||
cap_reset; probes_reset
|
||||
rm -f "$SSHLOG"
|
||||
MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" run -H user@fakehost -s t1 -m hi >/dev/null 2>&1
|
||||
[ "$(probe_count)" -eq 0 ]
|
||||
ck "C5: remote send does NO local discovery (0 probes with -H)" $?
|
||||
[ -f "$SSHLOG" ] && ! grep -qF -- "-L envsock" "$SSHLOG"
|
||||
ck "C5: MOSAIC_TMUX_SOCKET not forwarded to remote (ssh line carries no -L envsock)" $?
|
||||
|
||||
# --- C6: '=name' exact matching; verbatim '=X'; compound pinning -----------------
|
||||
mk_server sockA t1old "$SCRATCH" # ONLY t1old exists now
|
||||
cap_reset; probes_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -s t1 -m hi >/dev/null 2>&1
|
||||
[ -f "$CAP" ] && ! grep -qF -- "-L" "$CAP"
|
||||
ck "C6: t1 does NOT prefix-match t1old ('=t1' probe exact; zero hit -> default)" $?
|
||||
grep -qF 'has-session -t =t1' "$PROBES"
|
||||
ck "C6: discovery probes used the exact ('=t1') target form" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s =t1old -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old' "$CAP"
|
||||
ck "C6: already-exact '=X' input passes through verbatim" $?
|
||||
cap_reset
|
||||
TMUX_TMPDIR="$SCRATCH" run -L sockA -s t1old:0.0 -m hi >/dev/null 2>&1
|
||||
grep -qF -- '-t =t1old:0.0' "$CAP"
|
||||
ck "C6: compound 'sess:win.pane' pins the session component exact (=sess:0.0)" $?
|
||||
|
||||
# --- red controls: the arms bind --------------------------------------------------
|
||||
SAB="$SCRATCH/agent-send-sabotaged.sh"
|
||||
# (a) move the env-default AFTER discovery: C2 must go red
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
envblk = '''if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ] && [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then
|
||||
SOCKET_NAME="$MOSAIC_TMUX_SOCKET"
|
||||
fi
|
||||
'''
|
||||
assert s.count(envblk) == 1
|
||||
s2 = s.replace(envblk, "")
|
||||
anchor = 'socket_args=()'
|
||||
assert s.count(anchor) == 1
|
||||
s2 = s2.replace(anchor, envblk + anchor)
|
||||
assert s2 != s
|
||||
open(dst, "w").write(s2)
|
||||
PY
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if cap_has "-L envsock"; then ck "red-a: sabotaged precedence (env moved after discovery) is CAUGHT by C2 shape" 0; else ck "red-a: sabotaged precedence CAUGHT (envsock lost -> discovered/default socket used)" 0; fi
|
||||
# control validity: with sabotage, the SABOTAGED tool must NOT pin envsock with 0 probes
|
||||
cap_reset; probes_reset
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" MOSAIC_TMUX_SOCKET=envsock TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1
|
||||
if [ "$(probe_count)" -gt 0 ] || ! cap_has "-L envsock"; then
|
||||
ck "red-a validity: sabotage effective (behavior differs from clean tool)" 0
|
||||
else
|
||||
ck "red-a validity: sabotage was a NO-OP — control invalid" 1
|
||||
fi
|
||||
# (b) drop the exit 4: C3 must go red (send proceeds instead of refusing)
|
||||
python3 - "$TOOL" "$SAB" <<'PY'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
old = " exit 4\n"
|
||||
assert s.count(old) == 1
|
||||
s = s.replace(old, " :\n")
|
||||
open(dst, "w").write(s)
|
||||
PY
|
||||
mk_server sockB t1old "$SCRATCH" # second socket carrying the same name -> ambiguity shape
|
||||
cap_reset; probes_reset
|
||||
unset MOSAIC_TMUX_SOCKET
|
||||
AGENT_SEND_TOOL_OVERRIDE="$SAB" TMUX_TMPDIR="$SCRATCH" \
|
||||
bash -c 'PATH="'"$BIN"':$PATH" AGENT_SEND_SENDER="'"$BIN"'/sender-stub" MOSAIC_AGENT_NAME=x bash "$0" -S t:s -s t1old -m hi' "$SAB" >/dev/null 2>&1; src_rc=$?
|
||||
TMUX_TMPDIR="$SCRATCH" "$REAL_TMUX" -L sockB kill-server 2>/dev/null
|
||||
if [ "$src_rc" -eq 4 ]; then
|
||||
ck "red-b: sabotaged refusal still exits 4 — sabotage was a NO-OP, control invalid" 1
|
||||
else
|
||||
ck "red-b: sabotage effective (exit 4 dropped; rc=$src_rc) — C3 pins what the clean tool restores" 0
|
||||
fi
|
||||
|
||||
# --- verdict -----------------------------------------------------------------------
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "agent-send socket contract (live): all arms OK (C1-C6 + both red controls)"
|
||||
exit 0
|
||||
fi
|
||||
echo "agent-send socket contract (live): FAILURES above"
|
||||
exit 1
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bash
|
||||
# Live tmux semantics on private sockets only. A caller may run this suite from
|
||||
# inside mosaic-fleet, where inherited TMUX otherwise overrides TMUX_TMPDIR for
|
||||
# every bare tmux command. Clear pane context and keep both the named and
|
||||
# default fixtures below one scratch TMUX_TMPDIR.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
|
||||
SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
TEST_TMUX_TMPDIR="$TMPDIR/tmux"
|
||||
mkdir -p "$TEST_TMUX_TMPDIR"
|
||||
chmod 700 "$TEST_TMUX_TMPDIR"
|
||||
unset TMUX TMUX_PANE
|
||||
export TMUX_TMPDIR="$TEST_TMUX_TMPDIR"
|
||||
ART_OUT=$(mktemp)
|
||||
AMB_OUT=$(mktemp)
|
||||
AMB_ERR=$(mktemp)
|
||||
A2_OUT=$(mktemp)
|
||||
A2_ERR=$(mktemp)
|
||||
UNIQ_OUT=$(mktemp)
|
||||
UNIQ_ERR=$(mktemp)
|
||||
TWIN="twin-$RANDOM-$$"
|
||||
cleanup() {
|
||||
local test_rc=$? residue=0
|
||||
trap - EXIT
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true
|
||||
env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default kill-server >/dev/null 2>&1 || true
|
||||
sleep 0.2
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L "$SOCKET" list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: named scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
if env -u TMUX -u TMUX_PANE TMUX_TMPDIR="$TEST_TMUX_TMPDIR" \
|
||||
tmux -L default list-sessions >/dev/null 2>&1; then
|
||||
echo "FAIL: default scratch server still answering during cleanup" >&2
|
||||
residue=1
|
||||
fi
|
||||
rm -rf "$TMPDIR" "$ART_OUT" "$AMB_OUT" "$AMB_ERR" "$A2_OUT" "$A2_ERR" "$UNIQ_OUT" "$UNIQ_ERR"
|
||||
if [ "$test_rc" -ne 0 ]; then
|
||||
exit "$test_rc"
|
||||
fi
|
||||
exit "$residue"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_tmux() {
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
}
|
||||
|
||||
capture_named() {
|
||||
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
|
||||
}
|
||||
|
||||
capture_default() {
|
||||
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
|
||||
}
|
||||
|
||||
require_tmux
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >"$TMPDIR/send-message-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/send-message-named.out" || fail 'missing transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >"$TMPDIR/agent-send-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/agent-send-named.out" || fail 'wrapper changed transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
grep -qF "agent socket hello" <<<"$named_pane" || fail "agent-send.sh did not deliver to named socket"
|
||||
default_pane="$(capture_default)" || fail "could not capture default socket pane"
|
||||
if grep -qF "agent socket hello" <<<"$default_pane"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
# Concurrency: parallel senders on one server must not cross-deliver or drop.
|
||||
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
|
||||
# load overwrote load, -d deleted underneath — messages swapped between panes).
|
||||
CONC_N=5
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
done
|
||||
pids=()
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
|
||||
pids+=($!)
|
||||
done
|
||||
for pid in "${pids[@]}"; do
|
||||
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
|
||||
done
|
||||
sleep 0.2
|
||||
for i in $(seq 1 "$CONC_N"); do
|
||||
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
|
||||
grep -qF "CONCPAYLOAD-${i}-END" <<<"$pane" \
|
||||
|| fail "concurrent send dropped payload for pane conc-$i"
|
||||
for j in $(seq 1 "$CONC_N"); do
|
||||
[ "$j" = "$i" ] && continue
|
||||
if grep -qF "CONCPAYLOAD-${j}-END" <<<"$pane"; then
|
||||
fail "concurrent send cross-delivered payload $j to pane conc-$i"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# B1 (2026-08-29): socket default resolution in agent-send.sh. Measured
|
||||
# defect: tasking sends without -L landed in a stale default-socket twin of
|
||||
# the target seat; rc 0 reported honest delivery to the wrong pane.
|
||||
|
||||
# Arm A: session on MULTIPLE sockets, no -L -> refuse with rc 4 naming both.
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
amb_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "must refuse" >$AMB_OUT 2>$AMB_ERR || amb_rc=$?
|
||||
[ "$amb_rc" -eq 4 ] || fail "ambiguity refusal: rc=$amb_rc want 4 (stderr: $(cat $AMB_ERR))"
|
||||
grep -q "multiple sockets" $AMB_ERR || fail "ambiguity refusal message missing socket list"
|
||||
grep -qF "$SOCKET" $AMB_ERR || fail "ambiguity refusal message does not name the test socket"
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm A2: with MOSAIC_TMUX_SOCKET exported, a twin session is NOT ambiguous:
|
||||
# the env var disambiguates by precedence (codex PR #1466 blocker).
|
||||
tmux -L "$SOCKET" new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$TWIN" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
a2_rc=0
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" "$AGENT_SEND" -s "$TWIN" -m "env disambiguated" >$A2_OUT 2>$A2_ERR || a2_rc=$?
|
||||
[ "$a2_rc" -eq 0 ] || fail "env disambiguation: rc=$a2_rc (stderr: $(cat $A2_ERR))"
|
||||
sleep 0.2
|
||||
a2_pane="$(tmux -L "$SOCKET" capture-pane -t "=$TWIN:0.0" -p)" || fail "cannot capture twin (arm A2)"
|
||||
grep -qF "env disambiguated" <<<"$a2_pane" || fail "env disambiguation did not deliver on the named socket"
|
||||
a2_default="$(tmux capture-pane -t "=$TWIN:0.0" -p)" || true
|
||||
if grep -qF "env disambiguated" <<<"$a2_default"; then
|
||||
fail "env disambiguation cross-delivered to the default-socket twin"
|
||||
fi
|
||||
tmux kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
tmux -L "$SOCKET" kill-session -t "$TWIN" >/dev/null 2>&1 || true
|
||||
|
||||
# Arm B: session unique to ONE socket, no -L -> auto-resolve to that socket
|
||||
# and deliver there.
|
||||
# Arm A3: prefix matching must not produce false socket hits (codex PR
|
||||
# #1466): a session named TWIN-old must not count as a hit for target
|
||||
# TWIN (tmux target syntax prefix-matches without '=').
|
||||
PSEUDO="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A3_ERR=$(mktemp)
|
||||
a3_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN" -m "prefix trap" >/dev/null 2>"$A3_ERR" || a3_rc=$?
|
||||
# TWIN exists nowhere (both twins killed after arm A2); with '=' the
|
||||
# PSEUDO session is not a hit, so the sender must fail target-not-found
|
||||
# (rc 1) instead of delivering into the prefix-named session.
|
||||
[ "$a3_rc" -eq 1 ] || fail "prefix false-hit: rc=$a3_rc want 1 (stderr: $(cat "$A3_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO:0.0" -p 2>/dev/null | grep -qF "prefix trap"; then
|
||||
fail "delivery landed in the prefix-named session (false socket hit)"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO" >/dev/null 2>&1 || true
|
||||
rm -f "$A3_ERR"
|
||||
|
||||
# Arm A4: compound targets pin the SESSION component exact (codex PR
|
||||
# #1466): 'TWIN:0.0' must not resolve into the prefix-named session.
|
||||
PSEUDO2="${TWIN}-old"
|
||||
tmux new-session -d -s "$PSEUDO2" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
A4_ERR=$(mktemp)
|
||||
a4_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TWIN:0.0" -m "compound trap" >/dev/null 2>"$A4_ERR" || a4_rc=$?
|
||||
[ "$a4_rc" -eq 1 ] || fail "compound prefix false-hit: rc=$a4_rc want 1 (stderr: $(cat "$A4_ERR"))"
|
||||
if tmux capture-pane -t "=$PSEUDO2:0.0" -p 2>/dev/null | grep -qF "compound trap"; then
|
||||
fail "compound delivery landed in the prefix-named session"
|
||||
fi
|
||||
tmux kill-session -t "$PSEUDO2" >/dev/null 2>&1 || true
|
||||
rm -f "$A4_ERR"
|
||||
|
||||
uniq_rc=0
|
||||
env -u MOSAIC_TMUX_SOCKET "$AGENT_SEND" -s "$TARGET" -m "autoresolved hello" >$UNIQ_OUT 2>$UNIQ_ERR || uniq_rc=$?
|
||||
[ "$uniq_rc" -eq 0 ] || fail "unique auto-resolution: rc=$uniq_rc (stderr: $(cat $UNIQ_ERR))"
|
||||
sleep 0.2
|
||||
auto_pane="$(capture_named)" || fail "could not capture named socket pane (arm B)"
|
||||
grep -qF "autoresolved hello" <<<"$auto_pane" || fail "auto-resolution did not deliver to the named-socket pane"
|
||||
default_pane2="$(capture_default)" || fail "could not capture default socket pane (arm B)"
|
||||
if grep -qF "autoresolved hello" <<<"$default_pane2"; then
|
||||
fail "auto-resolution cross-delivered to the default socket pane"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deterministic transport-only contract tests; no live pane or model access.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir "$TMP/bin"
|
||||
cat > "$TMP/bin/tmux" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
[ "${1:-}" != -L ] || shift 2
|
||||
printf '%s\n' "$1" >> "$CALLS"
|
||||
case "$1" in
|
||||
display-message) [ "$FAIL_OP" != target ] || exit 1; printf '%%7\n' ;;
|
||||
load-buffer) cat > "$PAYLOAD"; [ "$FAIL_OP" != load ] || exit 1 ;;
|
||||
paste-buffer) [ "$FAIL_OP" != paste ] || exit 1 ;;
|
||||
send-keys) [ "$FAIL_OP" != key ] || exit 1 ;;
|
||||
capture-pane) echo 'ERROR: transport must not inspect application display' >&2; exit 99 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod +x "$TMP/bin/tmux"
|
||||
body=$'你好 transport\nsecond line'
|
||||
for mode in none target load paste key; do
|
||||
: > "$TMP/calls"
|
||||
set +e
|
||||
PATH="$TMP/bin:$PATH" CALLS="$TMP/calls" PAYLOAD="$TMP/payload" FAIL_OP="$mode" \
|
||||
bash "$HERE/send-message.sh" -L isolated -t '=fixture' -r 999 -v -m "$body" > "$TMP/out" 2> "$TMP/err"
|
||||
rc=$?
|
||||
set -e
|
||||
expected=2; [ "$mode" != none ] || expected=0; [ "$mode" != target ] || expected=1
|
||||
[ "$rc" -eq "$expected" ]
|
||||
! grep -q capture-pane "$TMP/calls"
|
||||
keys=$(grep -c '^send-keys$' "$TMP/calls" || true)
|
||||
pastes=$(grep -c '^paste-buffer$' "$TMP/calls" || true)
|
||||
[ "$keys" -le 1 ] && [ "$pastes" -le 1 ]
|
||||
if [ "$mode" = none ]; then
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMP/out"
|
||||
[ "$keys" -eq 1 ] && [ "$pastes" -eq 1 ]
|
||||
[ "$(<"$TMP/payload")" = "$body" ]
|
||||
else
|
||||
! grep -q 'transport dispatched' "$TMP/out"
|
||||
fi
|
||||
echo "PASS transport $mode exit=$rc paste=$pastes keys=$keys"
|
||||
done
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compatibility entry point. Screen-verdict semantics were rejected by
|
||||
# independent review and superseded by Jason's explicit transport-only ruling.
|
||||
# Historical fixtures and verdicts remain in frozen r2/r3 review exports.
|
||||
# Do not reinterpret their false confirmations as delivery successes.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec bash "$HERE/test-send-message-transport.sh"
|
||||
@@ -0,0 +1,11 @@
|
||||
# R5 independent review request — Filbert
|
||||
|
||||
Author Darkwing; reviewer Filbert. Jason's explicit transport-only ruling remains the contract: exit 0 means checked tmux dispatch only, application acceptance unknown. No application delivery claim, no extra Enter, no capture-based success.
|
||||
|
||||
Review ONLY `docs/plans/reviews/2026-09-07_tmux-transport-r5-export/`, verifying its nine-file SHA256SUMS. This separate read-only export remains unchanged. Preserve R4 NOT APPROVED and older rejections. Canonical checkout /mnt/storage/src/mosaic-stack. Write verdict to `docs/plans/reviews/2026-09-07_tmux-transport-r5-verdict.md`.
|
||||
|
||||
R4-F1 disposition: retry syntax validated before any SSH; all remote command arguments quoted independently with POSIX single-quote escaping. New `test-agent-send-remote.py` actually executes the SSH command string through /bin/sh using stubs. Quote-bearing target/socket values remain literal; injected retry refuses exit 3 before SSH. README now distinguishes base64 body encoding from shell quoting of other arguments.
|
||||
|
||||
Coordinator verification: three executable remote-shell controls PASS; transport five cases PASS; wrapper 19/0; private named-socket integration PASS; isolated C1–C6 live socket contract/sabotage controls PASS. Logs `/tmp/r5-*.log`; all nine hashes verified. No final approval inferred.
|
||||
|
||||
Please independently rerun R4-F1 attacks plus any necessary adversarial transport/target/cleanup controls on disposable private fixtures. Test bytes from the export only. No live-seat sends, private-pane inspection, source/export edits, deployments or commits. Return approve/request-changes at exact hashes with coverage limits. Trusted runtime acknowledgement remains out of scope; hidden retained drafts cannot become delivery claims.
|
||||
@@ -0,0 +1,38 @@
|
||||
# R5 independent verdict
|
||||
|
||||
Reviewer: Filbert. Author: Darkwing.
|
||||
|
||||
Verdict: APPROVED under Jason's transport-only contract. Exit 0 means checked tmux buffer load, one paste, and one Enter to a pinned pane. Application acceptance remains unknown by design. This approves no application delivery claim.
|
||||
|
||||
R4 remains NOT APPROVED on its own record; older rejections stand. This approval covers only the frozen r5 candidate.
|
||||
|
||||
Manifest SHA-256: `bc6063412ce2dd43bd7e929d7ef41cedcb4c53c7132c8e5d27f9481910e53ae8`. All nine listed hashes verified before and after testing. Reviewed only the frozen `2026-09-07_tmux-transport-r5-export/`. Tests used a disposable copy, fake ssh/tmux stubs, and private scratch tmux sockets. No live-seat sends, source or export edits, deployments, or commits.
|
||||
|
||||
## R4-F1 disposition verified
|
||||
|
||||
The diff from r4 is confined to `agent-send.sh` (retry validation before SSH, per-argument POSIX single-quote escaping of the remote command), a corrected README paragraph, and the new `test-agent-send-remote.py`. `send-message.sh` is byte-identical to r4.
|
||||
|
||||
I reran the exact R4-F1 attacks independently, with an ssh stub that executes the real command string through `/bin/sh` and a recording tmux stub:
|
||||
|
||||
- Session argument `x'; printf 'UNAUTHORIZED_EXTRA_EFFECT\n'; #` now reaches tmux literally (`=x'...:0.0` target, socket `-L` value verbatim), performs the normal transport sequence, and exits 0 with the acceptance-unknown line only.
|
||||
- Retry argument with the same injection is refused exit 3 before any SSH call (ssh log empty). Extended invalid retries `-1`, `1.0`, empty, and ` 2` are also refused exit 3 with zero ssh calls.
|
||||
- Additional quote-bearing adversarial targets and sockets (`'"$(`echo UNSAFE`)`, newlines, Unicode, shell metacharacters `; & | > < * ? [x]`) all pass through literally with no injected effect.
|
||||
|
||||
## Independent verification
|
||||
|
||||
All six suite entry points returned 0 in an isolated environment, each with its own log: `test-send-message-transport.sh`, `agent-send.test.sh`, `test-send-message-socket.sh`, `test-agent-send-socket-live.sh`, `test-send-message-verdict.sh`, and `test-agent-send-remote.py`.
|
||||
|
||||
My own controls beyond the supplied suites:
|
||||
|
||||
- Pane identity is pinned (`%id`) for paste and Enter across success and every failure path; no step re-resolves the target.
|
||||
- The named buffer is deleted on every exit path, including a forced `delete-buffer` failure and each transport failure; no residue after any run.
|
||||
- Effects stay bounded: at most one `paste-buffer` and one `send-keys Enter`; no `capture-pane` anywhere in the tool; `-r 999` adds no keys; `-v` prints metadata only and never the Unicode/multiline body.
|
||||
- A real isolated hidden-draft fixture on tmux 3.7c: a raw-mode receiver that enables bracketed paste and never submits retained the exact payload; output stayed `transport dispatched; application acceptance unknown`; verbose output carried no private display; no buffer residue. Observed byte stream: one bracketed paste with the embedded newline delivered as carriage return inside the markers, then exactly one trailing CR. That is one paste plus one Enter under the contract; the newline-to-CR conversion inside bracketed paste is tmux 3.7c behavior worth knowing, not a contract violation.
|
||||
|
||||
## Limits
|
||||
|
||||
The remote fix is verified by executing the exact ssh command string through a real `/bin/sh` with stubs; no real network ssh hop was run, consistent with isolated tests only. The remote tmux path is the same code path exercised live on private sockets. Trusted runtime acknowledgement remains out of scope; hidden retained drafts still cannot become delivery claims.
|
||||
|
||||
Evidence: `/tmp/filbert-r5-PBElK5/` (`adversarial.py`/`adversarial.log`, `hidden-draft.py`/`hidden-draft.log`, per-suite logs).
|
||||
|
||||
Jason retains issue closure and user acceptance. This verdict grants no push or deployment authority by itself.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Authorized wave selective-commit inventory
|
||||
|
||||
Current local HEAD: 67eaf6fb (five grouped wave commits already landed). Index empty at reconciliation. R5 independent verdict not yet present. No stage/commit/push performed by this inventory.
|
||||
|
||||
## Include after independent approval and identity verification
|
||||
|
||||
- tools/tmux/send-message.sh, agent-send.sh, agent-send.test.sh, test-send-message-socket.sh, test-send-message-verdict.sh, test-send-message-transport.sh, test-agent-send-remote.py, README.md. Match shipped files to frozen R5 manifest; unchanged socket-contract test is reviewed but needs no new staging.
|
||||
- This wave's dated `docs/plans/reviews/2026-09-07_tmux-*` requests, receipts, rejected verdicts and immutable exports, plus final verdict. Preserve rejection history; do not amend original manifests.
|
||||
- Dated A9 owner-acceptance and #53 inclusion records, this inventory and final verification record.
|
||||
- BUILD-LOG.md and docs/SESSIONS.md append-only wave entries; review shared diff immediately before selective staging.
|
||||
- docs/plans/CURRENT.md wave checkpoint and eventual registry-review next action.
|
||||
|
||||
## Exclude/preserve newer relocation work
|
||||
|
||||
agents/darkwing/SOUL.md; agents/README.md; agents/dewey/; agents/filbert/; agents/rocko/; scripts/agent-host-dev.sh; scripts/agent.sh; scripts/test-darkwing-launch.mjs; scripts/test-rocko-launch.mjs; skills/ms-executive-update/. These appeared after initial grouped commits. Do not adopt, stage, revert or clean them under this wave without ownership reconciliation.
|
||||
|
||||
## Remaining records and gates
|
||||
|
||||
1. R5 final independent verdict and any required corrections.
|
||||
2. Tmux fix Gitea issue still not created; Jason authorized its creation. Record issue identity and checked response; do not repeat a POST with unknown outcome.
|
||||
3. Final source hashes, applicable-suite receipts, transport-only live receipt (not application ACK), exact selective index inspection.
|
||||
4. Explicit non-force push of refactor and exactly the 17 local milestone tags; no main/next or archive refs. Verify remote refs after push, not merely local tracking refs.
|
||||
5. #53 planning acceptance evidence plus exact remote included commit; Jason closes original stack-v2 #53.
|
||||
6. Set registry review alignment as next action after wave. No registry implementation or live deployment.
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"remoteRefactor": null,
|
||||
"tagCount": 17,
|
||||
"tags": [
|
||||
{
|
||||
"ref": "refs/tags/adapter-seam-v1",
|
||||
"local": "d539744a51c1529c034aad0b6b6b25caa5618f1e",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/agent-seats-v1",
|
||||
"local": "e53d86d55344c8ac91e4403c1930d2d354193372",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/auto-apply-v1",
|
||||
"local": "ce1a28d6fbea7fb1cdfc06502b2bf8ab4788da7b",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/conductor-v1",
|
||||
"local": "ac665b360eb7abd17af7e7030ff764c0adb4f247",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/config-hello-v1",
|
||||
"local": "8912dfb2c8ca43bd211b3607a2a06e2d8d11cf40",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/interactive-agent-v1",
|
||||
"local": "661c954cc217ac847c04c33794e9de7b28b7a807",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/mission-policy-v1",
|
||||
"local": "7ae1cee48ec036578b1c71d03cb5d985fe89bfc2",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/mission-task-v1",
|
||||
"local": "6d51cc67874b23df762400f7b9f6b12d9ed3cb61",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/onboarding-v1",
|
||||
"local": "f08298e1a8fbfbc98dfb721552bcd24fdacf9785",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/operator-ergonomics-v1",
|
||||
"local": "744b19c5bea305bb63ac91539eae7b0a3f3ecc69",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/poc-container-hello-v0",
|
||||
"local": "988007f34e5dcd756ee35dad73c76cbf72712364",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/release-model-v1",
|
||||
"local": "5184afed6471548d44df449f29c39b8863f8f89c",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/retention-v1",
|
||||
"local": "99be1ef526bb9fa1347445bfb4033dcdc0072b33",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/session-fork-v1",
|
||||
"local": "4a87942c2087c0f7129312e37985f432d7eed031",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/sessions-v1",
|
||||
"local": "6fbe9f08b5fb211ca79cc76bda3ce43e6bf8c110",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/skill-lifecycle-v1",
|
||||
"local": "91aa52e052c0886cf3ff93055ef6db905f02b997",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
},
|
||||
{
|
||||
"ref": "refs/tags/workspace-capabilities-v1",
|
||||
"local": "67d6e8ac44623d721e13b50a8a9d873eeebb23fc",
|
||||
"remote": null,
|
||||
"collision": false
|
||||
}
|
||||
],
|
||||
"conflicts": 0,
|
||||
"mode": "read-only preflight, not publication proof"
|
||||
}
|
||||
+22
-4
@@ -28,7 +28,7 @@ Example exchange:
|
||||
## The helper: `agent-send.sh`
|
||||
|
||||
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
dispatches transport to local OR remote panes; application acceptance remains unknown.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
@@ -47,7 +47,7 @@ echo "msg" | agent-send.sh -s <dst_session>
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` verbose · `-r N` Enter-flush attempts.
|
||||
override source label · `-v` transport metadata only · `-r N` compatibility-only, no retries.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
@@ -75,13 +75,31 @@ plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||
|
||||
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||
keystroke and flush with a second, verifying against a draft heuristic.
|
||||
keystroke. It does not send automatic extra Enters. The legacy `-r` option
|
||||
is accepted for compatibility but no longer authorizes flushes.
|
||||
|
||||
Jason approved the **transport-only contract** after independent review demonstrated
|
||||
that screen layouts cannot prove application acceptance. Exit 0 means tmux accepted
|
||||
buffer load, one paste and one Enter command, not that the application submitted,
|
||||
queued or processed the message. Output explicitly says:
|
||||
`transport dispatched; application acceptance unknown`.
|
||||
|
||||
No capture-pane or editor/footer parser participates in transport success. Hidden
|
||||
retained drafts can coexist with successful transport; they are never called
|
||||
confirmed delivery. Failure exits remain nonzero (1 target resolution, 2 transport
|
||||
failed/partial/uncertain, 3 usage; wrapper 4 ambiguous socket). Failed paste is not
|
||||
retried with another mode. Never blindly replay a partial/uncertain operation.
|
||||
Verbose output is content-free transport metadata. Runtime-bound receipts are
|
||||
separate future work, not implemented by this tool.
|
||||
|
||||
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
message crosses the wire as base64 (`-b`). Every remote command argument is
|
||||
independently POSIX-shell quoted; retry syntax is validated before invoking SSH.
|
||||
The executing-shell regression suite checks quote-bearing target/socket arguments
|
||||
and rejects an invalid retry before any SSH call.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||
# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this
|
||||
# checked bracketed paste + one Enter (transport only). For REMOTE targets this
|
||||
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||
# reliable send-keys happens local to the target pane — sidestepping the
|
||||
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||
@@ -57,8 +57,8 @@
|
||||
# byte-for-byte identical to the classic format. Consumers MUST
|
||||
# treat an absent class as 'actionable' (fail-safe: agent sees it).
|
||||
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||
# -r N Enter-flush attempts passed through (default 2)
|
||||
# -v verbose: print pane tail after delivery
|
||||
# -r N Legacy compatibility option; no automatic extra Enter
|
||||
# -v verbose: transport metadata only, no private pane contents
|
||||
# -h help
|
||||
#
|
||||
# PREAMBLE GRAMMAR (for consumers / daemons mirroring this producer)
|
||||
@@ -67,7 +67,8 @@
|
||||
# group 3 = class (absent => actionable) group 4 = message body
|
||||
#
|
||||
# EXIT CODES (passed through from send-message.sh, except 4)
|
||||
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||
# 0 transport dispatched; application acceptance unknown · 1 target not found
|
||||
# 2 transport failed/partial/uncertain · 3 usage error
|
||||
# 4 agent-send refusal: local target session exists on multiple socket
|
||||
# servers and no -L / MOSAIC_TMUX_SOCKET disambiguated it (B1)
|
||||
set -uo pipefail
|
||||
@@ -106,6 +107,7 @@ while getopts "L:s:H:n:m:f:S:r:C:vh" o; do
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$RETRIES" =~ ^[0-9]+$ ]] || { echo 'ERROR: -r requires a nonnegative integer' >&2; exit 3; }
|
||||
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||
|
||||
@@ -201,7 +203,10 @@ if [ -z "$SOCKET_NAME" ] && [ -z "$SSH_TARGET" ]; then
|
||||
# '=' forces exact session-name matching: tmux target syntax otherwise
|
||||
# accepts an unambiguous PREFIX, so a session named X-old on a socket
|
||||
# would count as a false hit for target X (codex PR #1466).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" 2>/dev/null && hits="$hits$sname"$'\n'
|
||||
# Silence BOTH streams: has-session writes nothing to stdout, but a stub
|
||||
# (test fake) may — leaked probe stdout polluted this tool's stdout and
|
||||
# broke callers that read it (measured 2026-09-07, agent-send.test #9b).
|
||||
tmux -L "$sname" has-session -t "$DST_TARGET" >/dev/null 2>&1 && hits="$hits$sname"$'\n'
|
||||
done
|
||||
hit_count=$(printf '%s' "$hits" | grep -c . || true)
|
||||
if [ "$hit_count" -gt 1 ]; then
|
||||
@@ -224,6 +229,14 @@ if [ -z "$SSH_TARGET" ]; then
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_TARGET' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
# SSH passes a command string through the remote login shell. Quote EACH
|
||||
# argument with POSIX single-quote escaping before that shell parses it.
|
||||
remote_args=(bash -s -- "${socket_args[@]}" -t "$DST_TARGET" -b "$B64" -r "$RETRIES")
|
||||
[ "$VERBOSE" = 0 ] || remote_args+=(-v)
|
||||
remote_command=""
|
||||
for arg in "${remote_args[@]}"; do
|
||||
escaped=${arg//\'/\'\\\'\'}
|
||||
remote_command+=" '$escaped'"
|
||||
done
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" "$remote_command" < "$SENDER"
|
||||
fi
|
||||
|
||||
@@ -28,7 +28,7 @@ TOOL="$HERE/agent-send.sh"
|
||||
# Capture stub: stands in for send-message.sh. Decodes -b and prints the payload.
|
||||
STUB=$(mktemp)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN"' EXIT
|
||||
trap 'rm -f "$STUB"; rm -rf "$FAKE_BIN" "$SCRATCH_TMPDIR"' EXIT
|
||||
cat >"$STUB" <<'STUB_EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
@@ -62,9 +62,15 @@ no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
|
||||
# Run the tool with the stub injected; echoes captured payload on stdout.
|
||||
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
|
||||
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
|
||||
# depend on whether the caller running this suite sits inside tmux.
|
||||
# depend on whether the caller running this suite sits inside tmux — and
|
||||
# TMUX_TMPDIR is pinned to an empty scratch dir so socket discovery never
|
||||
# sees the HOST's sockets (measured 2026-09-07: with default+mosaic-fleet
|
||||
# live, discovery saw the fake answer 'mos' on both and B1-refused rc 4
|
||||
# before the stub ever ran; a one-socket host passed, so this only bites
|
||||
# multi-socket hosts).
|
||||
SCRATCH_TMPDIR=$(mktemp -d)
|
||||
run_auto() { # models a sender OUTSIDE tmux (no client context)
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX \
|
||||
env -u MOSAIC_AGENT_NAME -u TMUX TMUX_TMPDIR="$SCRATCH_TMPDIR" \
|
||||
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
|
||||
bash "$TOOL" -n dsthost "$@"
|
||||
}
|
||||
|
||||
+46
-168
@@ -1,181 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||
# send-message.sh — dispatch text through tmux; application acceptance unknown.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||||
# no-op in Claude Code, so the double-Enter is safe.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||
# -r N Enter-flush attempts (default 2)
|
||||
# -v verbose: print a short tail of the pane after delivery
|
||||
# -h help
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||
# 1 tmux target not found
|
||||
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
|
||||
# input box could not be located to confirm the message actually landed.
|
||||
# Locating the box is runtime-specific; see locate_input_box() below, and
|
||||
# add a shape there before pointing this tool at a new runtime.
|
||||
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
|
||||
# see the input box clear of the message (or the queued banner), we fail loud
|
||||
# so the sender learns immediately instead of a silent worker->lead stall.
|
||||
# 3 usage error
|
||||
# Usage: send-message.sh [-L socket] -t target {-m message|-f file|-b base64}
|
||||
# With no message option, reads stdin. Requires bash, tmux and base64.
|
||||
# -r N is compatibility-only: no automatic retries or extra Enter presses.
|
||||
# -v prints transport metadata only, never a captured private transcript.
|
||||
# Exit 0: tmux accepted buffer load, paste and one Enter command.
|
||||
# Exit 1: target resolution failed. Exit 2: transport failed/partial/uncertain.
|
||||
# Exit 3: invalid usage/input. No exit establishes application acknowledgement.
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; VERBOSE=0
|
||||
usage() { printf '%s\n' 'Usage: send-message.sh [-L socket] -t target [-m message|-f file|-b base64] [-r N] [-v]' 'Exit 0 = transport dispatched; application acceptance unknown. No automatic retries.'; exit "${1:-3}"; }
|
||||
while getopts 'L:t:m:f:b:r:vh' o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
L) SOCKET_NAME=$OPTARG ;; t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;;
|
||||
f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) [[ "$OPTARG" =~ ^[0-9]+$ ]] || usage 3 ;;
|
||||
v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
shift "$((OPTIND - 1))"
|
||||
[ "$#" -eq 0 ] && [ -n "$TARGET" ] || usage 3
|
||||
if [ -n "$B64" ]; then
|
||||
MSG=$(printf '%s' "$B64" | base64 -d) || { echo 'ERROR: invalid base64' >&2; exit 3; }
|
||||
elif [ -n "$FILE" ]; then
|
||||
MSG=$(cat -- "$FILE") || { echo 'ERROR: cannot read message file' >&2; exit 3; }
|
||||
elif [ -z "$MSG" ] && [ ! -t 0 ]; then
|
||||
MSG=$(cat) || exit 3
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
[ -n "$MSG" ] || { echo 'ERROR: empty message' >&2; exit 3; }
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
[ -z "$SOCKET_NAME" ] || tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
QUEUED_RE='Press up to edit queued messages'
|
||||
# A distinctive tail of the message to spot an unsubmitted draft on the input line.
|
||||
snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' | tail -c 32)
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
||||
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
|
||||
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
|
||||
# simultaneous restart (briefs swapped between sessions).
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
||||
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
|
||||
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
|
||||
# (e.g. the target vanished since the liveness check), delete explicitly —
|
||||
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
|
||||
# would otherwise accumulate forever.
|
||||
sleep 0.5
|
||||
|
||||
# Locate the REPL input box in a captured pane. Prints the box's contents on
|
||||
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
|
||||
# located at all. Found-but-empty is a real, distinct answer (an empty input box
|
||||
# is what a submitted message leaves behind), so the caller must branch on the
|
||||
# return code, never on whether the output is empty.
|
||||
#
|
||||
# Two REPL shapes are recognised:
|
||||
# * a prompt-glyph line — `❯`, a leading `>`, or `│ >`. Claude Code and most
|
||||
# readline REPLs.
|
||||
# * a box drawn as two horizontal `─` rules with the input between them and NO
|
||||
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
|
||||
# what makes it safe: agent output can contain its own rules, but nothing is
|
||||
# drawn below the input box except the status line.
|
||||
#
|
||||
# Adding a runtime means adding its shape HERE. A shape that is missing does not
|
||||
# degrade gracefully: it turns every send to that runtime into a false
|
||||
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
|
||||
# arm of the same probe.
|
||||
locate_input_box() {
|
||||
local pane=$1 glyph_line rule_lines top bottom
|
||||
glyph_line=$(printf '%s\n' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
|
||||
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
|
||||
[ -n "$rule_lines" ] || return 1
|
||||
# Split the (at most two) captured line numbers with parameter expansion. Not
|
||||
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
|
||||
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
|
||||
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
|
||||
# With one rule captured both halves resolve to the same value and the
|
||||
# ordering test below rejects it, which is the answer we want anyway.
|
||||
top=${rule_lines%%$'\n'*}
|
||||
bottom=${rule_lines##*$'\n'}
|
||||
[ "$top" != "$bottom" ] || return 1
|
||||
[ "$bottom" -gt "$top" ] || return 1
|
||||
# An empty range (adjacent rules) prints nothing and still returns 0: found,
|
||||
# empty, which is the delivered shape.
|
||||
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
|
||||
return 0
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then EFFECTIVE_TARGET="${TARGET}:0.0"; fi
|
||||
# Pin one pane ID for all subsequent commands rather than resolving a moving
|
||||
# session/window target independently at every transport step.
|
||||
PANE=$("${tmux_cmd[@]}" display-message -p -t "$EFFECTIVE_TARGET" '#{pane_id}' 2>/dev/null) || {
|
||||
echo 'ERROR: tmux target resolution failed' >&2; exit 1;
|
||||
}
|
||||
|
||||
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
|
||||
# still a draft. Success requires positive evidence — the queued banner, OR the
|
||||
# REPL input box located AND clear of our message tail. The historical bug was
|
||||
# treating ABSENCE of a draft as delivery: if the input box was never located
|
||||
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
|
||||
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
|
||||
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
|
||||
status="unconfirmed"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
sleep 1.2
|
||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||
|
||||
if grep -qF "$QUEUED_RE" <<<"$pane"; then
|
||||
status="queued"; break
|
||||
[[ "$PANE" =~ ^%[0-9]+$ ]] || { echo 'ERROR: invalid resolved pane identity' >&2; exit 1; }
|
||||
BUF="__mosaic_send_$$_$(date +%s%N)"
|
||||
cleanup() { "${tmux_cmd[@]}" delete-buffer -b "$BUF" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
if ! printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -; then
|
||||
echo 'ERROR: buffer load failed; transport incomplete' >&2; exit 2
|
||||
fi
|
||||
# If we cannot see the input box, we have NO evidence of submission state —
|
||||
# stay UNCONFIRMED and retry; never infer delivery.
|
||||
if ! inputbox=$(locate_input_box "$pane"); then
|
||||
status="unconfirmed"; continue
|
||||
# Do not retry a failed paste: failure may be partial. Bracketed paste is
|
||||
# requested once; changing paste mode after failure could duplicate effects.
|
||||
if ! "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$PANE"; then
|
||||
echo 'ERROR: paste failed; transport uncertain; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
|
||||
# (Submitted messages scroll up into history; a draft stays in the box.)
|
||||
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
|
||||
status="draft"; continue
|
||||
sleep 0.5
|
||||
if ! "${tmux_cmd[@]}" send-keys -t "$PANE" Enter; then
|
||||
echo 'ERROR: submission key failed; transport partial; do not blindly resend' >&2; exit 2
|
||||
fi
|
||||
# Input box located AND clear of our tail => positively submitted. This is the
|
||||
# only path to success besides the queued banner.
|
||||
status="delivered"; break
|
||||
done
|
||||
|
||||
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||
|
||||
case "$status" in
|
||||
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
|
||||
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
|
||||
esac
|
||||
[ "$VERBOSE" -eq 0 ] || printf 'transport pane=%s; paste_calls=1; submission_keys=1\n' "$PANE"
|
||||
printf '%s\n' 'transport dispatched; application acceptance unknown'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the SSH command through a shell; no network or real tmux access."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
here = Path(__file__).resolve().parent
|
||||
with tempfile.TemporaryDirectory(prefix="tmux-remote-contract-") as temp:
|
||||
root = Path(temp)
|
||||
bindir = root / "bin"
|
||||
bindir.mkdir()
|
||||
(bindir / "ssh").write_text("#!/usr/bin/env python3\nimport os,subprocess,sys\nopen(os.environ['SSH_CALLS'],'a').write('ssh\\n')\nraise SystemExit(subprocess.call(['/bin/sh','-c',sys.argv[-1]]))\n")
|
||||
(bindir / "tmux").write_text("#!/usr/bin/env python3\nimport json,os,sys\na=sys.argv[1:]\nwith open(os.environ['CALLS'],'a') as f: f.write(json.dumps(a)+'\\n')\nif a and a[0]=='-L': a=a[2:]\nif a[0]=='display-message': print('%7')\nif a[0]=='load-buffer': open(os.environ['PAYLOAD'],'wb').write(sys.stdin.buffer.read())\n")
|
||||
for path in bindir.iterdir():
|
||||
path.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(bindir) + ':' + os.environ['PATH'], CALLS=str(root/'calls'), SSH_CALLS=str(root/'ssh-calls'), PAYLOAD=str(root/'payload'))
|
||||
env.pop('AGENT_SEND_SENDER', None)
|
||||
attack = "x'; printf 'UNAUTHORIZED_EXTRA_EFFECT\\n'; #"
|
||||
cases = [('target', ['-s', attack], 0), ('socket', ['-s', 'fixture', '-L', attack], 0), ('retry', ['-s', 'fixture', '-r', attack], 3)]
|
||||
for label, args, expected in cases:
|
||||
(root/'calls').write_text('')
|
||||
(root/'ssh-calls').write_text('')
|
||||
result = subprocess.run(['bash', str(here/'agent-send.sh'), '-S', 'review:src', '-n', 'fake', '-H', 'fake', '-m', 'safe\n你好'] + args, env=env, text=True, capture_output=True)
|
||||
assert result.returncode == expected, (label, result)
|
||||
assert 'UNAUTHORIZED_EXTRA_EFFECT' not in result.stdout, (label, result.stdout)
|
||||
calls = [json.loads(line) for line in (root/'calls').read_text().splitlines()]
|
||||
if expected == 3:
|
||||
assert not calls and not (root/'ssh-calls').read_text()
|
||||
else:
|
||||
assert result.stdout.strip() == 'transport dispatched; application acceptance unknown'
|
||||
command_calls = [a[2:] if a[0] == '-L' else a for a in calls]
|
||||
assert sum(a[0] == 'paste-buffer' for a in command_calls) == 1
|
||||
assert sum(a[0] == 'send-keys' for a in command_calls) == 1
|
||||
assert b'safe\n' in (root/'payload').read_bytes()
|
||||
if label == 'socket': assert all(a[:2] == ['-L', attack] for a in calls)
|
||||
if label == 'target': assert command_calls[0][3] == '=' + attack + ':0.0'
|
||||
print('PASS remote', label)
|
||||
@@ -73,7 +73,8 @@ require_tmux
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >"$TMPDIR/send-message-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/send-message-named.out" || fail 'missing transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "named socket hello" <<<"$named_pane" || fail "send-message.sh did not deliver to named socket"
|
||||
@@ -82,7 +83,8 @@ if grep -qF "named socket hello" <<<"$default_pane"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >"$TMPDIR/agent-send-named.out"
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMPDIR/agent-send-named.out" || fail 'wrapper changed transport-only result'
|
||||
sleep 0.2
|
||||
named_pane="$(capture_named)" || fail "could not capture named socket pane"
|
||||
grep -qF "[tester:source ->" <<<"$named_pane" || fail "agent-send.sh did not include preamble"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deterministic transport-only contract tests; no live pane or model access.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
mkdir "$TMP/bin"
|
||||
cat > "$TMP/bin/tmux" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
[ "${1:-}" != -L ] || shift 2
|
||||
printf '%s\n' "$1" >> "$CALLS"
|
||||
case "$1" in
|
||||
display-message) [ "$FAIL_OP" != target ] || exit 1; printf '%%7\n' ;;
|
||||
load-buffer) cat > "$PAYLOAD"; [ "$FAIL_OP" != load ] || exit 1 ;;
|
||||
paste-buffer) [ "$FAIL_OP" != paste ] || exit 1 ;;
|
||||
send-keys) [ "$FAIL_OP" != key ] || exit 1 ;;
|
||||
capture-pane) echo 'ERROR: transport must not inspect application display' >&2; exit 99 ;;
|
||||
esac
|
||||
FAKE
|
||||
chmod +x "$TMP/bin/tmux"
|
||||
body=$'你好 transport\nsecond line'
|
||||
for mode in none target load paste key; do
|
||||
: > "$TMP/calls"
|
||||
set +e
|
||||
PATH="$TMP/bin:$PATH" CALLS="$TMP/calls" PAYLOAD="$TMP/payload" FAIL_OP="$mode" \
|
||||
bash "$HERE/send-message.sh" -L isolated -t '=fixture' -r 999 -v -m "$body" > "$TMP/out" 2> "$TMP/err"
|
||||
rc=$?
|
||||
set -e
|
||||
expected=2; [ "$mode" != none ] || expected=0; [ "$mode" != target ] || expected=1
|
||||
[ "$rc" -eq "$expected" ]
|
||||
! grep -q capture-pane "$TMP/calls"
|
||||
keys=$(grep -c '^send-keys$' "$TMP/calls" || true)
|
||||
pastes=$(grep -c '^paste-buffer$' "$TMP/calls" || true)
|
||||
[ "$keys" -le 1 ] && [ "$pastes" -le 1 ]
|
||||
if [ "$mode" = none ]; then
|
||||
grep -qx 'transport dispatched; application acceptance unknown' "$TMP/out"
|
||||
[ "$keys" -eq 1 ] && [ "$pastes" -eq 1 ]
|
||||
[ "$(<"$TMP/payload")" = "$body" ]
|
||||
else
|
||||
! grep -q 'transport dispatched' "$TMP/out"
|
||||
fi
|
||||
echo "PASS transport $mode exit=$rc paste=$pastes keys=$keys"
|
||||
done
|
||||
@@ -1,131 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-send-message-verdict.sh — locks the fail-loud verdict logic of the patched
|
||||
# send-message.sh against three real tmux-pane fixtures on a throwaway socket:
|
||||
#
|
||||
# 1. DELIVERED — a REPL that renders a `❯ ` input box and submits on Enter
|
||||
# (text scrolls to history, box clears) => exit 0 "✓ delivered".
|
||||
# 2. UNCONFIRMED — a pane with NO locatable prompt glyph. This is the exact
|
||||
# historical FALSE POSITIVE: pre-patch it printed "✓ delivered"
|
||||
# exit 0; post-patch it MUST fail loud (exit 2, stderr
|
||||
# "could not confirm submission").
|
||||
# 3. DRAFT — a `❯ `-prompt pane that never submits (message stays on the
|
||||
# input line) => exit 2, stderr "unsubmitted draft".
|
||||
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
|
||||
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
|
||||
# the glyph probe could not see this box at all, so EVERY send
|
||||
# to such a pane reported "may be UNDELIVERED" while landing.
|
||||
# 5. DRAFT — the same glyphless box, holding our tail across every flush
|
||||
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
|
||||
# also reported unconfirmed, so the true state was invisible.
|
||||
set -uo pipefail
|
||||
|
||||
# Compatibility entry point. Screen-verdict semantics were rejected by
|
||||
# independent review and superseded by Jason's explicit transport-only ruling.
|
||||
# Historical fixtures and verdicts remain in frozen r2/r3 review exports.
|
||||
# Do not reinterpret their false confirmations as delivery successes.
|
||||
set -euo pipefail
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND="$HERE/send-message.sh"
|
||||
SOCKET="verdict-test-$RANDOM-$$"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
|
||||
no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
command -v tmux >/dev/null 2>&1 || { echo "tmux required" >&2; exit 1; }
|
||||
|
||||
# --- Fixture 1: a submitting REPL with a ❯ prompt box (interactive bash, glyph PS1).
|
||||
# readline strips bracketed-paste markers just like a real agent REPL; Enter
|
||||
# executes (text -> scrollback), leaving a fresh empty `❯ ` box.
|
||||
tmux -L "$SOCKET" new-session -d -s repl -c "$TMP" \
|
||||
'PS1="❯ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=repl" -m "verdict fixture one delivered ok" 2>"$TMP/e1"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: ❯-prompt REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e1")]"
|
||||
fi
|
||||
|
||||
# --- Fixture 2: NO prompt glyph (default bash PS1). THE regression: pre-patch this
|
||||
# was a silent false-positive "delivered"; post-patch it must be unconfirmed→exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s noglyph -c "$TMP" \
|
||||
'PS1="sh-noglyph$ " exec bash --noprofile --norc -i'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=noglyph" -m "verdict fixture two must fail loud" 2>"$TMP/e2"); then
|
||||
no "unconfirmed: glyphless pane must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "could not confirm submission" "$TMP/e2"; then
|
||||
ok "unconfirmed: glyphless pane => exit 2 + 'could not confirm submission' (false-positive FIXED)"
|
||||
else
|
||||
no "unconfirmed: glyphless pane => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e2")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixture 3: a ❯ box that never submits (sleep ignores stdin; TTY echo keeps the
|
||||
# pasted tail sitting on the ❯ line) => draft => exit 2.
|
||||
tmux -L "$SOCKET" new-session -d -s draft -c "$TMP" \
|
||||
'printf "❯ "; exec sleep infinity'
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=draft" -r 1 -m "verdict fixture three stuck unsubmitted draft" 2>"$TMP/e3"); then
|
||||
no "draft: unsubmitted message must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e3"; then
|
||||
ok "draft: stuck ❯-line message => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: stuck ❯-line message => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e3")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
|
||||
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
|
||||
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
|
||||
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
|
||||
# MODE=keep leaves the text sitting in the box.
|
||||
cat > "$TMP/pibox.sh" <<'PIBOX'
|
||||
#!/usr/bin/env bash
|
||||
MODE=${1:-clear}
|
||||
RULE=$(printf '─%.0s' $(seq 1 60))
|
||||
buf=""
|
||||
draw() {
|
||||
printf '\033[H\033[2J'
|
||||
printf 'fixture output line\n\n'
|
||||
printf '%s\n' "$RULE"
|
||||
printf '%s\n' "$buf"
|
||||
printf '%s\n' "$RULE"
|
||||
printf '~/fixture (main)\n'
|
||||
printf 'tok 0 model fixture\n'
|
||||
}
|
||||
draw
|
||||
while IFS= read -r line; do
|
||||
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
|
||||
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
|
||||
draw
|
||||
done
|
||||
PIBOX
|
||||
chmod +x "$TMP/pibox.sh"
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
|
||||
sleep 0.3
|
||||
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
|
||||
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
|
||||
else
|
||||
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
|
||||
fi
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
|
||||
sleep 0.3
|
||||
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
|
||||
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
|
||||
else
|
||||
rc=$?
|
||||
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
|
||||
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
|
||||
else
|
||||
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
exec bash "$HERE/test-send-message-transport.sh"
|
||||
|
||||
Reference in New Issue
Block a user