1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
// SPDX-License-Identifier: AGPL-3.0-only
//! **Non-ASCII text in a JSON/JSONC config must survive the comment stripper
//! byte for byte.**
//!
//! `.json`/`.jsonc` documents go through `config::file::strip_jsonc` before
//! serde_json sees them, because agentd accepts jsonc and the dependency moat
//! forbids a jsonc *crate*. That stripper may scan bytes — correctly, since
//! every byte it matches on (`"`, `\`, `/`, `*`, `\n`) is ASCII and can never
//! appear inside a multibyte UTF-8 sequence — but it must not *emit* bytes, via
//! `byte as char`. That cast is a Latin-1 reinterpretation: `0xE2 as char` is
//! 'â', so an em-dash, an accented name or any CJK text inside a string literal
//! comes out mojibake'd.
//!
//! The failure mode is the worst one available: mojibake is still valid JSON, so
//! there is no parse error, no validation failure, no log line — the agent just
//! runs on a subtly wrong instruction. These tests therefore assert both halves:
//! the parsed document is byte-identical to what was written (`documents`), and
//! the instruction the MODEL actually receives is the one the operator typed
//! (`the daemon`), proved by making the mock LLM answer differently for the
//! corrupted spelling.
mod common;
use std::process::{Command, Stdio};
/// The instruction under test: an em-dash (3-byte), accented Latin (2-byte),
/// CJK (3-byte) and an emoji (4-byte) — one string covering every UTF-8
/// sequence length the stripper can split.
const INSTRUCTION: &str = "Résumé the brief — 日本語で요약 ✅ (τέλος)";
/// The Latin-1 mojibake a byte-wise copy produces: exactly `byte as char` over
/// the UTF-8 encoding. Computed rather than pasted so the probe can never drift
/// from the corruption it is watching for.
fn mojibake(s: &str) -> String {
s.bytes().map(|b| b as char).collect()
}
fn write_file(tag: &str, ext: &str, body: &str) -> String {
let path = common::unique_path(tag, ext);
std::fs::write(&path, body).expect("write test file");
path
}
/// A plain `.json` config — no comments at all. The stripper still runs over it
/// (format is decided by extension, not by whether comments are present), so
/// this is the case where a byte-wise copy corrupts a document that never asked
/// for jsonc in the first place.
fn json_config(intel: &str) -> String {
format!(
r#"{{
"config_version": "1",
"agent": {{ "name": "unicode", "instruction": "{INSTRUCTION}" }},
"intelligence": {{ "endpoints": "{intel}", "model": "mock" }},
"observability": {{ "log_level": "error" }}
}}"#
)
}
/// The same document as `.jsonc`, with comments placed exactly where a byte-wise
/// stripper breaks: multibyte text INSIDE a comment (whose bytes are skipped by
/// a byte counter that must land back on a char boundary), a `/* */` welded to
/// the closing quote of a value that ENDS in a multibyte char, and a `//` line
/// comment opening on multibyte text.
fn jsonc_config(intel: &str) -> String {
format!(
r#"{{
// 設定 — the agent identity, commented in 日本語
"config_version": "1",
/* 註釋: a block comment carrying 4-byte text 🎌 before the key */
"agent": {{ "name": "unicode", "instruction": "{INSTRUCTION}"/* — glued to the quote */ }},
"intelligence": {{ "endpoints": "{intel}", "model": "mock" }}, // ✅ 日本語 trailing
"observability": {{ "log_level": "error" }}
}}"#
)
}
#[test]
fn json_and_jsonc_documents_round_trip_non_ascii_byte_for_byte() {
// The direct half: read each file through the real config reader and compare
// the parsed string to the constant, byte for byte. `assert_eq!` on `&str` IS
// a byte comparison, and the extra byte-slice assertion makes the intent
// (and a failure's diagnostics) explicit for a mojibake'd value.
for (tag, ext, body) in [
("cfg-unicode", "json", json_config("https://intel.example")),
(
"cfg-unicode",
"jsonc",
jsonc_config("https://intel.example"),
),
] {
let path = write_file(tag, ext, &body);
let (doc, format) = agentd::config::file::read_document(&path).expect("config parses");
assert_eq!(format, agentd::config::file::Format::Json, "{ext} is json");
let got = doc["agent"]["instruction"]
.as_str()
.unwrap_or_else(|| panic!("{ext}: no agent.instruction in {doc}"));
assert_eq!(
got.as_bytes(),
INSTRUCTION.as_bytes(),
"{ext}: the instruction was corrupted in transit\n wrote: {INSTRUCTION}\n read : {got}"
);
// …and specifically NOT the Latin-1 shadow a byte-wise copy produces.
assert_ne!(got, mojibake(INSTRUCTION), "{ext}: mojibake");
// The comments themselves left nothing behind: the neighbouring scalars
// are intact, so the stripper resumed on a char boundary after each one.
assert_eq!(doc["agent"]["name"], serde_json::json!("unicode"));
assert_eq!(doc["intelligence"]["model"], serde_json::json!("mock"));
assert_eq!(
doc["observability"]["log_level"],
serde_json::json!("error")
);
let _ = std::fs::remove_file(&path);
}
}
#[test]
fn a_multibyte_char_welded_to_a_comment_survives() {
// The precise shape a byte-wise stripper mangles: the multibyte char is the
// LAST thing before a comment opens and the FIRST thing after it closes,
// with no ASCII in between to resynchronise on.
let body = r#"{
"config_version": "1",
"agent": { "instruction": "—"/*—*/ },
"intelligence": { "endpoints": "https://intel.example", "model": "日本語"//—
}
}"#;
let path = write_file("cfg-welded", "jsonc", body);
let (doc, _) = agentd::config::file::read_document(&path).expect("config parses");
assert_eq!(doc["agent"]["instruction"], serde_json::json!("—"));
assert_eq!(doc["intelligence"]["model"], serde_json::json!("日本語"));
let _ = std::fs::remove_file(&path);
// An escape sequence next to multibyte text must not eat the following byte
// (the stripper skips `\` + one byte without looking at it).
let body = r#"{ "config_version": "1",
"agent": { "instruction": "a\"—\\éé" } }"#;
let path = write_file("cfg-escape", "jsonc", body);
let (doc, _) = agentd::config::file::read_document(&path).expect("config parses");
assert_eq!(doc["agent"]["instruction"], serde_json::json!("a\"—\\éé"));
let _ = std::fs::remove_file(&path);
}
/// A mock-LLM playbook that answers with a DIFFERENT marker depending on which
/// spelling of the instruction reached the model. The corrupted rule is listed
/// first so a regression names itself in the assertion output instead of merely
/// failing to match.
fn probe_playbook() -> serde_json::Value {
serde_json::json!({
"match": [
{"when_contains": mojibake(INSTRUCTION), "content": "CORRUPTED-INSTRUCTION"},
{"when_contains": INSTRUCTION, "content": "INTACT-INSTRUCTION"}
],
"turns": [{"content": "MISSING-INSTRUCTION"}]
})
}
struct MockLlm {
child: std::process::Child,
addr_file: String,
uri: String,
}
impl Drop for MockLlm {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_file(&self.addr_file);
}
}
fn spawn_mock_llm(playbook: &serde_json::Value) -> MockLlm {
let pb = common::unique_path("playbook", "json");
std::fs::write(&pb, playbook.to_string()).unwrap();
let addr_file = common::unique_path("mock-llm", "addr");
let _ = std::fs::remove_file(&addr_file);
let child = Command::new(env!("CARGO_BIN_EXE_agentd"))
.args(["--internal-mock-llm", &addr_file, &format!("file:{pb}")])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn mock llm");
let addr = common::read_addr_file(&addr_file);
MockLlm {
child,
addr_file,
uri: format!("http://{addr}"),
}
}
#[test]
fn the_daemon_sends_the_model_the_instruction_the_operator_wrote() {
// End to end, through the real binary: a `.jsonc` config with comments welded
// to multibyte text drives a real turn, and the mock LLM reports which
// spelling of the instruction actually arrived in the request body. This is
// the assertion that matters operationally — a unit test on the stripper
// proves the bytes, this proves the AGENT runs on them.
let llm = spawn_mock_llm(&probe_playbook());
let cfg = write_file("cfg-unicode-e2e", "jsonc", &jsonc_config(&llm.uri));
let out = Command::new(env!("CARGO_BIN_EXE_agentd"))
.args(["--config", &cfg])
.stdin(Stdio::null())
.output()
.expect("run agentd");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "stderr:\n{stderr}");
assert!(
stdout.contains("INTACT-INSTRUCTION"),
"the model received a different instruction than the config carries.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let _ = std::fs::remove_file(&cfg);
}
#[test]
fn the_yaml_reader_round_trips_non_ascii_too() {
// The sibling syntax, held to the same bar. The hand-rolled YAML subset
// reader also scans bytes and also strips comments (`#`), so it is the other
// place this class of bug could live — it does NOT (it slices rather than
// casting), and this test is what keeps that true. "One document model, two
// syntaxes" has to include the document's text.
let body = format!(
"config_version: \"1\"\n# 註釋 — a comment with 日本語\nagent:\n instruction: \"{INSTRUCTION}\" # ✅ trailing\n name: unicode\nintelligence:\n endpoints: https://intel.example\n model: 日本語\n"
);
let path = write_file("cfg-unicode", "yaml", &body);
let (doc, format) = agentd::config::file::read_document(&path).expect("yaml parses");
assert_eq!(format, agentd::config::file::Format::Yaml);
let got = doc["agent"]["instruction"]
.as_str()
.expect("an instruction");
assert_eq!(
got.as_bytes(),
INSTRUCTION.as_bytes(),
"yaml: the instruction was corrupted in transit\n wrote: {INSTRUCTION}\n read : {got}"
);
// A bare (unquoted) CJK scalar with a comment on the line above it, too.
assert_eq!(doc["intelligence"]["model"], serde_json::json!("日本語"));
let _ = std::fs::remove_file(&path);
}