From 815ac70c2cf9b266acd2783e6f729ef2a8da1ac4 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:23:13 -0700 Subject: [PATCH 01/18] feat: add Rust probe template Add the Rust (file ingest) probe template: an owned AgentValue enum with From impls and an adbg! json-style macro, hand-rolled key normalization matching the shared redaction contract (Rust std has no regex), a backslash-free JSON serializer, verbatim redaction policy, 65536-byte cap, depth-64 cap, epoch-ms timestamp, 0600 append via OpenOptions, and total failure suppression. Registered as "rust" (alias "rs"), file ingest only. Cycles are unrepresentable in an owned value tree, so the live cycle fixture exercises the depth cap instead. Docs matrices and contract/e2e suites extended; live e2e compiles single-file rustc with no cargo/serde. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 5 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 2 +- specs/building-a-debug-mode-agent.md | 3 +- src/probes/render.ts | 9 +- src/probes/rust.ts | 292 ++++++++++++++++++++++ tests/contract/template-renderers.test.ts | 5 +- tests/e2e/languages/live-probes.test.ts | 24 ++ tests/fixtures/languages/rust-file.rs | 6 + 10 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 src/probes/rust.ts create mode 100644 tests/fixtures/languages/rust-file.rs diff --git a/DESIGN.md b/DESIGN.md index 4f7a0e4..83cb82a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,9 +115,10 @@ The first supported combinations are: - PowerShell + file - C# + file - Swift + file +- Rust + file -Every advertised combination must pass a live end-to-end test with its real runtime. Rust, Java, -Kotlin, C, C++, and shell are not advertised until a safe serializer contract is defined. +Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, +C, C++, and shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index be06eec..b489ca8 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ runtime: | PowerShell | file | | C# | file | | Swift | file | +| Rust | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index b3da0a4..ee3a1a5 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -31,6 +31,7 @@ schema** for one language and transport. Templates never take `--session`. | PowerShell | `powershell` (`pwsh`) | `file` | | C# | `csharp` (`cs`, `c#`) | `file` | | Swift | `swift` | `file` | +| Rust | `rust` (`rs`) | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 868768a..72dde64 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -114,7 +114,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | TypeScript | http | PHP | file | | Python | file | PowerShell | file | | Go | file | C# | file | -| Swift | file | | | +| Swift | file | Rust | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index 496ea74..a6b430f 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -146,6 +146,7 @@ advertised, end-to-end-tested combinations are: - PowerShell + file - C# + file - Swift + file +- Rust + file Other languages are not advertised until a safe serializer contract is defined. @@ -266,7 +267,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/render.ts b/src/probes/render.ts index 549cf95..bd5a748 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -5,6 +5,7 @@ import { renderPhpTemplate } from "./php"; import { renderPowerShellTemplate } from "./powershell"; import { renderPythonTemplate } from "./python"; import { renderRubyTemplate } from "./ruby"; +import { renderRustTemplate } from "./rust"; import { renderSwiftTemplate } from "./swift"; import { renderTypeScriptTemplate } from "./typescript"; @@ -17,7 +18,8 @@ export type TemplateLanguage = | "php" | "powershell" | "csharp" - | "swift"; + | "swift" + | "rust"; export type IngestMethod = "http" | "file"; @@ -77,6 +79,9 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "csharp"; case "swift": return "swift"; + case "rust": + case "rs": + return "rust"; default: return undefined; } @@ -109,6 +114,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCSharpTemplate(); case "swift:file": return renderSwiftTemplate(); + case "rust:file": + return renderRustTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/src/probes/rust.ts b/src/probes/rust.ts new file mode 100644 index 0000000..3813750 --- /dev/null +++ b/src/probes/rust.ts @@ -0,0 +1,292 @@ +import type { ProbeTemplates } from "./render"; + +export function renderRustTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "#[derive(Clone)]", + "enum AgentValue {", + " Null,", + " Bool(bool),", + " Int(i64),", + " Float(f64),", + " Str(String),", + " Array(Vec),", + " Object(Vec<(String, AgentValue)>),", + "}", + "", + "impl From for AgentValue { fn from(value: bool) -> Self { AgentValue::Bool(value) } }", + "impl From for AgentValue { fn from(value: i64) -> Self { AgentValue::Int(value) } }", + "impl From for AgentValue { fn from(value: f64) -> Self { AgentValue::Float(value) } }", + "impl From<&str> for AgentValue { fn from(value: &str) -> Self { AgentValue::Str(value.to_string()) } }", + "impl From for AgentValue { fn from(value: String) -> Self { AgentValue::Str(value) } }", + "", + "macro_rules! adbg {", + " ({ $($key:tt : $value:tt),* $(,)? }) => {", + " AgentValue::Object(vec![ $( ($key.to_string(), adbg!($value)) ),* ])", + " };", + " ([ $($value:tt),* $(,)? ]) => {", + " AgentValue::Array(vec![ $( adbg!($value) ),* ])", + " };", + " ($other:expr) => {", + " AgentValue::from($other)", + " };", + "}", + "", + "const AGENT_DEBUG_SECRET_KEYS: [&str; 12] = [", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials",', + "];", + "", + "const AGENT_DEBUG_SECRET_SUFFIXES: [&str; 11] = [", + ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', + ' "client_secret", "access_token", "refresh_token", "id_token",', + ' "auth_token", "bearer_token",', + "];", + "", + "fn __agent_debug_split_acronym(input: &str) -> String {", + " let chars: Vec = input.chars().collect();", + " let mut result = String::new();", + " let mut i = 0;", + " while i < chars.len() {", + " if chars[i].is_ascii_uppercase() {", + " let mut j = i;", + " while j < chars.len() && chars[j].is_ascii_uppercase() {", + " j += 1;", + " }", + " if j - i >= 2 && j < chars.len() && chars[j].is_ascii_lowercase() {", + " for k in i..(j - 1) {", + " result.push(chars[k]);", + " }", + " result.push('_');", + " result.push(chars[j - 1]);", + " result.push(chars[j]);", + " i = j + 1;", + " continue;", + " }", + " for k in i..j {", + " result.push(chars[k]);", + " }", + " i = j;", + " continue;", + " }", + " result.push(chars[i]);", + " i += 1;", + " }", + " result", + "}", + "", + "fn __agent_debug_split_camel(input: &str) -> String {", + " let chars: Vec = input.chars().collect();", + " let mut result = String::new();", + " let mut i = 0;", + " while i < chars.len() {", + " result.push(chars[i]);", + " let boundary = chars[i].is_ascii_lowercase() || chars[i].is_ascii_digit();", + " if boundary && i + 1 < chars.len() && chars[i + 1].is_ascii_uppercase() {", + " result.push('_');", + " }", + " i += 1;", + " }", + " result", + "}", + "", + "fn __agent_debug_normalize_key(key: &str) -> String {", + " let split = __agent_debug_split_camel(&__agent_debug_split_acronym(key));", + " let mut normalized = String::new();", + " let mut pending_underscore = false;", + " for c in split.chars() {", + " if c.is_ascii_alphanumeric() {", + " if pending_underscore && !normalized.is_empty() {", + " normalized.push('_');", + " }", + " pending_underscore = false;", + " for lower in c.to_lowercase() {", + " normalized.push(lower);", + " }", + " } else {", + " pending_underscore = true;", + " }", + " }", + " normalized", + "}", + "", + "fn __agent_debug_is_secret_key(key: &str) -> bool {", + " let normalized = __agent_debug_normalize_key(key);", + " for exact in AGENT_DEBUG_SECRET_KEYS.iter() {", + " if normalized == *exact {", + " return true;", + " }", + " }", + " let bytes = normalized.as_bytes();", + " for suffix in AGENT_DEBUG_SECRET_SUFFIXES.iter() {", + " if normalized.ends_with(suffix) {", + " let boundary = normalized.len() - suffix.len();", + " if boundary == 0 || bytes[boundary - 1] == b'_' {", + " return true;", + " }", + " }", + " }", + " false", + "}", + "", + "fn __agent_debug_write_string(out: &mut String, text: &str) {", + " let backslash = char::from(92u8);", + " let quote = char::from(34u8);", + " out.push(quote);", + " for c in text.chars() {", + " let code = c as u32;", + " if c == quote {", + " out.push(backslash);", + " out.push(quote);", + " } else if c == backslash {", + " out.push(backslash);", + " out.push(backslash);", + " } else if code == 8 {", + " out.push(backslash);", + " out.push(char::from(98u8));", + " } else if code == 9 {", + " out.push(backslash);", + " out.push(char::from(116u8));", + " } else if code == 10 {", + " out.push(backslash);", + " out.push(char::from(110u8));", + " } else if code == 12 {", + " out.push(backslash);", + " out.push(char::from(102u8));", + " } else if code == 13 {", + " out.push(backslash);", + " out.push(char::from(114u8));", + " } else if code < 32 {", + ' let digits = "0123456789abcdef".as_bytes();', + " out.push(backslash);", + " out.push(char::from(117u8));", + " out.push(char::from(48u8));", + " out.push(char::from(48u8));", + " out.push(char::from(digits[((code >> 4) & 15) as usize]));", + " out.push(char::from(digits[(code & 15) as usize]));", + " } else {", + " out.push(c);", + " }", + " }", + " out.push(quote);", + "}", + "", + "fn __agent_debug_write_value(out: &mut String, value: &AgentValue, depth: usize) -> bool {", + " if depth > 64 {", + " return false;", + " }", + " match value {", + ' AgentValue::Null => out.push_str("null"),', + ' AgentValue::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),', + " AgentValue::Int(number) => out.push_str(&number.to_string()),", + " AgentValue::Float(number) => {", + " if !number.is_finite() {", + " return false;", + " }", + " out.push_str(&number.to_string());", + " }", + " AgentValue::Str(text) => __agent_debug_write_string(out, text),", + " AgentValue::Array(items) => {", + " out.push('[');", + " let mut index = 0;", + " for item in items.iter() {", + " if index > 0 {", + " out.push(',');", + " }", + " index += 1;", + " if !__agent_debug_write_value(out, item, depth + 1) {", + " return false;", + " }", + " }", + " out.push(']');", + " }", + " AgentValue::Object(entries) => {", + " out.push('{');", + " let mut index = 0;", + " for entry in entries.iter() {", + " if index > 0 {", + " out.push(',');", + " }", + " index += 1;", + " __agent_debug_write_string(out, &entry.0);", + " out.push(':');", + " if __agent_debug_is_secret_key(&entry.0) {", + ' __agent_debug_write_string(out, "[REDACTED]");', + " } else if !__agent_debug_write_value(out, &entry.1, depth + 1) {", + " return false;", + " }", + " }", + " out.push('}');", + " }", + " }", + " true", + "}", + "", + "fn __agent_debug_timestamp_ms() -> i64 {", + " match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {", + " Ok(elapsed) => elapsed.as_millis() as i64,", + " Err(_) => 0,", + " }", + "}", + "", + "fn __agentDebugEmit(hypothesis_id: &str, location: &str, message: &str, data: AgentValue) {", + " let mut out = String::new();", + " out.push('{');", + ' __agent_debug_write_string(&mut out, "hypothesisId");', + " out.push(':');", + " __agent_debug_write_string(&mut out, hypothesis_id);", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "location");', + " out.push(':');", + " __agent_debug_write_string(&mut out, location);", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "message");', + " out.push(':');", + " __agent_debug_write_string(&mut out, message);", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "data");', + " out.push(':');", + " if !__agent_debug_write_value(&mut out, &data, 0) {", + " return;", + " }", + " out.push(',');", + ' __agent_debug_write_string(&mut out, "timestamp");', + " out.push(':');", + " out.push_str(&__agent_debug_timestamp_ms().to_string());", + " out.push('}');", + " out.push(char::from(10u8));", + " if out.len() > 65536 {", + " return;", + " }", + " let mut options = std::fs::OpenOptions::new();", + " options.create(true).append(true);", + " #[cfg(unix)]", + " {", + " use std::os::unix::fs::OpenOptionsExt;", + " options.mode(0o600);", + " }", + ' if let Ok(mut file) = options.open("__APPEND_PATH__") {', + " use std::io::Write;", + " let _ = file.write_all(out.as_bytes());", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "rust", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible Rust AgentValue that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 0c7192f..f09ee12 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -19,6 +19,7 @@ const supported = [ ["powershell", "file"], ["csharp", "file"], ["swift", "file"], + ["rust", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -109,6 +110,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("C#", "file").language).toBe("csharp"); expect(renderTemplate("cs", "file").language).toBe("csharp"); expect(renderTemplate("SWIFT", "FILE").language).toBe("swift"); + expect(renderTemplate("Rust", "FILE").language).toBe("rust"); + expect(renderTemplate("rs", "file").language).toBe("rust"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -122,7 +125,7 @@ describe("session-independent template renderers", () => { ["powershell", "http"], ["csharp", "http"], ["swift", "http"], - ["rust", "file"], + ["rust", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 49c3aa4..a31e754 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -206,6 +206,30 @@ const fixtures: Fixture[] = [ sharedData: '["left": __agentShared, "right": __agentShared]', sharedPrelude: 'let __agentShared: [String: Any] = ["APIKey": "source-shared-secret"]', }, + { + callData: + 'adbg!({ "value": 42i64, "designToken": "visible-design-token", "fortuneCookie": "visible-fortune-cookie", "secretSauceName": "visible-secret-sauce", "tokenCount": 7i64, "passwordPolicy": "visible-password-policy", "password": "source-password-secret", "APIKey": "source-api-acronym-secret", "APIToken": "source-api-token-secret", "IDToken": "source-id-token-secret", "OAuthToken": "source-oauth-token-secret", "Client Secret": "source-client-secret", "nested": { "apiKey": "source-api-secret", "items": [ { "refresh-token": "source-refresh-secret" }, { "credentials": "source-credentials-secret" } ] } })', + // Rust's AgentValue is an owned tree, so a reference cycle is unrepresentable. + // The depth-64 cap is the analogous unbounded-structure rejection: a value + // nested past the cap is dropped without emitting, exactly like a cycle would be. + command: (path) => { + const rustc = Bun.which("rustc") ?? ""; + const binary = path.replace(/\.rs$/, process.platform === "win32" ? ".exe" : ".out"); + const script = `"${rustc}" -A warnings "${path}" -o "${binary}" && "${binary}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agent_cycle", + cyclePrelude: + "let mut __agent_cycle = AgentValue::Int(0); let mut __agent_depth = 0; while __agent_depth < 100 { __agent_cycle = AgentValue::Array(vec![__agent_cycle]); __agent_depth += 1; }", + file: "rust-file.rs", + ingest: "file", + language: "rust", + runtime: Bun.which("rustc"), + sharedData: + 'AgentValue::Object(vec![("left".to_string(), __agent_shared.clone()), ("right".to_string(), __agent_shared)])', + sharedPrelude: + 'let __agent_shared = AgentValue::Object(vec![("APIKey".to_string(), AgentValue::from("source-shared-secret"))]);', + }, ]; async function run(command: string[], env: Record = process.env) { diff --git a/tests/fixtures/languages/rust-file.rs b/tests/fixtures/languages/rust-file.rs new file mode 100644 index 0000000..da5268b --- /dev/null +++ b/tests/fixtures/languages/rust-file.rs @@ -0,0 +1,6 @@ +__HELPER_TEMPLATE__ + +fn main() { + __CALL_TEMPLATE__ + println!("application-completed"); +} From 950d08d797e73e02cc5457dad324c2dff10b26a1 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:23:13 -0700 Subject: [PATCH 02/18] feat: add C++ probe template Add the C++ (file ingest) probe template: an owned AgentValue struct with implicit ctors from bool/long long/double/const char*/std::string plus an nlohmann-style initializer_list constructor for nested object/array literals, hand-rolled key normalization matching the shared redaction contract (no regex, ported 1:1 from the verified Rust normalizer), a backslash-free JSON serializer via ostringstream, verbatim redaction policy, 65536-byte cap, depth-64 cap, chrono epoch-ms timestamp, ofstream append with 0600 on POSIX, and total failure suppression (try/catch(...)). Registered as "cpp" (aliases "c++", "cxx"), file ingest only. Cycles are unrepresentable in an owned value tree, so the live cycle fixture exercises the depth cap instead. Docs matrices and contract/e2e suites extended; live e2e compiles single-file clang++ (g++ fallback), -std=c++17. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 3 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 1 + specs/building-a-debug-mode-agent.md | 3 +- src/probes/cpp.ts | 331 ++++++++++++++++++++++ src/probes/render.ts | 10 +- tests/contract/template-renderers.test.ts | 5 + tests/e2e/languages/live-probes.test.ts | 23 ++ tests/fixtures/languages/cpp-file.cpp | 7 + 10 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 src/probes/cpp.ts create mode 100644 tests/fixtures/languages/cpp-file.cpp diff --git a/DESIGN.md b/DESIGN.md index 83cb82a..9e89c1f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -116,9 +116,10 @@ The first supported combinations are: - C# + file - Swift + file - Rust + file +- C++ + file Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, -C, C++, and shell are not advertised until a safe serializer contract is defined. +C, and shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index b489ca8..613886a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ runtime: | C# | file | | Swift | file | | Rust | file | +| C++ | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index ee3a1a5..aa8560e 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -32,6 +32,7 @@ schema** for one language and transport. Templates never take `--session`. | C# | `csharp` (`cs`, `c#`) | `file` | | Swift | `swift` | `file` | | Rust | `rust` (`rs`) | `file` | +| C++ | `cpp` (`c++`, `cxx`) | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 72dde64..e64fd4a 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -115,6 +115,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Python | file | PowerShell | file | | Go | file | C# | file | | Swift | file | Rust | file | +| C++ | file | | | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index a6b430f..2a1d44f 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -147,6 +147,7 @@ advertised, end-to-end-tested combinations are: - C# + file - Swift + file - Rust + file +- C++ + file Other languages are not advertised until a safe serializer contract is defined. @@ -267,7 +268,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++ | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts new file mode 100644 index 0000000..8921ad5 --- /dev/null +++ b/src/probes/cpp.ts @@ -0,0 +1,331 @@ +import type { ProbeTemplates } from "./render"; + +export function renderCppTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#ifndef _WIN32", + "#include ", + "#endif", + "", + "enum class AgentKind { Null, Bool, Int, Double, Str, Array, Object };", + "", + "struct AgentValue {", + " AgentKind kind = AgentKind::Null;", + " bool boolValue = false;", + " long long intValue = 0;", + " double doubleValue = 0.0;", + " std::string strValue;", + " std::vector arrayValue;", + " std::vector> objectValue;", + "", + " AgentValue() {}", + " AgentValue(bool v) { kind = AgentKind::Bool; boolValue = v; }", + " AgentValue(long long v) { kind = AgentKind::Int; intValue = v; }", + " AgentValue(double v) { kind = AgentKind::Double; doubleValue = v; }", + " AgentValue(const char* v) { kind = AgentKind::Str; strValue = v; }", + " AgentValue(const std::string& v) { kind = AgentKind::Str; strValue = v; }", + " AgentValue(std::initializer_list items) {", + " bool isObject = items.size() > 0;", + " for (const auto& item : items) {", + " if (item.kind != AgentKind::Array || item.arrayValue.size() != 2 ||", + " item.arrayValue[0].kind != AgentKind::Str) {", + " isObject = false;", + " break;", + " }", + " }", + " if (isObject) {", + " kind = AgentKind::Object;", + " for (const auto& item : items) {", + " objectValue.emplace_back(item.arrayValue[0].strValue, item.arrayValue[1]);", + " }", + " } else {", + " kind = AgentKind::Array;", + " arrayValue = std::vector(items);", + " }", + " }", + "};", + "", + "static const char* const AGENT_DEBUG_SECRET_KEYS[] = {", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"};', + "", + "static const char* const AGENT_DEBUG_SECRET_SUFFIXES[] = {", + ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', + ' "client_secret", "access_token", "refresh_token", "id_token",', + ' "auth_token", "bearer_token"};', + "", + "static std::string __agent_debug_split_acronym(const std::string& input) {", + " std::string result;", + " size_t n = input.size();", + " size_t i = 0;", + " while (i < n) {", + " unsigned char ci = static_cast(input[i]);", + " if (ci >= 'A' && ci <= 'Z') {", + " size_t j = i;", + " while (j < n) {", + " unsigned char cj = static_cast(input[j]);", + " if (cj >= 'A' && cj <= 'Z') {", + " j += 1;", + " } else {", + " break;", + " }", + " }", + " unsigned char after = (j < n) ? static_cast(input[j]) : 0;", + " if (j - i >= 2 && j < n && after >= 'a' && after <= 'z') {", + " for (size_t k = i; k < j - 1; k += 1) {", + " result.push_back(input[k]);", + " }", + " result.push_back('_');", + " result.push_back(input[j - 1]);", + " result.push_back(input[j]);", + " i = j + 1;", + " continue;", + " }", + " for (size_t k = i; k < j; k += 1) {", + " result.push_back(input[k]);", + " }", + " i = j;", + " continue;", + " }", + " result.push_back(input[i]);", + " i += 1;", + " }", + " return result;", + "}", + "", + "static std::string __agent_debug_split_camel(const std::string& input) {", + " std::string result;", + " size_t n = input.size();", + " for (size_t i = 0; i < n; i += 1) {", + " unsigned char ci = static_cast(input[i]);", + " result.push_back(input[i]);", + " bool boundary = (ci >= 'a' && ci <= 'z') || (ci >= '0' && ci <= '9');", + " if (boundary && i + 1 < n) {", + " unsigned char next = static_cast(input[i + 1]);", + " if (next >= 'A' && next <= 'Z') {", + " result.push_back('_');", + " }", + " }", + " }", + " return result;", + "}", + "", + "static std::string __agent_debug_normalize_key(const std::string& key) {", + " std::string split = __agent_debug_split_camel(__agent_debug_split_acronym(key));", + " std::string normalized;", + " bool pending_underscore = false;", + " for (unsigned char c : split) {", + " bool alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');", + " if (alnum) {", + " if (pending_underscore && !normalized.empty()) {", + " normalized.push_back('_');", + " }", + " pending_underscore = false;", + " if (c >= 'A' && c <= 'Z') {", + " normalized.push_back(static_cast(c - 'A' + 'a'));", + " } else {", + " normalized.push_back(static_cast(c));", + " }", + " } else {", + " pending_underscore = true;", + " }", + " }", + " return normalized;", + "}", + "", + "static bool __agent_debug_is_secret_key(const std::string& key) {", + " std::string normalized = __agent_debug_normalize_key(key);", + " for (const auto& exact : AGENT_DEBUG_SECRET_KEYS) {", + " if (normalized == exact) {", + " return true;", + " }", + " }", + " for (const auto& suffix : AGENT_DEBUG_SECRET_SUFFIXES) {", + " std::string tail(suffix);", + " if (normalized.size() >= tail.size() &&", + " normalized.compare(normalized.size() - tail.size(), tail.size(), tail) == 0) {", + " size_t boundary = normalized.size() - tail.size();", + " if (boundary == 0 || normalized[boundary - 1] == '_') {", + " return true;", + " }", + " }", + " }", + " return false;", + "}", + "", + "static void __agent_debug_write_string(std::ostringstream& out, const std::string& text) {", + " const char backslash = static_cast(0x5C);", + " const char quote = static_cast(0x22);", + " out << quote;", + " for (unsigned char c : text) {", + " unsigned int code = static_cast(c);", + " if (c == quote) {", + " out << backslash << quote;", + " } else if (c == backslash) {", + " out << backslash << backslash;", + " } else if (code == 8) {", + " out << backslash << 'b';", + " } else if (code == 9) {", + " out << backslash << 't';", + " } else if (code == 10) {", + " out << backslash << 'n';", + " } else if (code == 12) {", + " out << backslash << 'f';", + " } else if (code == 13) {", + " out << backslash << 'r';", + " } else if (code < 32) {", + ' const char* digits = "0123456789abcdef";', + " out << backslash << 'u' << '0' << '0';", + " out << digits[(code >> 4) & 15] << digits[code & 15];", + " } else {", + " out << static_cast(c);", + " }", + " }", + " out << quote;", + "}", + "", + "static bool __agent_debug_write_value(std::ostringstream& out, const AgentValue& value, int depth) {", + " if (depth > 64) {", + " return false;", + " }", + " switch (value.kind) {", + " case AgentKind::Null:", + ' out << "null";', + " break;", + " case AgentKind::Bool:", + ' out << (value.boolValue ? "true" : "false");', + " break;", + " case AgentKind::Int:", + " out << value.intValue;", + " break;", + " case AgentKind::Double: {", + " if (!std::isfinite(value.doubleValue)) {", + " return false;", + " }", + " std::ostringstream number;", + " number << std::setprecision(17) << value.doubleValue;", + " out << number.str();", + " break;", + " }", + " case AgentKind::Str:", + " __agent_debug_write_string(out, value.strValue);", + " break;", + " case AgentKind::Array: {", + " out << '[';", + " int index = 0;", + " for (const auto& item : value.arrayValue) {", + " if (index > 0) {", + " out << ',';", + " }", + " index += 1;", + " if (!__agent_debug_write_value(out, item, depth + 1)) {", + " return false;", + " }", + " }", + " out << ']';", + " break;", + " }", + " case AgentKind::Object: {", + " out << '{';", + " int index = 0;", + " for (const auto& entry : value.objectValue) {", + " if (index > 0) {", + " out << ',';", + " }", + " index += 1;", + " __agent_debug_write_string(out, entry.first);", + " out << ':';", + " if (__agent_debug_is_secret_key(entry.first)) {", + ' __agent_debug_write_string(out, "[REDACTED]");', + " } else if (!__agent_debug_write_value(out, entry.second, depth + 1)) {", + " return false;", + " }", + " }", + " out << '}';", + " break;", + " }", + " }", + " return true;", + "}", + "", + "static long long __agent_debug_timestamp_ms() {", + " return std::chrono::duration_cast(", + " std::chrono::system_clock::now().time_since_epoch())", + " .count();", + "}", + "", + "static void __agentDebugEmit(const std::string& hypothesisId, const std::string& location,", + " const std::string& message, const AgentValue& data) {", + " try {", + " std::ostringstream out;", + " out << '{';", + ' __agent_debug_write_string(out, "hypothesisId");', + " out << ':';", + " __agent_debug_write_string(out, hypothesisId);", + " out << ',';", + ' __agent_debug_write_string(out, "location");', + " out << ':';", + " __agent_debug_write_string(out, location);", + " out << ',';", + ' __agent_debug_write_string(out, "message");', + " out << ':';", + " __agent_debug_write_string(out, message);", + " out << ',';", + ' __agent_debug_write_string(out, "data");', + " out << ':';", + " if (!__agent_debug_write_value(out, data, 0)) {", + " return;", + " }", + " out << ',';", + ' __agent_debug_write_string(out, "timestamp");', + " out << ':';", + " out << __agent_debug_timestamp_ms();", + " out << '}';", + " out << static_cast(0x0A);", + " std::string payload = out.str();", + " if (payload.size() > 65536) {", + " return;", + " }", + ' std::ofstream file("__APPEND_PATH__", std::ios::out | std::ios::app | std::ios::binary);', + " if (!file.is_open()) {", + " return;", + " }", + " file << payload;", + " file.close();", + "#ifndef _WIN32", + ' chmod("__APPEND_PATH__", 0600);', + "#endif", + " } catch (...) {", + " return;", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "cpp", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible C++ AgentValue that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index bd5a748..f1e488d 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -1,3 +1,4 @@ +import { renderCppTemplate } from "./cpp"; import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; import { renderJavaScriptTemplate } from "./javascript"; @@ -19,7 +20,8 @@ export type TemplateLanguage = | "powershell" | "csharp" | "swift" - | "rust"; + | "rust" + | "cpp"; export type IngestMethod = "http" | "file"; @@ -82,6 +84,10 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { case "rust": case "rs": return "rust"; + case "cpp": + case "c++": + case "cxx": + return "cpp"; default: return undefined; } @@ -116,6 +122,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderSwiftTemplate(); case "rust:file": return renderRustTemplate(); + case "cpp:file": + return renderCppTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index f09ee12..4661b64 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -20,6 +20,7 @@ const supported = [ ["csharp", "file"], ["swift", "file"], ["rust", "file"], + ["cpp", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -112,6 +113,9 @@ describe("session-independent template renderers", () => { expect(renderTemplate("SWIFT", "FILE").language).toBe("swift"); expect(renderTemplate("Rust", "FILE").language).toBe("rust"); expect(renderTemplate("rs", "file").language).toBe("rust"); + expect(renderTemplate("CPP", "FILE").language).toBe("cpp"); + expect(renderTemplate("C++", "file").language).toBe("cpp"); + expect(renderTemplate("cxx", "file").language).toBe("cpp"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -126,6 +130,7 @@ describe("session-independent template renderers", () => { ["csharp", "http"], ["swift", "http"], ["rust", "http"], + ["cpp", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index a31e754..da297d6 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -230,6 +230,29 @@ const fixtures: Fixture[] = [ sharedPrelude: 'let __agent_shared = AgentValue::Object(vec![("APIKey".to_string(), AgentValue::from("source-shared-secret"))]);', }, + { + callData: + 'AgentValue{ {"value", 42LL}, {"designToken", "visible-design-token"}, {"fortuneCookie", "visible-fortune-cookie"}, {"secretSauceName", "visible-secret-sauce"}, {"tokenCount", 7LL}, {"passwordPolicy", "visible-password-policy"}, {"password", "source-password-secret"}, {"APIKey", "source-api-acronym-secret"}, {"APIToken", "source-api-token-secret"}, {"IDToken", "source-id-token-secret"}, {"OAuthToken", "source-oauth-token-secret"}, {"Client Secret", "source-client-secret"}, {"nested", AgentValue{ {"apiKey", "source-api-secret"}, {"items", AgentValue{ AgentValue{ {"refresh-token", "source-refresh-secret"} }, AgentValue{ {"credentials", "source-credentials-secret"} } }} }} }', + // C++'s AgentValue is an owned value tree, so a reference cycle is + // unrepresentable. The depth-64 cap is the analogous unbounded-structure + // rejection: a value nested past the cap is dropped without emitting, + // exactly like a cycle would be. + command: (path) => { + const compiler = Bun.which("clang++") ?? Bun.which("g++") ?? ""; + const binary = path.replace(/\.cpp$/, process.platform === "win32" ? ".exe" : ".out"); + const script = `"${compiler}" -std=c++17 "${path}" -o "${binary}" && "${binary}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agent_cycle", + cyclePrelude: + "AgentValue __agent_cycle = AgentValue(0LL); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = AgentValue{ __agent_cycle }; }", + file: "cpp-file.cpp", + ingest: "file", + language: "cpp", + runtime: Bun.which("clang++") ?? Bun.which("g++"), + sharedData: 'AgentValue{ {"left", __agent_shared}, {"right", __agent_shared} }', + sharedPrelude: 'AgentValue __agent_shared = AgentValue{ {"APIKey", "source-shared-secret"} };', + }, ]; async function run(command: string[], env: Record = process.env) { diff --git a/tests/fixtures/languages/cpp-file.cpp b/tests/fixtures/languages/cpp-file.cpp new file mode 100644 index 0000000..b4ffe3e --- /dev/null +++ b/tests/fixtures/languages/cpp-file.cpp @@ -0,0 +1,7 @@ +__HELPER_TEMPLATE__ + +int main() { + __CALL_TEMPLATE__ + std::cout << "application-completed" << std::endl; + return 0; +} From 8e20eb850b9e51af652f4f07d19eda5b70fc615b Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:23:13 -0700 Subject: [PATCH 03/18] feat: add C probe template Co-Authored-By: Claude Fable 5 --- DESIGN.md | 3 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 2 +- specs/building-a-debug-mode-agent.md | 3 +- src/probes/c.ts | 584 ++++++++++++++++++++++ src/probes/render.ts | 8 +- tests/contract/template-renderers.test.ts | 4 + tests/e2e/languages/live-probes.test.ts | 27 + tests/fixtures/languages/c-file.c | 7 + 10 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 src/probes/c.ts create mode 100644 tests/fixtures/languages/c-file.c diff --git a/DESIGN.md b/DESIGN.md index 9e89c1f..759a87e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,9 +117,10 @@ The first supported combinations are: - Swift + file - Rust + file - C++ + file +- C + file Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, -C, and shell are not advertised until a safe serializer contract is defined. +and shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index 613886a..c2438ca 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ runtime: | Swift | file | | Rust | file | | C++ | file | +| C | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index aa8560e..8f7fd9a 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -33,6 +33,7 @@ schema** for one language and transport. Templates never take `--session`. | Swift | `swift` | `file` | | Rust | `rust` (`rs`) | `file` | | C++ | `cpp` (`c++`, `cxx`) | `file` | +| C | `c` | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index e64fd4a..66f3925 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -115,7 +115,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Python | file | PowerShell | file | | Go | file | C# | file | | Swift | file | Rust | file | -| C++ | file | | | +| C++ | file | C | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index 2a1d44f..db79f8b 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -148,6 +148,7 @@ advertised, end-to-end-tested combinations are: - Swift + file - Rust + file - C++ + file +- C + file Other languages are not advertised until a safe serializer contract is defined. @@ -268,7 +269,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust, C++ | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++, C | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/c.ts b/src/probes/c.ts new file mode 100644 index 0000000..3199810 --- /dev/null +++ b/src/probes/c.ts @@ -0,0 +1,584 @@ +import type { ProbeTemplates } from "./render"; + +export function renderCTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "#define _POSIX_C_SOURCE 200809L", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#include ", + "#ifndef _WIN32", + "#include ", + "#include ", + "#endif", + "", + "enum {", + " AGENT_KIND_NULL,", + " AGENT_KIND_BOOL,", + " AGENT_KIND_INT,", + " AGENT_KIND_DOUBLE,", + " AGENT_KIND_STR,", + " AGENT_KIND_ARRAY,", + " AGENT_KIND_OBJECT", + "};", + "", + "typedef struct AgentValue AgentValue;", + "struct AgentValue {", + " int kind;", + " int boolValue;", + " long long intValue;", + " double doubleValue;", + " char *strValue;", + " char **keys;", + " AgentValue **items;", + " size_t count;", + "};", + "", + "typedef struct {", + " char *buffer;", + " size_t length;", + " size_t capacity;", + " int overflow;", + "} AgentDebugBuffer;", + "", + "static const char *const AGENT_DEBUG_SECRET_KEYS[] = {", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"};', + "", + "static const char *const AGENT_DEBUG_SECRET_SUFFIXES[] = {", + ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', + ' "client_secret", "access_token", "refresh_token", "id_token",', + ' "auth_token", "bearer_token"};', + "", + "static AgentValue *__agent_debug_new(int kind) {", + " AgentValue *value = (AgentValue *)malloc(sizeof(AgentValue));", + " if (value == NULL) {", + " return NULL;", + " }", + " value->kind = kind;", + " value->boolValue = 0;", + " value->intValue = 0;", + " value->doubleValue = 0.0;", + " value->strValue = NULL;", + " value->keys = NULL;", + " value->items = NULL;", + " value->count = 0;", + " return value;", + "}", + "", + "static char *__agent_debug_strdup(const char *text) {", + " if (text == NULL) {", + " return NULL;", + " }", + " size_t n = strlen(text);", + " char *copy = (char *)malloc(n + 1);", + " if (copy == NULL) {", + " return NULL;", + " }", + " for (size_t i = 0; i <= n; i += 1) {", + " copy[i] = text[i];", + " }", + " return copy;", + "}", + "", + "static void __agent_debug_free(AgentValue *value) {", + " if (value == NULL) {", + " return;", + " }", + " if (value->strValue != NULL) {", + " free(value->strValue);", + " }", + " if (value->keys != NULL) {", + " for (size_t i = 0; i < value->count; i += 1) {", + " if (value->keys[i] != NULL) {", + " free(value->keys[i]);", + " }", + " }", + " free(value->keys);", + " }", + " if (value->items != NULL) {", + " for (size_t i = 0; i < value->count; i += 1) {", + " __agent_debug_free(value->items[i]);", + " }", + " free(value->items);", + " }", + " free(value);", + "}", + "", + "static AgentValue *adbg_null(void) {", + " return __agent_debug_new(AGENT_KIND_NULL);", + "}", + "", + "static AgentValue *adbg_bool(int flag) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_BOOL);", + " if (value != NULL) {", + " value->boolValue = flag ? 1 : 0;", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_int(long long number) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_INT);", + " if (value != NULL) {", + " value->intValue = number;", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_double(double number) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_DOUBLE);", + " if (value != NULL) {", + " value->doubleValue = number;", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_str(const char *text) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_STR);", + " if (value != NULL) {", + " value->strValue = __agent_debug_strdup(text);", + " }", + " return value;", + "}", + "", + "static AgentValue *adbg_arr(AgentValue *first, ...) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_ARRAY);", + " va_list args;", + " va_start(args, first);", + " AgentValue *current = first;", + " while (current != NULL) {", + " if (value != NULL) {", + " AgentValue **grown = (AgentValue **)realloc(value->items, (value->count + 1) * sizeof(AgentValue *));", + " if (grown != NULL) {", + " value->items = grown;", + " value->items[value->count] = current;", + " value->count += 1;", + " } else {", + " __agent_debug_free(current);", + " }", + " } else {", + " __agent_debug_free(current);", + " }", + " current = va_arg(args, AgentValue *);", + " }", + " va_end(args);", + " return value;", + "}", + "", + "static AgentValue *adbg_obj(const char *first_key, ...) {", + " AgentValue *value = __agent_debug_new(AGENT_KIND_OBJECT);", + " va_list args;", + " va_start(args, first_key);", + " const char *key = first_key;", + " while (key != NULL) {", + " AgentValue *child = va_arg(args, AgentValue *);", + " if (value != NULL) {", + " char **grown_keys = (char **)realloc(value->keys, (value->count + 1) * sizeof(char *));", + " if (grown_keys != NULL) {", + " value->keys = grown_keys;", + " }", + " AgentValue **grown_items = (AgentValue **)realloc(value->items, (value->count + 1) * sizeof(AgentValue *));", + " if (grown_items != NULL) {", + " value->items = grown_items;", + " }", + " if (grown_keys != NULL && grown_items != NULL) {", + " value->keys[value->count] = __agent_debug_strdup(key);", + " value->items[value->count] = child;", + " value->count += 1;", + " } else {", + " __agent_debug_free(child);", + " }", + " } else {", + " __agent_debug_free(child);", + " }", + " key = va_arg(args, const char *);", + " }", + " va_end(args);", + " return value;", + "}", + "", + "static char *__agent_debug_split_acronym(const char *input) {", + " size_t n = strlen(input);", + " char *result = (char *)malloc(n * 2 + 1);", + " if (result == NULL) {", + " return NULL;", + " }", + " size_t out = 0;", + " size_t i = 0;", + " while (i < n) {", + " unsigned char ci = (unsigned char)input[i];", + " if (ci >= 'A' && ci <= 'Z') {", + " size_t j = i;", + " while (j < n) {", + " unsigned char cj = (unsigned char)input[j];", + " if (cj >= 'A' && cj <= 'Z') {", + " j += 1;", + " } else {", + " break;", + " }", + " }", + " unsigned char after = (j < n) ? (unsigned char)input[j] : 0;", + " if (j - i >= 2 && j < n && after >= 'a' && after <= 'z') {", + " for (size_t k = i; k < j - 1; k += 1) {", + " result[out] = input[k];", + " out += 1;", + " }", + " result[out] = '_';", + " out += 1;", + " result[out] = input[j - 1];", + " out += 1;", + " result[out] = input[j];", + " out += 1;", + " i = j + 1;", + " continue;", + " }", + " for (size_t k = i; k < j; k += 1) {", + " result[out] = input[k];", + " out += 1;", + " }", + " i = j;", + " continue;", + " }", + " result[out] = input[i];", + " out += 1;", + " i += 1;", + " }", + " result[out] = 0;", + " return result;", + "}", + "", + "static char *__agent_debug_split_camel(const char *input) {", + " size_t n = strlen(input);", + " char *result = (char *)malloc(n * 2 + 1);", + " if (result == NULL) {", + " return NULL;", + " }", + " size_t out = 0;", + " for (size_t i = 0; i < n; i += 1) {", + " unsigned char ci = (unsigned char)input[i];", + " result[out] = input[i];", + " out += 1;", + " int boundary = (ci >= 'a' && ci <= 'z') || (ci >= '0' && ci <= '9');", + " if (boundary && i + 1 < n) {", + " unsigned char next = (unsigned char)input[i + 1];", + " if (next >= 'A' && next <= 'Z') {", + " result[out] = '_';", + " out += 1;", + " }", + " }", + " }", + " result[out] = 0;", + " return result;", + "}", + "", + "static char *__agent_debug_normalize_key(const char *key) {", + " char *acronym = __agent_debug_split_acronym(key);", + " if (acronym == NULL) {", + " return NULL;", + " }", + " char *split = __agent_debug_split_camel(acronym);", + " free(acronym);", + " if (split == NULL) {", + " return NULL;", + " }", + " size_t n = strlen(split);", + " char *normalized = (char *)malloc(n + 1);", + " if (normalized == NULL) {", + " free(split);", + " return NULL;", + " }", + " size_t out = 0;", + " int pending_underscore = 0;", + " for (size_t i = 0; i < n; i += 1) {", + " unsigned char c = (unsigned char)split[i];", + " int alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');", + " if (alnum) {", + " if (pending_underscore && out > 0) {", + " normalized[out] = '_';", + " out += 1;", + " }", + " pending_underscore = 0;", + " if (c >= 'A' && c <= 'Z') {", + " normalized[out] = (char)(c - 'A' + 'a');", + " out += 1;", + " } else {", + " normalized[out] = (char)c;", + " out += 1;", + " }", + " } else {", + " pending_underscore = 1;", + " }", + " }", + " normalized[out] = 0;", + " free(split);", + " return normalized;", + "}", + "", + "static int __agent_debug_is_secret_key(const char *key) {", + " char *normalized = __agent_debug_normalize_key(key);", + " if (normalized == NULL) {", + " return 0;", + " }", + " size_t norm_len = strlen(normalized);", + " int secret = 0;", + " size_t exact_count = sizeof(AGENT_DEBUG_SECRET_KEYS) / sizeof(AGENT_DEBUG_SECRET_KEYS[0]);", + " for (size_t i = 0; i < exact_count; i += 1) {", + " if (strcmp(normalized, AGENT_DEBUG_SECRET_KEYS[i]) == 0) {", + " secret = 1;", + " break;", + " }", + " }", + " if (!secret) {", + " size_t suffix_count = sizeof(AGENT_DEBUG_SECRET_SUFFIXES) / sizeof(AGENT_DEBUG_SECRET_SUFFIXES[0]);", + " for (size_t i = 0; i < suffix_count; i += 1) {", + " const char *suffix = AGENT_DEBUG_SECRET_SUFFIXES[i];", + " size_t suffix_len = strlen(suffix);", + " if (norm_len >= suffix_len && strcmp(normalized + (norm_len - suffix_len), suffix) == 0) {", + " size_t boundary = norm_len - suffix_len;", + " if (boundary == 0 || normalized[boundary - 1] == '_') {", + " secret = 1;", + " break;", + " }", + " }", + " }", + " }", + " free(normalized);", + " return secret;", + "}", + "", + "static void __agent_debug_append(AgentDebugBuffer *buf, const char *text, size_t n) {", + " if (buf->overflow) {", + " return;", + " }", + " if (n > buf->capacity - buf->length) {", + " buf->overflow = 1;", + " return;", + " }", + " for (size_t i = 0; i < n; i += 1) {", + " buf->buffer[buf->length + i] = text[i];", + " }", + " buf->length += n;", + "}", + "", + "static void __agent_debug_append_char(AgentDebugBuffer *buf, char c) {", + " __agent_debug_append(buf, &c, 1);", + "}", + "", + "static void __agent_debug_append_ll(AgentDebugBuffer *buf, long long number) {", + " char temp[32];", + ' int written = snprintf(temp, sizeof(temp), "%lld", number);', + " if (written < 0) {", + " buf->overflow = 1;", + " return;", + " }", + " __agent_debug_append(buf, temp, (size_t)written);", + "}", + "", + "static void __agent_debug_append_double(AgentDebugBuffer *buf, double number) {", + " char temp[64];", + ' int written = snprintf(temp, sizeof(temp), "%.17g", number);', + " if (written < 0) {", + " buf->overflow = 1;", + " return;", + " }", + " __agent_debug_append(buf, temp, (size_t)written);", + "}", + "", + "static void __agent_debug_write_string(AgentDebugBuffer *buf, const char *text) {", + " char backslash = (char)0x5C;", + " char quote = (char)0x22;", + " __agent_debug_append_char(buf, quote);", + " if (text != NULL) {", + " for (const unsigned char *p = (const unsigned char *)text; *p != 0; p += 1) {", + " unsigned int code = (unsigned int)(*p);", + " char c = (char)(*p);", + " if (c == quote) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, quote);", + " } else if (c == backslash) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, backslash);", + " } else if (code == 8) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'b');", + " } else if (code == 9) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 't');", + " } else if (code == 10) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'n');", + " } else if (code == 12) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'f');", + " } else if (code == 13) {", + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'r');", + " } else if (code < 32) {", + ' const char *digits = "0123456789abcdef";', + " __agent_debug_append_char(buf, backslash);", + " __agent_debug_append_char(buf, 'u');", + " __agent_debug_append_char(buf, '0');", + " __agent_debug_append_char(buf, '0');", + " __agent_debug_append_char(buf, digits[(code >> 4) & 15]);", + " __agent_debug_append_char(buf, digits[code & 15]);", + " } else {", + " __agent_debug_append_char(buf, c);", + " }", + " }", + " }", + " __agent_debug_append_char(buf, quote);", + "}", + "", + "static int __agent_debug_write_value(AgentDebugBuffer *buf, const AgentValue *value, int depth) {", + " if (depth > 64) {", + " return 0;", + " }", + " if (value == NULL) {", + " return 0;", + " }", + " switch (value->kind) {", + " case AGENT_KIND_NULL:", + ' __agent_debug_append(buf, "null", 4);', + " break;", + " case AGENT_KIND_BOOL:", + " if (value->boolValue) {", + ' __agent_debug_append(buf, "true", 4);', + " } else {", + ' __agent_debug_append(buf, "false", 5);', + " }", + " break;", + " case AGENT_KIND_INT:", + " __agent_debug_append_ll(buf, value->intValue);", + " break;", + " case AGENT_KIND_DOUBLE:", + " if (!isfinite(value->doubleValue)) {", + " return 0;", + " }", + " __agent_debug_append_double(buf, value->doubleValue);", + " break;", + " case AGENT_KIND_STR:", + " __agent_debug_write_string(buf, value->strValue);", + " break;", + " case AGENT_KIND_ARRAY: {", + " __agent_debug_append_char(buf, '[');", + " for (size_t i = 0; i < value->count; i += 1) {", + " if (i > 0) {", + " __agent_debug_append_char(buf, ',');", + " }", + " if (!__agent_debug_write_value(buf, value->items[i], depth + 1)) {", + " return 0;", + " }", + " }", + " __agent_debug_append_char(buf, ']');", + " break;", + " }", + " case AGENT_KIND_OBJECT: {", + " __agent_debug_append_char(buf, '{');", + " for (size_t i = 0; i < value->count; i += 1) {", + " if (i > 0) {", + " __agent_debug_append_char(buf, ',');", + " }", + " __agent_debug_write_string(buf, value->keys[i]);", + " __agent_debug_append_char(buf, ':');", + " if (__agent_debug_is_secret_key(value->keys[i])) {", + ' __agent_debug_write_string(buf, "[REDACTED]");', + " } else if (!__agent_debug_write_value(buf, value->items[i], depth + 1)) {", + " return 0;", + " }", + " }", + " __agent_debug_append_char(buf, '}');", + " break;", + " }", + " }", + " return 1;", + "}", + "", + "static long long __agent_debug_timestamp_ms(void) {", + "#ifdef _WIN32", + " return (long long)time(NULL) * 1000;", + "#else", + " struct timespec ts;", + " if (clock_gettime(CLOCK_REALTIME, &ts) != 0) {", + " return 0;", + " }", + " return (long long)ts.tv_sec * 1000 + (long long)ts.tv_nsec / 1000000;", + "#endif", + "}", + "", + "static void __agentDebugEmit(const char *hypothesisId, const char *location,", + " const char *message, AgentValue *data) {", + " char storage[65536 + 4096];", + " AgentDebugBuffer buf;", + " buf.buffer = storage;", + " buf.length = 0;", + " buf.capacity = sizeof(storage);", + " buf.overflow = 0;", + " int serialized = 1;", + " __agent_debug_append_char(&buf, '{');", + ' __agent_debug_write_string(&buf, "hypothesisId");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_write_string(&buf, hypothesisId);", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "location");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_write_string(&buf, location);", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "message");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_write_string(&buf, message);", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "data");', + " __agent_debug_append_char(&buf, ':');", + " if (!__agent_debug_write_value(&buf, data, 0)) {", + " serialized = 0;", + " }", + " if (serialized) {", + " __agent_debug_append_char(&buf, ',');", + ' __agent_debug_write_string(&buf, "timestamp");', + " __agent_debug_append_char(&buf, ':');", + " __agent_debug_append_ll(&buf, __agent_debug_timestamp_ms());", + " __agent_debug_append_char(&buf, '}');", + " __agent_debug_append_char(&buf, (char)0x0A);", + " }", + " if (serialized && !buf.overflow && buf.length <= 65536) {", + "#ifdef _WIN32", + ' FILE *file = fopen("__APPEND_PATH__", "ab");', + " if (file != NULL) {", + " size_t wrote = fwrite(storage, 1, buf.length, file);", + " (void)wrote;", + " fclose(file);", + " }", + "#else", + ' int fd = open("__APPEND_PATH__", O_WRONLY | O_APPEND | O_CREAT, 0600);', + " if (fd >= 0) {", + " ssize_t wrote = write(fd, storage, buf.length);", + " (void)wrote;", + " close(fd);", + " }", + "#endif", + " }", + " __agent_debug_free(data);", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "c", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible C AgentValue that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index f1e488d..b03ae53 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -1,3 +1,4 @@ +import { renderCTemplate } from "./c"; import { renderCppTemplate } from "./cpp"; import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; @@ -21,7 +22,8 @@ export type TemplateLanguage = | "csharp" | "swift" | "rust" - | "cpp"; + | "cpp" + | "c"; export type IngestMethod = "http" | "file"; @@ -88,6 +90,8 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { case "c++": case "cxx": return "cpp"; + case "c": + return "c"; default: return undefined; } @@ -124,6 +128,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderRustTemplate(); case "cpp:file": return renderCppTemplate(); + case "c:file": + return renderCTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 4661b64..acf5b52 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -21,6 +21,7 @@ const supported = [ ["swift", "file"], ["rust", "file"], ["cpp", "file"], + ["c", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -116,6 +117,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("CPP", "FILE").language).toBe("cpp"); expect(renderTemplate("C++", "file").language).toBe("cpp"); expect(renderTemplate("cxx", "file").language).toBe("cpp"); + expect(renderTemplate("C", "FILE").language).toBe("c"); + expect(renderTemplate("c", "file").language).toBe("c"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -131,6 +134,7 @@ describe("session-independent template renderers", () => { ["swift", "http"], ["rust", "http"], ["cpp", "http"], + ["c", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index da297d6..efaaf7a 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -253,6 +253,33 @@ const fixtures: Fixture[] = [ sharedData: 'AgentValue{ {"left", __agent_shared}, {"right", __agent_shared} }', sharedPrelude: 'AgentValue __agent_shared = AgentValue{ {"APIKey", "source-shared-secret"} };', }, + { + callData: + 'adbg_obj("value", adbg_int(42), "designToken", adbg_str("visible-design-token"), "fortuneCookie", adbg_str("visible-fortune-cookie"), "secretSauceName", adbg_str("visible-secret-sauce"), "tokenCount", adbg_int(7), "passwordPolicy", adbg_str("visible-password-policy"), "password", adbg_str("source-password-secret"), "APIKey", adbg_str("source-api-acronym-secret"), "APIToken", adbg_str("source-api-token-secret"), "IDToken", adbg_str("source-id-token-secret"), "OAuthToken", adbg_str("source-oauth-token-secret"), "Client Secret", adbg_str("source-client-secret"), "nested", adbg_obj("apiKey", adbg_str("source-api-secret"), "items", adbg_arr(adbg_obj("refresh-token", adbg_str("source-refresh-secret"), NULL), adbg_obj("credentials", adbg_str("source-credentials-secret"), NULL), NULL), NULL), NULL)', + // C's AgentValue is an owned pointer tree, so a reference cycle is + // unrepresentable. The depth-64 cap is the analogous unbounded-structure + // rejection: a value nested past the cap is dropped without emitting, + // exactly like a cycle would be. + command: (path) => { + const compiler = Bun.which("clang") ?? Bun.which("gcc") ?? ""; + const binary = path.replace(/\.c$/, process.platform === "win32" ? ".exe" : ".out"); + const script = `"${compiler}" -std=c99 "${path}" -o "${binary}" && "${binary}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agent_cycle", + cyclePrelude: + "AgentValue *__agent_cycle = adbg_int(0); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = adbg_arr(__agent_cycle, NULL); }", + file: "c-file.c", + ingest: "file", + language: "c", + runtime: Bun.which("clang") ?? Bun.which("gcc"), + // C stores an owned pointer tree; sharing one pointer twice would double-free + // on cleanup, so the shared-reference case builds two independent equal + // subtrees (the test compares output equality, not pointer identity). + sharedData: + 'adbg_obj("left", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), "right", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), NULL)', + sharedPrelude: "", + }, ]; async function run(command: string[], env: Record = process.env) { diff --git a/tests/fixtures/languages/c-file.c b/tests/fixtures/languages/c-file.c new file mode 100644 index 0000000..2c5e1d5 --- /dev/null +++ b/tests/fixtures/languages/c-file.c @@ -0,0 +1,7 @@ +__HELPER_TEMPLATE__ + +int main(void) { + __CALL_TEMPLATE__ + printf("application-completed\n"); + return 0; +} From 06481eba5b02ec968ac8e0624b881fb96d187960 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:23:13 -0700 Subject: [PATCH 04/18] feat: add Java probe template Add a Java (file-ingest) probe template cloning the csharp.ts approach: real java.util.regex key normalization, Map/List/array/scalar handling with a java.lang.reflect fallback over public getters and fields, IdentityHashMap-based cycle detection with a depth-64 cap, fully-qualified names (no imports) so the helper is injectable anywhere, and single-file `java .java` execution. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 5 +- README.md | 1 + skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 1 + specs/building-a-debug-mode-agent.md | 3 +- src/probes/java.ts | 274 ++++++++++++++++++++++ src/probes/render.ts | 8 +- tests/contract/template-renderers.test.ts | 4 + tests/e2e/languages/live-probes.test.ts | 124 +++++++--- tests/fixtures/languages/java-file.java | 9 + 10 files changed, 388 insertions(+), 42 deletions(-) create mode 100644 src/probes/java.ts create mode 100644 tests/fixtures/languages/java-file.java diff --git a/DESIGN.md b/DESIGN.md index 759a87e..a36eeb7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -118,9 +118,10 @@ The first supported combinations are: - Rust + file - C++ + file - C + file +- Java + file -Every advertised combination must pass a live end-to-end test with its real runtime. Java, Kotlin, -and shell are not advertised until a safe serializer contract is defined. +Every advertised combination must pass a live end-to-end test with its real runtime. Kotlin and +shell are not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/README.md b/README.md index c2438ca..5763325 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ runtime: | Rust | file | | C++ | file | | C | file | +| Java | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index 8f7fd9a..166e8d1 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -34,6 +34,7 @@ schema** for one language and transport. Templates never take `--session`. | Rust | `rust` (`rs`) | `file` | | C++ | `cpp` (`c++`, `cxx`) | `file` | | C | `c` | `file` | +| Java | `java` | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 66f3925..f76dba4 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -116,6 +116,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Go | file | C# | file | | Swift | file | Rust | file | | C++ | file | C | file | +| Java | file | | | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index db79f8b..d8be185 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -149,6 +149,7 @@ advertised, end-to-end-tested combinations are: - Rust + file - C++ + file - C + file +- Java + file Other languages are not advertised until a safe serializer contract is defined. @@ -269,7 +270,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust, C++, C | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++, C, Java | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/java.ts b/src/probes/java.ts new file mode 100644 index 0000000..a26c887 --- /dev/null +++ b/src/probes/java.ts @@ -0,0 +1,274 @@ +import type { ProbeTemplates } from "./render"; + +export function renderJavaTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'AgentDebugLog.Emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "class AgentDebugLog {", + " private static final java.util.Set SECRET_KEYS = new java.util.HashSet<>(java.util.Arrays.asList(", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"));', + "", + " private static final java.util.regex.Pattern SECRET_SUFFIX = java.util.regex.Pattern.compile(", + ' "(^|_)(api_key|api_token|oauth_token|o_auth_token|private_key|client_secret|access_token|refresh_token|id_token|auth_token|bearer_token)$");', + "", + " private static boolean isSecretKey(String key) {", + ' String normalized = key.replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2");', + ' normalized = normalized.replaceAll("([a-z0-9])([A-Z])", "$1_$2");', + ' normalized = normalized.replaceAll("[^a-zA-Z0-9]+", "_").toLowerCase(java.util.Locale.ROOT);', + ' normalized = normalized.replaceAll("^_+", "").replaceAll("_+$", "");', + " return SECRET_KEYS.contains(normalized) || SECRET_SUFFIX.matcher(normalized).find();", + " }", + "", + " private static String decapitalize(String name) {", + " if (name.isEmpty()) {", + " return name;", + " }", + " if (name.length() > 1 && Character.isUpperCase(name.charAt(0)) && Character.isUpperCase(name.charAt(1))) {", + " return name;", + " }", + " char[] letters = name.toCharArray();", + " letters[0] = Character.toLowerCase(letters[0]);", + " return new String(letters);", + " }", + "", + " private static Object redact(Object value, java.util.IdentityHashMap active, int depth) {", + " if (depth > 64) {", + ' throw new IllegalStateException("Observation graph is too deep");', + " }", + " if (value == null) {", + " return null;", + " }", + " if (value instanceof CharSequence || value instanceof Character) {", + " return value.toString();", + " }", + " if (value instanceof Number || value instanceof Boolean) {", + " return value;", + " }", + " if (value instanceof Enum) {", + " return ((Enum) value).name();", + " }", + " if (active.put(value, Boolean.TRUE) != null) {", + ' throw new IllegalStateException("Cyclic observation graph");', + " }", + " try {", + " if (value instanceof java.util.Map) {", + " java.util.LinkedHashMap redacted = new java.util.LinkedHashMap<>();", + " for (java.util.Map.Entry entry : ((java.util.Map) value).entrySet()) {", + " String key = String.valueOf(entry.getKey());", + ' redacted.put(key, isSecretKey(key) ? "[REDACTED]" : redact(entry.getValue(), active, depth + 1));', + " }", + " return redacted;", + " }", + " if (value instanceof Iterable) {", + " java.util.ArrayList redacted = new java.util.ArrayList<>();", + " for (Object item : (Iterable) value) {", + " redacted.add(redact(item, active, depth + 1));", + " }", + " return redacted;", + " }", + " if (value.getClass().isArray()) {", + " java.util.ArrayList redacted = new java.util.ArrayList<>();", + " int length = java.lang.reflect.Array.getLength(value);", + " for (int index = 0; index < length; index += 1) {", + " redacted.add(redact(java.lang.reflect.Array.get(value, index), active, depth + 1));", + " }", + " return redacted;", + " }", + " java.util.LinkedHashMap members = new java.util.LinkedHashMap<>();", + " Class type = value.getClass();", + " for (java.lang.reflect.Method method : type.getMethods()) {", + " if (method.getParameterCount() != 0 || method.getReturnType() == void.class) {", + " continue;", + " }", + " if (java.lang.reflect.Modifier.isStatic(method.getModifiers()) || method.getDeclaringClass() == Object.class) {", + " continue;", + " }", + " String name = method.getName();", + " String property;", + ' if (name.startsWith("get") && name.length() > 3) {', + " property = decapitalize(name.substring(3));", + ' } else if (name.startsWith("is") && name.length() > 2 && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) {', + " property = decapitalize(name.substring(2));", + " } else {", + " continue;", + " }", + " Object raw;", + " try {", + " method.setAccessible(true);", + " raw = method.invoke(value);", + " } catch (Throwable failure) {", + " continue;", + " }", + ' members.put(property, isSecretKey(property) ? "[REDACTED]" : redact(raw, active, depth + 1));', + " }", + " for (java.lang.reflect.Field field : type.getFields()) {", + " if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) {", + " continue;", + " }", + " Object raw;", + " try {", + " field.setAccessible(true);", + " raw = field.get(value);", + " } catch (Throwable failure) {", + " continue;", + " }", + ' members.put(field.getName(), isSecretKey(field.getName()) ? "[REDACTED]" : redact(raw, active, depth + 1));', + " }", + " return members;", + " } finally {", + " active.remove(value);", + " }", + " }", + "", + " private static void writeString(StringBuilder out, String text) {", + " char backslash = (char) 92;", + " char quote = (char) 34;", + " out.append(quote);", + " for (int index = 0; index < text.length(); index += 1) {", + " char symbol = text.charAt(index);", + " if (symbol == quote) {", + " out.append(backslash).append(quote);", + " } else if (symbol == backslash) {", + " out.append(backslash).append(backslash);", + " } else if (symbol == (char) 8) {", + " out.append(backslash).append('b');", + " } else if (symbol == (char) 9) {", + " out.append(backslash).append('t');", + " } else if (symbol == (char) 10) {", + " out.append(backslash).append('n');", + " } else if (symbol == (char) 12) {", + " out.append(backslash).append('f');", + " } else if (symbol == (char) 13) {", + " out.append(backslash).append('r');", + " } else if (symbol < (char) 32) {", + ' String digits = "0123456789abcdef";', + " out.append(backslash).append('u').append('0').append('0');", + " out.append(digits.charAt((symbol >> 4) & 15)).append(digits.charAt(symbol & 15));", + " } else {", + " out.append(symbol);", + " }", + " }", + " out.append(quote);", + " }", + "", + " private static void writeValue(StringBuilder out, Object value) {", + " if (value == null) {", + ' out.append("null");', + " return;", + " }", + " if (value instanceof String) {", + " writeString(out, (String) value);", + " return;", + " }", + " if (value instanceof Boolean) {", + ' out.append(((Boolean) value).booleanValue() ? "true" : "false");', + " return;", + " }", + " if (value instanceof Double || value instanceof Float) {", + " double number = ((Number) value).doubleValue();", + " if (Double.isNaN(number) || Double.isInfinite(number)) {", + ' throw new IllegalStateException("Non-finite number");', + " }", + " out.append(Double.toString(number));", + " return;", + " }", + " if (value instanceof Number) {", + " out.append(value.toString());", + " return;", + " }", + " if (value instanceof java.util.Map) {", + " out.append('{');", + " boolean first = true;", + " for (java.util.Map.Entry entry : ((java.util.Map) value).entrySet()) {", + " if (!first) {", + " out.append(',');", + " }", + " first = false;", + " writeString(out, String.valueOf(entry.getKey()));", + " out.append(':');", + " writeValue(out, entry.getValue());", + " }", + " out.append('}');", + " return;", + " }", + " if (value instanceof java.util.List) {", + " out.append('[');", + " boolean first = true;", + " for (Object item : (java.util.List) value) {", + " if (!first) {", + " out.append(',');", + " }", + " first = false;", + " writeValue(out, item);", + " }", + " out.append(']');", + " return;", + " }", + " writeString(out, String.valueOf(value));", + " }", + "", + " static void Emit(String hypothesisId, String location, String message, Object data) {", + " try {", + " StringBuilder out = new StringBuilder();", + " out.append('{');", + ' writeString(out, "hypothesisId");', + " out.append(':');", + " writeString(out, hypothesisId);", + " out.append(',');", + ' writeString(out, "location");', + " out.append(':');", + " writeString(out, location);", + " out.append(',');", + ' writeString(out, "message");', + " out.append(':');", + " writeString(out, message);", + " out.append(',');", + ' writeString(out, "data");', + " out.append(':');", + " writeValue(out, redact(data, new java.util.IdentityHashMap(), 0));", + " out.append(',');", + ' writeString(out, "timestamp");', + " out.append(':');", + " out.append(System.currentTimeMillis());", + " out.append('}');", + " out.append((char) 10);", + " byte[] payload = out.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);", + " if (payload.length > 65536) {", + " return;", + " }", + ' java.nio.file.Path path = java.nio.file.Paths.get("__APPEND_PATH__");', + " try {", + ' java.nio.file.Files.createFile(path, java.nio.file.attribute.PosixFilePermissions.asFileAttribute(java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")));', + " } catch (UnsupportedOperationException nonPosix) {", + " try {", + " java.nio.file.Files.createFile(path);", + " } catch (Throwable ignored) {", + " }", + " } catch (Throwable ignored) {", + " }", + " java.nio.file.Files.write(path, payload, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);", + " } catch (Throwable failure) {", + " // Observations must never change application behavior.", + " }", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "java", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible Java value that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index b03ae53..9208716 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -2,6 +2,7 @@ import { renderCTemplate } from "./c"; import { renderCppTemplate } from "./cpp"; import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; +import { renderJavaTemplate } from "./java"; import { renderJavaScriptTemplate } from "./javascript"; import { renderPhpTemplate } from "./php"; import { renderPowerShellTemplate } from "./powershell"; @@ -23,7 +24,8 @@ export type TemplateLanguage = | "swift" | "rust" | "cpp" - | "c"; + | "c" + | "java"; export type IngestMethod = "http" | "file"; @@ -92,6 +94,8 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "cpp"; case "c": return "c"; + case "java": + return "java"; default: return undefined; } @@ -130,6 +134,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCppTemplate(); case "c:file": return renderCTemplate(); + case "java:file": + return renderJavaTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index acf5b52..6ce91c7 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -22,6 +22,7 @@ const supported = [ ["rust", "file"], ["cpp", "file"], ["c", "file"], + ["java", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -119,6 +120,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("cxx", "file").language).toBe("cpp"); expect(renderTemplate("C", "FILE").language).toBe("c"); expect(renderTemplate("c", "file").language).toBe("c"); + expect(renderTemplate("Java", "FILE").language).toBe("java"); + expect(renderTemplate("java", "file").language).toBe("java"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -135,6 +138,7 @@ describe("session-independent template renderers", () => { ["rust", "http"], ["cpp", "http"], ["c", "http"], + ["java", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index efaaf7a..00b4e25 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -280,6 +280,21 @@ const fixtures: Fixture[] = [ 'adbg_obj("left", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), "right", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), NULL)', sharedPrelude: "", }, + { + callData: + 'java.util.Map.ofEntries(java.util.Map.entry("value", 42), java.util.Map.entry("designToken", "visible-design-token"), java.util.Map.entry("fortuneCookie", "visible-fortune-cookie"), java.util.Map.entry("secretSauceName", "visible-secret-sauce"), java.util.Map.entry("tokenCount", 7), java.util.Map.entry("passwordPolicy", "visible-password-policy"), java.util.Map.entry("password", "source-password-secret"), java.util.Map.entry("APIKey", "source-api-acronym-secret"), java.util.Map.entry("APIToken", "source-api-token-secret"), java.util.Map.entry("IDToken", "source-id-token-secret"), java.util.Map.entry("OAuthToken", "source-oauth-token-secret"), java.util.Map.entry("Client Secret", "source-client-secret"), java.util.Map.entry("nested", java.util.Map.ofEntries(java.util.Map.entry("apiKey", "source-api-secret"), java.util.Map.entry("items", java.util.List.of(java.util.Map.of("refresh-token", "source-refresh-secret"), java.util.Map.of("credentials", "source-credentials-secret"))))))', + command: (path) => [Bun.which("java") ?? "", path], + cycleData: "__agentCycle", + cyclePrelude: + 'java.util.Map __agentCycle = new java.util.LinkedHashMap<>(); __agentCycle.put("self", __agentCycle);', + file: "java-file.java", + ingest: "file", + language: "java", + runtime: Bun.which("java"), + sharedData: 'java.util.Map.of("left", __agentShared, "right", __agentShared)', + sharedPrelude: + 'java.util.Map __agentShared = new java.util.LinkedHashMap<>(); __agentShared.put("APIKey", "source-shared-secret");', + }, ]; async function run(command: string[], env: Record = process.env) { @@ -800,10 +815,73 @@ describe("live language templates", () => { }, 120_000); } - for (const language of ["csharp", "powershell"]) { + // Per-language custom-object coverage: C# and PowerShell exercise value types + // (structs) so the redactor inspects their members instead of treating them as + // atomic; Java has no value types, so it exercises the reflection path over a + // plain object's public fields — the only case that reaches the reflection + // branch, since the shared policy matrix uses maps and lists throughout. + const valueTypeCases: Record< + string, + { dataExpression: string; prelude: string; extraTypes?: string } + > = { + csharp: { + dataExpression: "__agentValue", + prelude: [ + "var __agentValue = new AgentCustomValue", + "{", + ' APIKey = "source-value-api-secret",', + ' designToken = "visible-value-design-token",', + ' Nested = new Dictionary { ["OAuthToken"] = "source-value-oauth-secret" },', + "};", + ].join("\n"), + extraTypes: [ + "internal struct AgentCustomValue", + "{", + " public string APIKey { get; init; }", + " public Dictionary Nested;", + " public string designToken;", + "}", + ].join("\n"), + }, + java: { + dataExpression: "__agentValue", + prelude: [ + "AgentCustomValue __agentValue = new AgentCustomValue();", + '__agentValue.APIKey = "source-value-api-secret";', + '__agentValue.designToken = "visible-value-design-token";', + '__agentValue.Nested = java.util.Map.of("OAuthToken", "source-value-oauth-secret");', + ].join("\n"), + extraTypes: [ + "static class AgentCustomValue {", + " public String APIKey;", + " public Object Nested;", + " public String designToken;", + "}", + ].join("\n"), + }, + powershell: { + dataExpression: "$__agentValue", + prelude: [ + "Add-Type -TypeDefinition @'", + "public struct AgentCustomValue {", + " public string APIKey { get; set; }", + " public object Nested { get; set; }", + " public string designToken;", + "}", + "'@", + "$__agentValue = [AgentCustomValue]::new()", + '$__agentValue.APIKey = "source-value-api-secret"', + '$__agentValue.designToken = "visible-value-design-token"', + '$__agentValue.Nested = @{ OAuthToken = "source-value-oauth-secret" }', + ].join("\n"), + }, + }; + + for (const language of ["csharp", "powershell", "java"]) { const fixture = fixtures.find((candidate) => candidate.language === language); const unavailable = fixture?.runtime === null; const runtimeTest = unavailable && !requireRuntimes ? test.skip : test; + const valueTypeCase = valueTypeCases[language]; runtimeTest( `${language} redacts custom value-type members`, @@ -811,6 +889,9 @@ describe("live language templates", () => { if (!fixture) { throw new Error(`Missing ${language} fixture`); } + if (!valueTypeCase) { + throw new Error(`Missing ${language} value-type case`); + } expect(fixture.runtime, `${language} runtime must be installed`).not.toBeNull(); const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); const workspace = await mkdtemp(join(tmpdir(), `debug-mode-${language}-value-type-`)); @@ -823,41 +904,8 @@ describe("live language templates", () => { ); await fixture.setup?.(workspace); const fixturePath = join(workspace, fixture.file); - const isCSharp = language === "csharp"; - const prelude = isCSharp - ? [ - "var __agentValue = new AgentCustomValue", - "{", - ' APIKey = "source-value-api-secret",', - ' designToken = "visible-value-design-token",', - ' Nested = new Dictionary { ["OAuthToken"] = "source-value-oauth-secret" },', - "};", - ].join("\n") - : [ - "Add-Type -TypeDefinition @'", - "public struct AgentCustomValue {", - " public string APIKey { get; set; }", - " public object Nested { get; set; }", - " public string designToken;", - "}", - "'@", - "$__agentValue = [AgentCustomValue]::new()", - '$__agentValue.APIKey = "source-value-api-secret"', - '$__agentValue.designToken = "visible-value-design-token"', - '$__agentValue.Nested = @{ OAuthToken = "source-value-oauth-secret" }', - ].join("\n"); - if (isCSharp) { - source = source.replace( - "/* __EXTRA_TYPES__ */", - [ - "internal struct AgentCustomValue", - "{", - " public string APIKey { get; init; }", - " public Dictionary Nested;", - " public string designToken;", - "}", - ].join("\n"), - ); + if (valueTypeCase.extraTypes) { + source = source.replace("/* __EXTRA_TYPES__ */", valueTypeCase.extraTypes); } await writeFile( fixturePath, @@ -867,8 +915,8 @@ describe("live language templates", () => { fixture, created.data.appendPath.replaceAll("\\", "\\\\"), 1, - isCSharp ? "__agentValue" : "$__agentValue", - prelude, + valueTypeCase.dataExpression, + valueTypeCase.prelude, ), ); diff --git a/tests/fixtures/languages/java-file.java b/tests/fixtures/languages/java-file.java new file mode 100644 index 0000000..640aa0e --- /dev/null +++ b/tests/fixtures/languages/java-file.java @@ -0,0 +1,9 @@ +class JavaFile { + /* __EXTRA_TYPES__ */ + public static void main(String[] args) { + __CALL_TEMPLATE__ + System.out.println("application-completed"); + } +} + +__HELPER_TEMPLATE__ From cbf32a2b95b7fb055fb4f2cfcb1b2e2cbda36e82 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:23:13 -0700 Subject: [PATCH 05/18] feat: add Kotlin probe template Add the Kotlin (file ingest) probe template, closing the five-language systems batch (C, C++, Rust, Java, Kotlin). Kotlin clones the Java JVM approach: java.lang.reflect over public getters then fields (not kotlin-reflect), IdentityHashMap cycle detection, depth cap 64, real java.util.regex secret-key normalization, Files.write(CREATE, APPEND) with 0600-at-create POSIX handling, and an object AgentDebugLog.emit static helper that never alters control flow (catch Throwable). Also updates the changeset (minor bump: nine helper/runtime pairs become fourteen) and every current-facing language matrix and count (README, DESIGN, SKILL, REFERENCE, spec advertised list + region-marker row). Co-Authored-By: Claude Fable 5 --- .changeset/systems-language-templates.md | 7 + DESIGN.md | 1 + README.md | 3 +- skills/agentic-debug-mode/REFERENCE.md | 1 + skills/agentic-debug-mode/SKILL.md | 2 +- specs/building-a-debug-mode-agent.md | 3 +- src/probes/kotlin.ts | 280 ++++++++++++++++++++++ src/probes/render.ts | 9 +- tests/contract/template-renderers.test.ts | 4 + tests/e2e/languages/live-probes.test.ts | 52 +++- tests/fixtures/languages/kotlin-file.kt | 7 + 11 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 .changeset/systems-language-templates.md create mode 100644 src/probes/kotlin.ts create mode 100644 tests/fixtures/languages/kotlin-file.kt diff --git a/.changeset/systems-language-templates.md b/.changeset/systems-language-templates.md new file mode 100644 index 0000000..c299281 --- /dev/null +++ b/.changeset/systems-language-templates.md @@ -0,0 +1,7 @@ +--- +"agentic-debug-mode": minor +--- + +Add probe templates for five systems languages: C, C++, Rust, Java, and Kotlin. Each renders a +hypothesis-tagged, at-source-redacting fire-and-forget helper that appends bounded NDJSON evidence, +bringing the advertised helper/runtime pairs from nine to fourteen. diff --git a/DESIGN.md b/DESIGN.md index a36eeb7..ec590a2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -119,6 +119,7 @@ The first supported combinations are: - C++ + file - C + file - Java + file +- Kotlin + file Every advertised combination must pass a live end-to-end test with its real runtime. Kotlin and shell are not advertised until a safe serializer contract is defined. diff --git a/README.md b/README.md index 5763325..8eb9254 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ reproduces the failure, and proves the root cause with runtime evidence before i Inspired from [Cursor's debug mode](https://cursor.com/blog/debug-mode) and built upon it: the same hypothesis-driven core, made agent-agnostic (any coding agent that can run a CLI), and extended -with a session-scoped evidence store, hypothesis-tagged probes across nine languages, embedded +with a session-scoped evidence store, hypothesis-tagged probes across fourteen languages, embedded structured queries over captured events, at-source secret redaction, and bounded reads that keep large evidence sets token-cheap. @@ -90,6 +90,7 @@ runtime: | C++ | file | | C | file | | Java | file | +| Kotlin | file | ## Development diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index 166e8d1..18f8540 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -35,6 +35,7 @@ schema** for one language and transport. Templates never take `--session`. | C++ | `cpp` (`c++`, `cxx`) | `file` | | C | `c` | `file` | | Java | `java` | `file` | +| Kotlin | `kotlin` (`kt`) | `file` | HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index f76dba4..615eac8 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -116,7 +116,7 @@ with the Append Path. Advertised combinations, each verified end-to-end: | Go | file | C# | file | | Swift | file | Rust | file | | C++ | file | C | file | -| Java | file | | | +| Java | file | Kotlin | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index d8be185..ab7dcde 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -150,6 +150,7 @@ advertised, end-to-end-tested combinations are: - C++ + file - C + file - Java + file +- Kotlin + file Other languages are not advertised until a safe serializer contract is defined. @@ -270,7 +271,7 @@ visually distinct, foldable, mechanically removable, and auditable as complete b | Language/file type | Open | Close | | --------------------------------- | ---------------------- | --------------- | -| JavaScript, TypeScript, Go, Swift, Rust, C++, C, Java | `// #region agent log` | `// #endregion` | +| JavaScript, TypeScript, Go, Swift, Rust, C++, C, Java, Kotlin | `// #region agent log` | `// #endregion` | | C#, PowerShell | `#region agent log` | `#endregion` | | Python, Ruby | `# region agent log` | `# endregion` | | PHP | `// #region agent log` | `// #endregion` | diff --git a/src/probes/kotlin.ts b/src/probes/kotlin.ts new file mode 100644 index 0000000..defa3fa --- /dev/null +++ b/src/probes/kotlin.ts @@ -0,0 +1,280 @@ +import type { ProbeTemplates } from "./render"; + +export function renderKotlinTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'AgentDebugLog.emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__)', + "// #endregion", + ].join("\n"), + helperTemplate: [ + "// #region agent log", + "object AgentDebugLog {", + " private val SECRET_KEYS = java.util.HashSet(java.util.Arrays.asList(", + ' "authorization", "authorization_header", "cookie", "set_cookie",', + ' "password", "passwd", "pwd", "private_key", "secret", "token",', + ' "credential", "credentials"))', + "", + ' private val SECRET_BOUNDARY_ONE = java.util.regex.Pattern.compile("([A-Z]+)([A-Z][a-z])")', + ' private val SECRET_BOUNDARY_TWO = java.util.regex.Pattern.compile("([a-z0-9])([A-Z])")', + ' private val SECRET_NON_ALNUM = java.util.regex.Pattern.compile("[^a-zA-Z0-9]+")', + ' private val SECRET_TRIM_LEADING = java.util.regex.Pattern.compile("^_+")', + ' private val SECRET_TRIM_TRAILING = java.util.regex.Pattern.compile("_+\\$")', + " private val SECRET_SUFFIX = java.util.regex.Pattern.compile(", + ' "(^|_)(api_key|api_token|oauth_token|o_auth_token|private_key|client_secret|access_token|refresh_token|id_token|auth_token|bearer_token)\\$")', + "", + " private fun isSecretKey(key: String): Boolean {", + ' var normalized = SECRET_BOUNDARY_ONE.matcher(key).replaceAll("\\$1_\\$2")', + ' normalized = SECRET_BOUNDARY_TWO.matcher(normalized).replaceAll("\\$1_\\$2")', + ' normalized = SECRET_NON_ALNUM.matcher(normalized).replaceAll("_").lowercase()', + ' normalized = SECRET_TRIM_LEADING.matcher(normalized).replaceAll("")', + ' normalized = SECRET_TRIM_TRAILING.matcher(normalized).replaceAll("")', + " return SECRET_KEYS.contains(normalized) || SECRET_SUFFIX.matcher(normalized).find()", + " }", + "", + " private fun decapitalize(name: String): String {", + " if (name.isEmpty()) {", + " return name", + " }", + " if (name.length > 1 && name[0].isUpperCase() && name[1].isUpperCase()) {", + " return name", + " }", + " val letters = name.toCharArray()", + " letters[0] = letters[0].lowercaseChar()", + " return String(letters)", + " }", + "", + " private fun redact(value: Any?, active: java.util.IdentityHashMap, depth: Int): Any? {", + " if (depth > 64) {", + ' throw IllegalStateException("Observation graph is too deep")', + " }", + " if (value == null) {", + " return null", + " }", + " if (value is CharSequence || value is Char) {", + " return value.toString()", + " }", + " if (value is Number || value is Boolean) {", + " return value", + " }", + " if (value is Enum<*>) {", + " return value.name", + " }", + " if (active.put(value, true) != null) {", + ' throw IllegalStateException("Cyclic observation graph")', + " }", + " try {", + " if (value is Map<*, *>) {", + " val redacted = java.util.LinkedHashMap()", + " for (entry in value.entries) {", + ' val key = entry.key?.toString() ?: "null"', + ' redacted[key] = if (isSecretKey(key)) "[REDACTED]" else redact(entry.value, active, depth + 1)', + " }", + " return redacted", + " }", + " if (value is Iterable<*>) {", + " val redacted = java.util.ArrayList()", + " for (item in value) {", + " redacted.add(redact(item, active, depth + 1))", + " }", + " return redacted", + " }", + " if (value.javaClass.isArray) {", + " val redacted = java.util.ArrayList()", + " val length = java.lang.reflect.Array.getLength(value)", + " for (index in 0 until length) {", + " redacted.add(redact(java.lang.reflect.Array.get(value, index), active, depth + 1))", + " }", + " return redacted", + " }", + " val members = java.util.LinkedHashMap()", + " val type = value.javaClass", + " for (method in type.methods) {", + " if (method.parameterCount != 0 || method.returnType == java.lang.Void.TYPE) {", + " continue", + " }", + " if (java.lang.reflect.Modifier.isStatic(method.modifiers) || method.declaringClass == java.lang.Object::class.java) {", + " continue", + " }", + " val name = method.name", + " val property: String", + ' if (name.startsWith("get") && name.length > 3) {', + " property = decapitalize(name.substring(3))", + ' } else if (name.startsWith("is") && name.length > 2 && (method.returnType == java.lang.Boolean.TYPE || method.returnType == java.lang.Boolean::class.java)) {', + " property = decapitalize(name.substring(2))", + " } else {", + " continue", + " }", + " val raw: Any?", + " try {", + " method.isAccessible = true", + " raw = method.invoke(value)", + " } catch (methodFailure: Throwable) {", + " continue", + " }", + ' members[property] = if (isSecretKey(property)) "[REDACTED]" else redact(raw, active, depth + 1)', + " }", + " for (field in type.fields) {", + " if (java.lang.reflect.Modifier.isStatic(field.modifiers)) {", + " continue", + " }", + " val raw: Any?", + " try {", + " field.isAccessible = true", + " raw = field.get(value)", + " } catch (fieldFailure: Throwable) {", + " continue", + " }", + ' members[field.name] = if (isSecretKey(field.name)) "[REDACTED]" else redact(raw, active, depth + 1)', + " }", + " return members", + " } finally {", + " active.remove(value)", + " }", + " }", + "", + " private fun writeString(out: StringBuilder, text: String) {", + " val backslash = 92.toChar()", + " val quote = 34.toChar()", + " out.append(quote)", + " for (index in 0 until text.length) {", + " val symbol = text[index]", + " if (symbol == quote) {", + " out.append(backslash).append(quote)", + " } else if (symbol == backslash) {", + " out.append(backslash).append(backslash)", + " } else if (symbol == 8.toChar()) {", + " out.append(backslash).append('b')", + " } else if (symbol == 9.toChar()) {", + " out.append(backslash).append('t')", + " } else if (symbol == 10.toChar()) {", + " out.append(backslash).append('n')", + " } else if (symbol == 12.toChar()) {", + " out.append(backslash).append('f')", + " } else if (symbol == 13.toChar()) {", + " out.append(backslash).append('r')", + " } else if (symbol < 32.toChar()) {", + ' val digits = "0123456789abcdef"', + " out.append(backslash).append('u').append('0').append('0')", + " out.append(digits[(symbol.code shr 4) and 15]).append(digits[symbol.code and 15])", + " } else {", + " out.append(symbol)", + " }", + " }", + " out.append(quote)", + " }", + "", + " private fun writeValue(out: StringBuilder, value: Any?) {", + " if (value == null) {", + ' out.append("null")', + " return", + " }", + " if (value is String) {", + " writeString(out, value)", + " return", + " }", + " if (value is Boolean) {", + ' out.append(if (value) "true" else "false")', + " return", + " }", + " if (value is Double || value is Float) {", + " val number = (value as Number).toDouble()", + " if (number.isNaN() || number.isInfinite()) {", + ' throw IllegalStateException("Non-finite number")', + " }", + " out.append(number.toString())", + " return", + " }", + " if (value is Number) {", + " out.append(value.toString())", + " return", + " }", + " if (value is Map<*, *>) {", + " out.append('{')", + " var first = true", + " for (entry in value.entries) {", + " if (!first) {", + " out.append(',')", + " }", + " first = false", + ' writeString(out, entry.key?.toString() ?: "null")', + " out.append(':')", + " writeValue(out, entry.value)", + " }", + " out.append('}')", + " return", + " }", + " if (value is List<*>) {", + " out.append('[')", + " var first = true", + " for (item in value) {", + " if (!first) {", + " out.append(',')", + " }", + " first = false", + " writeValue(out, item)", + " }", + " out.append(']')", + " return", + " }", + " writeString(out, value.toString())", + " }", + "", + " fun emit(hypothesisId: String, location: String, message: String, data: Any?) {", + " try {", + " val out = StringBuilder()", + " out.append('{')", + ' writeString(out, "hypothesisId")', + " out.append(':')", + " writeString(out, hypothesisId)", + " out.append(',')", + ' writeString(out, "location")', + " out.append(':')", + " writeString(out, location)", + " out.append(',')", + ' writeString(out, "message")', + " out.append(':')", + " writeString(out, message)", + " out.append(',')", + ' writeString(out, "data")', + " out.append(':')", + " writeValue(out, redact(data, java.util.IdentityHashMap(), 0))", + " out.append(',')", + ' writeString(out, "timestamp")', + " out.append(':')", + " out.append(System.currentTimeMillis())", + " out.append('}')", + " out.append(10.toChar())", + " val payload = out.toString().toByteArray(java.nio.charset.StandardCharsets.UTF_8)", + " if (payload.size > 65536) {", + " return", + " }", + ' val path = java.nio.file.Paths.get("__APPEND_PATH__")', + " try {", + ' java.nio.file.Files.createFile(path, java.nio.file.attribute.PosixFilePermissions.asFileAttribute(java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")))', + " } catch (nonPosix: UnsupportedOperationException) {", + " try {", + " java.nio.file.Files.createFile(path)", + " } catch (ignored: Throwable) {", + " }", + " } catch (ignored: Throwable) {", + " }", + " java.nio.file.Files.write(path, payload, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND)", + " } catch (failure: Throwable) {", + " // Observations must never change application behavior.", + " }", + " }", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "kotlin", + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __DATA_EXPRESSION__: "Replace with a JSON-compatible Kotlin value that has no secrets.", + __HYPOTHESIS_ID__: "Replace with the hypothesis label.", + __LOCATION__: "Replace with the observed source location.", + __MESSAGE__: "Replace with a constant observation message.", + }, + }; +} diff --git a/src/probes/render.ts b/src/probes/render.ts index 9208716..c622045 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -4,6 +4,7 @@ import { renderCSharpTemplate } from "./csharp"; import { renderGoTemplate } from "./go"; import { renderJavaTemplate } from "./java"; import { renderJavaScriptTemplate } from "./javascript"; +import { renderKotlinTemplate } from "./kotlin"; import { renderPhpTemplate } from "./php"; import { renderPowerShellTemplate } from "./powershell"; import { renderPythonTemplate } from "./python"; @@ -25,7 +26,8 @@ export type TemplateLanguage = | "rust" | "cpp" | "c" - | "java"; + | "java" + | "kotlin"; export type IngestMethod = "http" | "file"; @@ -96,6 +98,9 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "c"; case "java": return "java"; + case "kotlin": + case "kt": + return "kotlin"; default: return undefined; } @@ -136,6 +141,8 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCTemplate(); case "java:file": return renderJavaTemplate(); + case "kotlin:file": + return renderKotlinTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 6ce91c7..e4f5b41 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -23,6 +23,7 @@ const supported = [ ["cpp", "file"], ["c", "file"], ["java", "file"], + ["kotlin", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; const callPlaceholders = [ @@ -122,6 +123,8 @@ describe("session-independent template renderers", () => { expect(renderTemplate("c", "file").language).toBe("c"); expect(renderTemplate("Java", "FILE").language).toBe("java"); expect(renderTemplate("java", "file").language).toBe("java"); + expect(renderTemplate("Kotlin", "FILE").language).toBe("kotlin"); + expect(renderTemplate("kt", "file").language).toBe("kotlin"); }); test("rejects every unadvertised language and ingest pair with a typed error", () => { @@ -139,6 +142,7 @@ describe("session-independent template renderers", () => { ["cpp", "http"], ["c", "http"], ["java", "http"], + ["kotlin", "http"], ["javascript", "socket"], ] as const) { try { diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 00b4e25..8504bdc 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -295,6 +295,28 @@ const fixtures: Fixture[] = [ sharedPrelude: 'java.util.Map __agentShared = new java.util.LinkedHashMap<>(); __agentShared.put("APIKey", "source-shared-secret");', }, + { + callData: + 'mapOf("value" to 42, "designToken" to "visible-design-token", "fortuneCookie" to "visible-fortune-cookie", "secretSauceName" to "visible-secret-sauce", "tokenCount" to 7, "passwordPolicy" to "visible-password-policy", "password" to "source-password-secret", "APIKey" to "source-api-acronym-secret", "APIToken" to "source-api-token-secret", "IDToken" to "source-id-token-secret", "OAuthToken" to "source-oauth-token-secret", "Client Secret" to "source-client-secret", "nested" to mapOf("apiKey" to "source-api-secret", "items" to listOf(mapOf("refresh-token" to "source-refresh-secret"), mapOf("credentials" to "source-credentials-secret"))))', + command: (path) => { + const kotlinc = Bun.which("kotlinc") ?? ""; + const java = Bun.which("java") ?? ""; + const jar = path.replace(/\.kt$/, ".jar"); + const script = `"${kotlinc}" -include-runtime -d "${jar}" "${path}" && "${java}" -jar "${jar}"`; + return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + }, + cycleData: "__agentCycle", + cyclePrelude: + 'val __agentCycle = java.util.LinkedHashMap(); __agentCycle.put("self", __agentCycle)', + file: "kotlin-file.kt", + ingest: "file", + language: "kotlin", + runtime: + Bun.which("kotlinc") !== null && Bun.which("java") !== null ? Bun.which("kotlinc") : null, + sharedData: 'mapOf("left" to __agentShared, "right" to __agentShared)', + sharedPrelude: + 'val __agentShared = java.util.LinkedHashMap(); __agentShared.put("APIKey", "source-shared-secret")', + }, ]; async function run(command: string[], env: Record = process.env) { @@ -584,7 +606,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); runtimeTest( @@ -614,7 +636,7 @@ describe("live language templates", () => { expect(executed.exitCode, executed.stderr).toBe(0); expect(`${executed.stdout}\n${executed.stderr}`).toContain("application-completed"); }, - 90_000, + 180_000, ); runtimeTest( @@ -669,7 +691,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); runtimeTest( @@ -720,7 +742,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); } @@ -859,6 +881,22 @@ describe("live language templates", () => { "}", ].join("\n"), }, + kotlin: { + dataExpression: "__agentValue", + prelude: [ + "val __agentValue = AgentCustomValue()", + '__agentValue.APIKey = "source-value-api-secret"', + '__agentValue.designToken = "visible-value-design-token"', + '__agentValue.Nested = java.util.Map.of("OAuthToken", "source-value-oauth-secret")', + ].join("\n"), + extraTypes: [ + "class AgentCustomValue {", + ' @JvmField var APIKey: String = ""', + ' @JvmField var Nested: Any = ""', + ' @JvmField var designToken: String = ""', + "}", + ].join("\n"), + }, powershell: { dataExpression: "$__agentValue", prelude: [ @@ -877,7 +915,7 @@ describe("live language templates", () => { }, }; - for (const language of ["csharp", "powershell", "java"]) { + for (const language of ["csharp", "powershell", "java", "kotlin"]) { const fixture = fixtures.find((candidate) => candidate.language === language); const unavailable = fixture?.runtime === null; const runtimeTest = unavailable && !requireRuntimes ? test.skip : test; @@ -939,7 +977,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); } @@ -985,7 +1023,7 @@ describe("live language templates", () => { expect(() => JSON.parse(line)).not.toThrow(); } }, - 90_000, + 180_000, ); } }); diff --git a/tests/fixtures/languages/kotlin-file.kt b/tests/fixtures/languages/kotlin-file.kt new file mode 100644 index 0000000..ad43917 --- /dev/null +++ b/tests/fixtures/languages/kotlin-file.kt @@ -0,0 +1,7 @@ +/* __EXTRA_TYPES__ */ +fun main() { + __CALL_TEMPLATE__ + println("application-completed") +} + +__HELPER_TEMPLATE__ From c29b053cddc380b9b84812ca089a0e1d90c19cdb Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 14:23:13 -0700 Subject: [PATCH 06/18] fix: address branch-review findings for the new language templates DESIGN.md advertised-list contradiction; C NULL-key guard on malloc failure; Rust allow attributes so user builds stay warning-free; C++ int constructor to remove the LL-suffix footgun. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 4 ++-- src/probes/c.ts | 3 +++ src/probes/cpp.ts | 1 + src/probes/rust.ts | 2 ++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ec590a2..f62dbe3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -121,8 +121,8 @@ The first supported combinations are: - Java + file - Kotlin + file -Every advertised combination must pass a live end-to-end test with its real runtime. Kotlin and -shell are not advertised until a safe serializer contract is defined. +Every advertised combination must pass a live end-to-end test with its real runtime. Shell is +not advertised until a safe serializer contract is defined. ### `debug-mode reset --session ` diff --git a/src/probes/c.ts b/src/probes/c.ts index 3199810..c745115 100644 --- a/src/probes/c.ts +++ b/src/probes/c.ts @@ -325,6 +325,9 @@ export function renderCTemplate(): ProbeTemplates { "}", "", "static int __agent_debug_is_secret_key(const char *key) {", + " if (key == NULL) {", + " return 0;", + " }", " char *normalized = __agent_debug_normalize_key(key);", " if (normalized == NULL) {", " return 0;", diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts index 8921ad5..d3032b8 100644 --- a/src/probes/cpp.ts +++ b/src/probes/cpp.ts @@ -36,6 +36,7 @@ export function renderCppTemplate(): ProbeTemplates { "", " AgentValue() {}", " AgentValue(bool v) { kind = AgentKind::Bool; boolValue = v; }", + " AgentValue(int v) { kind = AgentKind::Int; intValue = v; }", " AgentValue(long long v) { kind = AgentKind::Int; intValue = v; }", " AgentValue(double v) { kind = AgentKind::Double; doubleValue = v; }", " AgentValue(const char* v) { kind = AgentKind::Str; strValue = v; }", diff --git a/src/probes/rust.ts b/src/probes/rust.ts index 3813750..72bf09a 100644 --- a/src/probes/rust.ts +++ b/src/probes/rust.ts @@ -10,6 +10,7 @@ export function renderRustTemplate(): ProbeTemplates { helperTemplate: [ "// #region agent log", "#[derive(Clone)]", + "#[allow(dead_code)]", "enum AgentValue {", " Null,", " Bool(bool),", @@ -236,6 +237,7 @@ export function renderRustTemplate(): ProbeTemplates { " }", "}", "", + "#[allow(non_snake_case)]", "fn __agentDebugEmit(hypothesis_id: &str, location: &str, message: &str, data: AgentValue) {", " let mut out = String::new();", " out.push('{');", From 14a641b2c7b36b9e39d4de4fbaf750f551bcbc41 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 15:39:42 -0700 Subject: [PATCH 07/18] fix: make compiled-language live probes spawn without a shell The C/C++/Rust/Kotlin fixtures compiled and ran their probe binaries through a single `cmd /c " && "` (or `sh -c`) command string. On Windows, cmd.exe mangles the quotes of that compound string, so every step failed with `'"...rustc.exe"' is not recognized`. Run each step as its own shell-free Bun.spawn argv instead: a new runSteps helper executes compile then run in order, stopping on the first non-zero exit. Kotlin's kotlinc resolves to kotlinc.bat on Windows, which cmd.exe must interpret, so its compile step is wrapped in `cmd /c` with the batch path and arguments passed as separate argv elements (correctly quoted by Bun) rather than one pre-quoted string. Also build the C++ cyclic-drop fixture's 100-deep structure by appending to arrayValue explicitly. `AgentValue{ __agent_cycle }` is a single-element braced-init-list whose overload resolution differs across compilers (Apple clang selects the initializer_list constructor and wraps; clang 18 / gcc 13 select the copy constructor and leave a scalar), so the nesting depth the serializer saw varied by toolchain and the depth-64 cap only tripped locally. The explicit array wrap nests identically everywhere, so the value is dropped on every compiler. Co-Authored-By: Claude Fable 5 --- tests/e2e/languages/live-probes.test.ts | 83 +++++++++++++++++-------- 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 8504bdc..29b07dd 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -61,7 +61,11 @@ interface TemplateOutput extends ProbeTemplates { interface Fixture { callData: string; - command: (fixturePath: string) => string[]; + // A sequence of argv steps run in order without a shell; each step must exit 0 + // before the next runs. Compiled languages use two steps (compile, then run); + // interpreted languages use one. Avoiding a shell sidesteps cmd.exe quote + // mangling of compound "&&" command strings on Windows. + command: (fixturePath: string) => string[][]; cycleData: string; cyclePrelude: string; file: string; @@ -77,7 +81,7 @@ const fixtures: Fixture[] = [ { callData: '{ value: 42, designToken: "visible-design-token", fortuneCookie: "visible-fortune-cookie", secretSauceName: "visible-secret-sauce", tokenCount: 7, passwordPolicy: "visible-password-policy", password: "source-password-secret", APIKey: "source-api-acronym-secret", APIToken: "source-api-token-secret", IDToken: "source-id-token-secret", OAuthToken: "source-oauth-token-secret", "Client Secret": "source-client-secret", nested: { apiKey: "source-api-secret", items: [{ "refresh-token": "source-refresh-secret" }, { credentials: "source-credentials-secret" }] } }', - command: (path) => [Bun.which("node") ?? "", path], + command: (path) => [[Bun.which("node") ?? "", path]], cycleData: "__agentCycle", cyclePrelude: "const __agentCycle = {}; __agentCycle.self = __agentCycle;", file: "javascript-http.mjs", @@ -90,7 +94,7 @@ const fixtures: Fixture[] = [ { callData: '{ value: 42, designToken: "visible-design-token", fortuneCookie: "visible-fortune-cookie", secretSauceName: "visible-secret-sauce", tokenCount: 7, passwordPolicy: "visible-password-policy", password: "source-password-secret", APIKey: "source-api-acronym-secret", APIToken: "source-api-token-secret", IDToken: "source-id-token-secret", OAuthToken: "source-oauth-token-secret", "Client Secret": "source-client-secret", nested: { apiKey: "source-api-secret", items: [{ "refresh-token": "source-refresh-secret" }, { credentials: "source-credentials-secret" }] } }', - command: (path) => [process.execPath, path], + command: (path) => [[process.execPath, path]], cycleData: "__agentCycle", cyclePrelude: "const __agentCycle: Record = {}; __agentCycle.self = __agentCycle;", @@ -105,7 +109,7 @@ const fixtures: Fixture[] = [ { callData: '{"value": 42, "designToken": "visible-design-token", "fortuneCookie": "visible-fortune-cookie", "secretSauceName": "visible-secret-sauce", "tokenCount": 7, "passwordPolicy": "visible-password-policy", "password": "source-password-secret", "APIKey": "source-api-acronym-secret", "APIToken": "source-api-token-secret", "IDToken": "source-id-token-secret", "OAuthToken": "source-oauth-token-secret", "Client Secret": "source-client-secret", "nested": {"apiKey": "source-api-secret", "items": ({"refresh-token": "source-refresh-secret"}, {"credentials": "source-credentials-secret"})}}', - command: (path) => [Bun.which("python3") ?? "", path], + command: (path) => [[Bun.which("python3") ?? "", path]], cycleData: "__agent_cycle", cyclePrelude: '__agent_cycle = {}; __agent_cycle["self"] = __agent_cycle', file: "python-file.py", @@ -118,7 +122,7 @@ const fixtures: Fixture[] = [ { callData: 'map[string]any{"value": 42, "designToken": "visible-design-token", "fortuneCookie": "visible-fortune-cookie", "secretSauceName": "visible-secret-sauce", "tokenCount": 7, "passwordPolicy": "visible-password-policy", "password": "source-password-secret", "APIKey": "source-api-acronym-secret", "APIToken": "source-api-token-secret", "IDToken": "source-id-token-secret", "OAuthToken": "source-oauth-token-secret", "Client Secret": "source-client-secret", "nested": map[string]any{"apiKey": "source-api-secret", "items": []any{map[string]string{"refresh-token": "source-refresh-secret"}, map[string]string{"credentials": "source-credentials-secret"}}}}', - command: (path) => [Bun.which("go") ?? "", "run", path], + command: (path) => [[Bun.which("go") ?? "", "run", path]], cycleData: "__agentCycle", cyclePrelude: '__agentCycle := map[string]any{}; __agentCycle["self"] = __agentCycle', file: "go-file.go", @@ -131,7 +135,7 @@ const fixtures: Fixture[] = [ { callData: '{ "value" => 42, "designToken" => "visible-design-token", "fortuneCookie" => "visible-fortune-cookie", "secretSauceName" => "visible-secret-sauce", "tokenCount" => 7, "passwordPolicy" => "visible-password-policy", "password" => "source-password-secret", "APIKey" => "source-api-acronym-secret", "APIToken" => "source-api-token-secret", "IDToken" => "source-id-token-secret", "OAuthToken" => "source-oauth-token-secret", "Client Secret" => "source-client-secret", "nested" => { "apiKey" => "source-api-secret", "items" => [{ "refresh-token" => "source-refresh-secret" }, { "credentials" => "source-credentials-secret" }] } }', - command: (path) => [Bun.which("ruby") ?? "", path], + command: (path) => [[Bun.which("ruby") ?? "", path]], cycleData: "__agent_cycle", cyclePrelude: '__agent_cycle = {}; __agent_cycle["self"] = __agent_cycle', file: "ruby-file.rb", @@ -144,7 +148,7 @@ const fixtures: Fixture[] = [ { callData: '["value" => 42, "designToken" => "visible-design-token", "fortuneCookie" => "visible-fortune-cookie", "secretSauceName" => "visible-secret-sauce", "tokenCount" => 7, "passwordPolicy" => "visible-password-policy", "password" => "source-password-secret", "APIKey" => "source-api-acronym-secret", "APIToken" => "source-api-token-secret", "IDToken" => "source-id-token-secret", "OAuthToken" => "source-oauth-token-secret", "Client Secret" => "source-client-secret", "nested" => ["apiKey" => "source-api-secret", "items" => [["refresh-token" => "source-refresh-secret"], ["credentials" => "source-credentials-secret"]]]]', - command: (path) => [Bun.which("php") ?? "", path], + command: (path) => [[Bun.which("php") ?? "", path]], cycleData: "$__agentCycle", cyclePrelude: '$__agentCycle = []; $__agentCycle["self"] = &$__agentCycle;', file: "php-file.php", @@ -157,7 +161,7 @@ const fixtures: Fixture[] = [ { callData: '@{ value = 42; designToken = "visible-design-token"; fortuneCookie = "visible-fortune-cookie"; secretSauceName = "visible-secret-sauce"; tokenCount = 7; passwordPolicy = "visible-password-policy"; password = "source-password-secret"; APIKey = "source-api-acronym-secret"; APIToken = "source-api-token-secret"; IDToken = "source-id-token-secret"; OAuthToken = "source-oauth-token-secret"; "Client Secret" = "source-client-secret"; nested = @{ apiKey = "source-api-secret"; items = @(@{ "refresh-token" = "source-refresh-secret" }, @{ credentials = "source-credentials-secret" }) } }', - command: (path) => [Bun.which("pwsh") ?? "", "-File", path], + command: (path) => [[Bun.which("pwsh") ?? "", "-File", path]], cycleData: "$__agentCycle", cyclePrelude: "$__agentCycle = @{}; $__agentCycle.self = $__agentCycle", file: "powershell-file.ps1", @@ -170,7 +174,7 @@ const fixtures: Fixture[] = [ { callData: 'new Dictionary { ["value"] = 42, ["designToken"] = "visible-design-token", ["fortuneCookie"] = "visible-fortune-cookie", ["secretSauceName"] = "visible-secret-sauce", ["tokenCount"] = 7, ["passwordPolicy"] = "visible-password-policy", ["password"] = "source-password-secret", ["APIKey"] = "source-api-acronym-secret", ["APIToken"] = "source-api-token-secret", ["IDToken"] = "source-id-token-secret", ["OAuthToken"] = "source-oauth-token-secret", ["Client Secret"] = "source-client-secret", ["nested"] = new Dictionary { ["apiKey"] = "source-api-secret", ["items"] = new object?[] { new Dictionary { ["refresh-token"] = "source-refresh-secret" }, new Dictionary { ["credentials"] = "source-credentials-secret" } } } }', - command: (path) => [Bun.which("dotnet") ?? "", "run", "--project", join(path, "..")], + command: (path) => [[Bun.which("dotnet") ?? "", "run", "--project", join(path, "..")]], cycleData: "__agentCycle", cyclePrelude: 'var __agentCycle = new Dictionary(); __agentCycle["self"] = __agentCycle;', @@ -196,7 +200,7 @@ const fixtures: Fixture[] = [ { callData: '["value": 42, "designToken": "visible-design-token", "fortuneCookie": "visible-fortune-cookie", "secretSauceName": "visible-secret-sauce", "tokenCount": 7, "passwordPolicy": "visible-password-policy", "password": "source-password-secret", "APIKey": "source-api-acronym-secret", "APIToken": "source-api-token-secret", "IDToken": "source-id-token-secret", "OAuthToken": "source-oauth-token-secret", "Client Secret": "source-client-secret", "nested": ["apiKey": "source-api-secret", "items": [["refresh-token": "source-refresh-secret"], ["credentials": "source-credentials-secret"]]]]', - command: (path) => [Bun.which("swift") ?? "", path], + command: (path) => [[Bun.which("swift") ?? "", path]], cycleData: "__agentCycle", cyclePrelude: 'let __agentCycle = NSMutableDictionary(); __agentCycle["self"] = __agentCycle', file: "swift-file.swift", @@ -215,8 +219,7 @@ const fixtures: Fixture[] = [ command: (path) => { const rustc = Bun.which("rustc") ?? ""; const binary = path.replace(/\.rs$/, process.platform === "win32" ? ".exe" : ".out"); - const script = `"${rustc}" -A warnings "${path}" -o "${binary}" && "${binary}"`; - return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + return [[rustc, "-A", "warnings", path, "-o", binary], [binary]]; }, cycleData: "__agent_cycle", cyclePrelude: @@ -240,12 +243,16 @@ const fixtures: Fixture[] = [ command: (path) => { const compiler = Bun.which("clang++") ?? Bun.which("g++") ?? ""; const binary = path.replace(/\.cpp$/, process.platform === "win32" ? ".exe" : ".out"); - const script = `"${compiler}" -std=c++17 "${path}" -o "${binary}" && "${binary}"`; - return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + return [[compiler, "-std=c++17", path, "-o", binary], [binary]]; }, cycleData: "__agent_cycle", + // Build the deep structure by explicitly appending to arrayValue rather than + // AgentValue{ __agent_cycle }: a single-element braced-init-list is ambiguous + // between the initializer_list constructor and the copy + // constructor, and compilers disagree on which wins (Apple clang wraps, + // clang 18 / gcc 13 copy), so the nesting depth would vary by toolchain. cyclePrelude: - "AgentValue __agent_cycle = AgentValue(0LL); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = AgentValue{ __agent_cycle }; }", + "AgentValue __agent_cycle = AgentValue(0LL); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { AgentValue __agent_wrap; __agent_wrap.kind = AgentKind::Array; __agent_wrap.arrayValue.push_back(__agent_cycle); __agent_cycle = __agent_wrap; }", file: "cpp-file.cpp", ingest: "file", language: "cpp", @@ -263,8 +270,7 @@ const fixtures: Fixture[] = [ command: (path) => { const compiler = Bun.which("clang") ?? Bun.which("gcc") ?? ""; const binary = path.replace(/\.c$/, process.platform === "win32" ? ".exe" : ".out"); - const script = `"${compiler}" -std=c99 "${path}" -o "${binary}" && "${binary}"`; - return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + return [[compiler, "-std=c99", path, "-o", binary], [binary]]; }, cycleData: "__agent_cycle", cyclePrelude: @@ -283,7 +289,7 @@ const fixtures: Fixture[] = [ { callData: 'java.util.Map.ofEntries(java.util.Map.entry("value", 42), java.util.Map.entry("designToken", "visible-design-token"), java.util.Map.entry("fortuneCookie", "visible-fortune-cookie"), java.util.Map.entry("secretSauceName", "visible-secret-sauce"), java.util.Map.entry("tokenCount", 7), java.util.Map.entry("passwordPolicy", "visible-password-policy"), java.util.Map.entry("password", "source-password-secret"), java.util.Map.entry("APIKey", "source-api-acronym-secret"), java.util.Map.entry("APIToken", "source-api-token-secret"), java.util.Map.entry("IDToken", "source-id-token-secret"), java.util.Map.entry("OAuthToken", "source-oauth-token-secret"), java.util.Map.entry("Client Secret", "source-client-secret"), java.util.Map.entry("nested", java.util.Map.ofEntries(java.util.Map.entry("apiKey", "source-api-secret"), java.util.Map.entry("items", java.util.List.of(java.util.Map.of("refresh-token", "source-refresh-secret"), java.util.Map.of("credentials", "source-credentials-secret"))))))', - command: (path) => [Bun.which("java") ?? "", path], + command: (path) => [[Bun.which("java") ?? "", path]], cycleData: "__agentCycle", cyclePrelude: 'java.util.Map __agentCycle = new java.util.LinkedHashMap<>(); __agentCycle.put("self", __agentCycle);', @@ -302,8 +308,14 @@ const fixtures: Fixture[] = [ const kotlinc = Bun.which("kotlinc") ?? ""; const java = Bun.which("java") ?? ""; const jar = path.replace(/\.kt$/, ".jar"); - const script = `"${kotlinc}" -include-runtime -d "${jar}" "${path}" && "${java}" -jar "${jar}"`; - return process.platform === "win32" ? ["cmd", "/c", script] : ["sh", "-c", script]; + // On Windows kotlinc resolves to kotlinc.bat, which cmd.exe must interpret; + // passing the batch path and its arguments as separate argv elements lets + // Bun quote each one correctly instead of mangling a compound string. + const compile = + process.platform === "win32" + ? ["cmd", "/c", kotlinc, "-include-runtime", "-d", jar, path] + : [kotlinc, "-include-runtime", "-d", jar, path]; + return [compile, [java, "-jar", jar]]; }, cycleData: "__agentCycle", cyclePrelude: @@ -334,6 +346,25 @@ async function run(command: string[], env: Record = return { exitCode, stderr, stdout }; } +// Runs argv steps in order, concatenating their output. Stops at the first step +// that exits non-zero and returns its exit code, so a failed compile surfaces +// the compiler's stderr and the run step never executes. +async function runSteps(steps: string[][], env: Record = process.env) { + let exitCode = 0; + let stdout = ""; + let stderr = ""; + for (const step of steps) { + const result = await run(step, env); + stdout += result.stdout; + stderr += result.stderr; + exitCode = result.exitCode; + if (exitCode !== 0) { + break; + } + } + return { exitCode, stderr, stdout }; +} + async function runCli(home: string, args: string[]) { return run([executable, ...args], { ...process.env, @@ -564,7 +595,7 @@ describe("live language templates", () => { await writeFile(fixturePath, materialize(source, rendered.data, fixture, target)); const startedAt = Date.now(); - const executed = await run(fixture.command(fixturePath)); + const executed = await runSteps(fixture.command(fixturePath)); const finishedAt = Date.now(); expect(executed.exitCode, executed.stderr).toBe(0); const raw = capture @@ -632,7 +663,7 @@ describe("live language templates", () => { materialize(source, rendered.data, fixture, unavailableTarget), ); - const executed = await run(fixture.command(fixturePath)); + const executed = await runSteps(fixture.command(fixturePath)); expect(executed.exitCode, executed.stderr).toBe(0); expect(`${executed.stdout}\n${executed.stderr}`).toContain("application-completed"); }, @@ -671,7 +702,7 @@ describe("live language templates", () => { fixture.cyclePrelude, ), ); - const executed = await run(fixture.command(fixturePath)); + const executed = await runSteps(fixture.command(fixturePath)); expect(executed.exitCode, executed.stderr).toBe(0); expect(`${executed.stdout}\n${executed.stderr}`).toContain("application-completed"); await Bun.sleep(200); @@ -726,7 +757,7 @@ describe("live language templates", () => { fixture.sharedPrelude, ), ); - const executed = await run(fixture.command(fixturePath)); + const executed = await runSteps(fixture.command(fixturePath)); expect(executed.exitCode, executed.stderr).toBe(0); const raw = capture ? capture.bodies.join("") @@ -810,7 +841,7 @@ describe("live language templates", () => { const fixturePath = join(workspace, fixture.file); await writeFile(fixturePath, materialize(source, rendered.data, fixture, capture.url, 200)); - const executed = await run(fixture.command(fixturePath)); + const executed = await runSteps(fixture.command(fixturePath)); expect(executed.exitCode, executed.stderr).toBe(0); expect(executed.stdout).toContain("cleanup-count:200"); expect(capture.bodies).toHaveLength(200); @@ -959,7 +990,7 @@ describe("live language templates", () => { ); try { - const executed = await run(fixture.command(fixturePath)); + const executed = await runSteps(fixture.command(fixturePath)); expect(executed.exitCode, executed.stderr).toBe(0); const raw = await readFile(created.data.appendPath, "utf8"); expect(raw).not.toContain("source-value-api-secret"); From 00c8c39f733c6ae27071819255f902652b1b13e7 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 15:53:31 -0700 Subject: [PATCH 08/18] fix: retry dist removal in build-binary to survive Windows file locks On Windows a just-exited process can hold an executable's file handle open for a short window after termination. The language e2e step spawns detached debug-mode daemons that run dist/debug-mode.exe; `stop` returns as soon as a daemon acks shutdown, before the OS releases the handle. The next CI step's build:binary then races that release and fails with `EACCES: permission denied, rm '...\dist'`, failing the Distribution and System test steps. Retry the dist removal with a short backoff (up to ~5s) on EACCES/EPERM/EBUSY so the rebuild proceeds once the handle is freed; other platforms release handles on exit and still succeed on the first attempt. Co-Authored-By: Claude Fable 5 --- scripts/build-binary.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/build-binary.ts b/scripts/build-binary.ts index 39aca9e..6d71298 100644 --- a/scripts/build-binary.ts +++ b/scripts/build-binary.ts @@ -6,7 +6,30 @@ const outputDirectory = join(root, "dist"); const executableName = process.platform === "win32" ? "debug-mode.exe" : "debug-mode"; const executable = join(outputDirectory, executableName); -await rm(outputDirectory, { force: true, recursive: true }); +// On Windows a just-exited process (e.g. a debug-mode daemon that ran the +// previous test step's dist/debug-mode.exe) can keep the executable's file +// handle open for a short window after it terminates, so removing dist races +// that release and fails with EACCES/EPERM/EBUSY. Retry with a short backoff +// until the handle is released; other platforms release handles on exit and +// succeed on the first attempt. +async function removeOutputDirectory(): Promise { + const maxAttempts = 20; + for (let attempt = 1; ; attempt += 1) { + try { + await rm(outputDirectory, { force: true, recursive: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const retriable = code === "EACCES" || code === "EPERM" || code === "EBUSY"; + if (!retriable || attempt >= maxAttempts) { + throw error; + } + await Bun.sleep(250); + } + } +} + +await removeOutputDirectory(); await mkdir(outputDirectory, { recursive: true }); const processHandle = Bun.spawn( From 04e07807821c7a8359eae7e3a71e2e2e35a7f0c8 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 16:16:06 -0700 Subject: [PATCH 09/18] feat: add dataEncoding and placement to the template contract Extend ProbeTemplates with a dataEncoding discriminator ("native-json-value" | "serialized-json") and machine-readable placement metadata for the helper and call insertion points. Every existing renderer declares metadata matching today's behavior (native-json-value; helper file-start for C/C++, top-level elsewhere). The template command surfaces the new fields via JSON passthrough, and the pretty renderer prints a data encoding and placement section. Co-Authored-By: Claude Fable 5 --- specs/lightweight-probe-templates.md | 474 ++++++++++++++++++++++ src/cli/pretty-renderer.ts | 21 +- src/probes/c.ts | 2 + src/probes/cpp.ts | 2 + src/probes/csharp.ts | 2 + src/probes/go.ts | 2 + src/probes/java.ts | 2 + src/probes/javascript.ts | 2 + src/probes/kotlin.ts | 2 + src/probes/php.ts | 2 + src/probes/powershell.ts | 2 + src/probes/python.ts | 2 + src/probes/render.ts | 7 + src/probes/ruby.ts | 2 + src/probes/rust.ts | 2 + src/probes/swift.ts | 2 + src/probes/typescript.ts | 2 + tests/contract/result-rendering.test.ts | 5 + tests/contract/template-renderers.test.ts | 5 + 19 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 specs/lightweight-probe-templates.md diff --git a/specs/lightweight-probe-templates.md b/specs/lightweight-probe-templates.md new file mode 100644 index 0000000..b3233cc --- /dev/null +++ b/specs/lightweight-probe-templates.md @@ -0,0 +1,474 @@ +# Lightweight probe templates + +Status: Proposed + +## Summary + +Replace the large, language-specific object serializers in native probe templates with small +emitters that accept an expression producing serialized JSON. + +The agent remains responsible for choosing safe diagnostic values and excluding secrets. The +background service continues to validate events and redact accepted data before canonical +persistence. + +This change preserves the existing session, transport, event, query, and region-marker contracts. +It changes only how probe call sites provide `data`. + +## Problem + +The current Rust, C++, and C templates embed complete JSON value models, recursive serializers, +secret-key normalization, redaction, depth handling, and collection builders directly into the +instrumented application. + +That design has several costs: + +- The helper pasted into an application can be hundreds of lines long. +- Rust call sites use `AgentValue` and `adbg!` instead of the application's normal values. +- C++ call sites use `AgentValue` initializer-list heuristics and explicit integer suffixes. +- C call sites allocate an owned object tree through `adbg_*` builder functions. +- Generic helper names can conflict with application symbols. +- Includes, imports, macros, and helpers must be inserted at language-specific legal locations. +- Every language duplicates the same recursive serialization and secret-redaction policy. +- Tests prove isolated fixtures but do not prove insertion into representative application files. + +The helper implementation is much larger than the observation call it supports. Instrumentation +should be easy to inspect and remove, and should introduce as little application code as possible. + +## Goals + +- Keep native helper templates small and dependency-free. +- Preserve structured JSON when the application already has a serializer. +- Support an escaped text fallback when structured serialization is unavailable. +- Preserve the existing five-field event schema. +- Preserve the rule that observation failures never change application behavior. +- Preserve the 64 KiB encoded-event limit. +- Preserve foldable `agent log` regions. +- Continue using HTTP for JavaScript and TypeScript and file append for current native templates. +- Make placement requirements explicit for imports, helpers, and call sites. +- Migrate incrementally, beginning with Rust and C++. + +## Non-goals + +- Adding an AST-based `instrument` or `uninstrument` command. +- Adding probe SDK packages, crates, or external JSON dependencies. +- Supporting arbitrary application objects without serialization. +- Changing session creation, reset behavior, log storage, query behavior, or daemon lifecycle. +- Changing the language-to-ingest compatibility matrix. +- Guaranteeing delivery when the process crashes or the destination is unavailable. +- Automatically discovering whether a value contains secrets. + +## Design decision + +### Serialized JSON is the native probe interface + +For templates whose language does not provide a standard structured JSON value, the call template +accepts an expression that evaluates to a complete serialized JSON value: + +```text +__DATA_JSON_EXPRESSION__ +``` + +The value may represent any valid JSON type: + +- object; +- array; +- string; +- number; +- boolean; +- null. + +The emitter inserts this value directly after the event envelope's `"data":` key. It does not quote +or reinterpret the serialized value. + +For example, an expression returning: + +```json +{"queueSize":3,"ready":true} +``` + +produces: + +```json +{ + "hypothesisId": "H1", + "location": "src/worker.cpp:42", + "message": "Queue state before pop", + "data": {"queueSize":3,"ready":true}, + "timestamp": 1784313728231 +} +``` + +The append file contains the event as one newline-terminated record. + +### Use the application's serializer when available + +The agent should prefer serialization facilities already present in the application. + +Rust with `serde_json`: + +```rust +// #region agent log +if let Ok(__agent_debug_data) = serde_json::to_string(&serde_json::json!({ + "queueSize": queue.len(), + "ready": ready, +})) { + agent_debug_mode::emit( + "H1", + &format!("{}:{}", file!(), line!()), + "Queue state before pop", + &__agent_debug_data, + ); +} +// #endregion +``` + +C++ with nlohmann JSON: + +```cpp +// #region agent log +agent_debug_mode::emit( + "H1", + __FILE__ ":" AGENT_DEBUG_STRINGIFY(__LINE__), + "Queue state before pop", + nlohmann::json{ + {"queueSize", queue.size()}, + {"ready", ready}, + }.dump()); +// #endregion +``` + +These are call-site examples, not dependencies added by debug-mode. Templates must not assume that +either library is installed. + +### Fall back to an escaped JSON string + +When the application has no structured JSON serializer, the helper exposes one small utility that +encodes text as a JSON string value: + +```text +agent_debug_mode::json_string(text) +``` + +The agent may then log a concise textual representation: + +```cpp +// #region agent log +std::ostringstream __agent_debug_text; +__agent_debug_text << "queueSize=" << queue.size() << " ready=" << ready; +agent_debug_mode::emit( + "H1", + __FILE__ ":" AGENT_DEBUG_STRINGIFY(__LINE__), + "Queue state before pop", + agent_debug_mode::json_string(__agent_debug_text.str())); +// #endregion +``` + +The resulting event's `data` field is a JSON string. Queries can still filter by hypothesis, +location, message, and timestamp, but cannot address fields inside the text. + +The fallback does not manually construct an object from dynamic strings. Manually concatenating +unescaped strings into raw JSON is invalid and must not be recommended. + +## Secret-handling contract + +Probe call sites must not include secrets, credentials, authentication material, or unnecessary +personally identifiable information. + +This is an explicit caller contract: + +- The template placeholder description tells the agent to supply valid JSON containing no secrets. +- The Agent Skill instructs the agent to choose the smallest diagnostic value needed to test a + hypothesis. +- Native helpers do not scan keys or redact values before transport. +- The background service still validates and redacts accepted JSON before writing canonical + evidence. + +For file ingestion, the session's `incoming.ndjson` file temporarily contains the caller-provided +event before daemon normalization. Daemon redaction therefore remains defense-in-depth, not +permission to send secrets. + +This decision intentionally removes the client-side secret normalization and redaction code that +accounts for much of the current helper size. + +## Template interface changes + +Extend the template metadata so callers can distinguish native values from serialized JSON: + +```typescript +export type ProbeDataEncoding = "native-json-value" | "serialized-json"; + +export interface ProbeTemplates { + language: TemplateLanguage; + ingest: IngestMethod; + dataEncoding: ProbeDataEncoding; + helperTemplate: string; + callTemplate: string; + placeholders: Record; + placement: { + helper: "file-start" | "top-level"; + call: "statement"; + }; +} +``` + +Initial assignments: + +- JavaScript and TypeScript use `native-json-value`. +- Rust, C++, and C use `serialized-json` after migration. +- Other languages retain their current behavior until migrated. + +During the incremental migration, a language may continue using +`__DATA_EXPRESSION__`. A language switches atomically to `__DATA_JSON_EXPRESSION__` when its helper, +call template, fixture, and tests are updated together. + +The serialized-JSON placeholder description is: + +```text +Replace with an expression that returns one complete valid JSON value containing no secrets. +Use the application's serializer when available; otherwise pass +agent_debug_mode::json_string(text). +``` + +## Lightweight helper responsibilities + +The native helper performs only these operations: + +1. Escape the constant `hypothesisId`, `location`, and `message` strings. +2. Accept a caller-provided serialized JSON value. +3. Add a Unix epoch timestamp in milliseconds. +4. Construct one newline-terminated event. +5. Reject an encoded event larger than 65,536 bytes. +6. Append the complete event to `__APPEND_PATH__`. +7. Swallow serialization-envelope and write failures so the observation cannot affect the + application. + +The helper does not: + +- model arbitrary JSON values; +- recursively traverse data; +- detect cycles; +- enforce a nesting-depth limit; +- normalize secret-key names; +- redact values; +- allocate an intermediate object tree; +- inspect application types through reflection. + +### Raw JSON handling + +The emitter treats the data argument as raw JSON. It must not surround the argument with quotes. + +The helper may reject an empty data string immediately. Full JSON syntax validation remains in the +daemon because implementing a parser in every source language would recreate the boilerplate this +design removes. + +Malformed data therefore has the same outcome as any invalid event: + +- the application continues; +- no accepted event appears in canonical evidence; +- ingestion diagnostics identify the rejected record. + +### File append + +Native helpers should open the append path with restrictive permissions where the language and +platform APIs permit it. POSIX implementations should use append mode and a single write operation +for each encoded event. + +Cross-process append behavior must be verified for Rust, C++, and C. A helper must not claim atomic +record delivery solely because a high-level append stream was used. + +## Placement contract + +`helperTemplate` is not valid at an arbitrary source location. + +- C and C++ preprocessor definitions and includes belong at file start, before declarations. +- Rust helper items belong at module scope. +- Calls belong in statement position. + +The new `placement` metadata makes these requirements machine-readable and visible in CLI output. +The Agent Skill must instruct the agent to insert the helper once at the required location and each +call in its own `agent log` region. + +Symbols should use a language-appropriate private namespace or distinctive prefix. C++ helpers +should live under an `agent_debug_mode` namespace. Rust helpers should live in an +`agent_debug_mode` module. The migration must remove generic global names such as `AgentValue` and +`AgentKind`. + +## Migration sequence + +### 1. Update the shared template contract + +Modify: + +- `src/probes/render.ts`; +- `src/commands/template.ts` if output serialization needs the new metadata; +- `src/cli/pretty-renderer.ts` to display data encoding and placement; +- `tests/contract/template-renderers.test.ts`. + +Add `dataEncoding` and `placement` without changing any language implementation. Existing +renderers initially declare metadata matching their current behavior. + +### 2. Migrate Rust + +Modify: + +- `src/probes/rust.ts`; +- `tests/fixtures/languages/rust-file.rs`; +- Rust entries in `tests/e2e/languages/live-probes.test.ts`. + +Delete: + +- `AgentValue`; +- `From` implementations; +- `adbg!`; +- recursive value serialization; +- depth handling; +- secret-key normalization and redaction. + +Keep: + +- JSON string escaping for envelope metadata and text fallback; +- timestamp generation; +- size enforcement; +- secure append; +- failure isolation. + +### 3. Migrate C++ + +Modify: + +- `src/probes/cpp.ts`; +- `tests/fixtures/languages/cpp-file.cpp`; +- C++ entries in `tests/e2e/languages/live-probes.test.ts`. + +Delete: + +- `AgentKind`; +- `AgentValue`; +- initializer-list object detection; +- recursive value serialization; +- depth handling; +- secret-key normalization and redaction. + +Place remaining helper symbols in `agent_debug_mode`. + +### 4. Migrate C + +Modify: + +- `src/probes/c.ts`; +- `tests/fixtures/languages/c-file.c`; +- C entries in `tests/e2e/languages/live-probes.test.ts`. + +Delete the allocated `AgentValue` tree, variadic object builders, recursive serializer, depth +handling, and secret-redaction implementation. Retain only bounded envelope construction, string +escaping, timestamping, and append. + +### 5. Evaluate the remaining languages + +Measure each helper after Rust, C++, and C are complete. Migrate another language only when raw JSON +meaningfully reduces its helper and call complexity. Languages with safe, standard JSON facilities +may retain native structured values. + +Do not force every language through the serialized-JSON interface solely for uniformity. + +### 6. Update agent-facing documentation + +Modify: + +- `DESIGN.md`; +- `skills/agentic-debug-mode/SKILL.md`; +- `skills/agentic-debug-mode/REFERENCE.md`; +- `skills/agentic-debug-mode/EXAMPLES.md`; +- `specs/building-a-debug-mode-agent.md`. + +Document: + +- the raw-JSON placeholder contract; +- the escaped-text fallback; +- the caller's no-secrets responsibility; +- placement metadata; +- per-language region markers; +- `application/x-ndjson` as the actual HTTP content type. + +## Test strategy + +### Contract tests + +For every migrated serialized-JSON template, assert: + +- `dataEncoding` is `serialized-json`; +- placement metadata is correct; +- `callTemplate` contains `__DATA_JSON_EXPRESSION__`; +- the prior `__DATA_EXPRESSION__` placeholder is absent; +- the helper does not contain prior value-model symbols; +- the helper contains the 65,536-byte cap; +- helper and call templates contain the correct region markers. + +### Runtime tests + +Run each migrated template with its real compiler or runtime and verify: + +1. A serialized object is accepted and remains structured in canonical evidence. +2. Serialized arrays, strings, numbers, booleans, and null are accepted. +3. `agent_debug_mode::json_string` safely handles quotes, backslashes, control characters, and + newlines. +4. Malformed raw JSON does not alter application behavior and is not accepted. +5. Oversize JSON does not alter application behavior and is not emitted. +6. An unavailable append path does not alter application behavior. +7. Multiple processes append complete, independently parseable records. +8. Metadata containing quotes and control characters remains valid JSON. + +### Realistic insertion tests + +Add fixtures that resemble existing application files rather than empty single-file programs: + +- C++ with existing includes, a namespace, and an application type named `AgentValue`; +- Rust with existing imports and nested modules; +- C with system headers included before application declarations. + +These tests verify the documented placement contract and symbol isolation. + +### Removed tests + +For migrated templates, remove tests whose only purpose was exercising the deleted recursive value +model: + +- cyclic/depth rejection; +- shared-reference cloning; +- client-side secret-key redaction. + +Retain daemon redaction tests independently of probe templates. + +## Acceptance criteria + +The migration is complete when: + +- Rust, C++, and C no longer define custom recursive JSON value models. +- Their call templates accept serialized JSON expressions. +- Their helpers provide safe JSON-string fallback. +- Each generated helper contains no more than 150 nonblank source lines and is at least 60% smaller + than the helper it replaces. +- Their helpers contain only envelope, timestamp, bound, append, and JSON-string escaping logic. +- Existing structured event queries continue to work when callers provide object or array JSON. +- All failure-isolation tests pass. +- Native concurrent-append tests produce only complete parseable NDJSON lines. +- Realistic insertion fixtures compile. +- Agent-facing documentation accurately describes content types, placement, markers, and secret + responsibility. + +## Rollout and compatibility + +This changes generated source templates, not stored event data. Existing sessions and canonical +evidence remain compatible. + +Previously inserted helpers continue to work until the agent removes their regions. New template +output uses the lightweight helper. A reset does not require reinstrumentation because append paths +remain stable. + +The placeholder rename is intentionally breaking for tools that mechanically substitute current +template output. The CLI's template result exposes the placeholder map, so consumers should use +that map instead of assuming placeholder names. + +If a migrated language exposes unacceptable integration or concurrency failures, revert that +language to its previous renderer without reverting the shared `dataEncoding` and `placement` +metadata. diff --git a/src/cli/pretty-renderer.ts b/src/cli/pretty-renderer.ts index 74ab11e..765eacd 100644 --- a/src/cli/pretty-renderer.ts +++ b/src/cli/pretty-renderer.ts @@ -233,13 +233,23 @@ function isStringRecord(value: unknown): value is Record { ); } +function isPlacement(value: unknown): value is { call: string; helper: string } { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const placement = value as Record; + return typeof placement.helper === "string" && typeof placement.call === "string"; +} + function renderTemplateData(data: Record): string[] | undefined { - const { callTemplate, eventSchema, helperTemplate, placeholders } = data; + const { callTemplate, dataEncoding, eventSchema, helperTemplate, placeholders, placement } = data; if ( typeof helperTemplate !== "string" || typeof callTemplate !== "string" || + typeof dataEncoding !== "string" || !isStringRecord(placeholders) || - !isStringRecord(eventSchema) + !isStringRecord(eventSchema) || + !isPlacement(placement) ) { return undefined; } @@ -251,6 +261,13 @@ function renderTemplateData(data: Record): string[] | undefined "CALL TEMPLATE", ...callTemplate.split("\n"), "", + "DATA ENCODING", + dataEncoding, + "", + "PLACEMENT", + `Helper ${placement.helper}`, + `Call ${placement.call}`, + "", "PLACEHOLDERS", ...Object.entries(placeholders).map( ([name, meaning]) => `${name.padEnd(placeholderWidth)} ${meaning}`, diff --git a/src/probes/c.ts b/src/probes/c.ts index c745115..4fd3b03 100644 --- a/src/probes/c.ts +++ b/src/probes/c.ts @@ -576,6 +576,8 @@ export function renderCTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "c", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "file-start" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible C AgentValue that has no secrets.", diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts index d3032b8..6d82d8d 100644 --- a/src/probes/cpp.ts +++ b/src/probes/cpp.ts @@ -321,6 +321,8 @@ export function renderCppTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "cpp", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "file-start" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible C++ AgentValue that has no secrets.", diff --git a/src/probes/csharp.ts b/src/probes/csharp.ts index d1ff006..268ae50 100644 --- a/src/probes/csharp.ts +++ b/src/probes/csharp.ts @@ -121,6 +121,8 @@ export function renderCSharpTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "csharp", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible C# expression that has no secrets.", diff --git a/src/probes/go.ts b/src/probes/go.ts index 39168b2..85d3518 100644 --- a/src/probes/go.ts +++ b/src/probes/go.ts @@ -136,6 +136,8 @@ export function renderGoTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "go", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Go expression that has no secrets.", diff --git a/src/probes/java.ts b/src/probes/java.ts index a26c887..8666a76 100644 --- a/src/probes/java.ts +++ b/src/probes/java.ts @@ -263,6 +263,8 @@ export function renderJavaTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "java", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Java value that has no secrets.", diff --git a/src/probes/javascript.ts b/src/probes/javascript.ts index b48140c..4be3776 100644 --- a/src/probes/javascript.ts +++ b/src/probes/javascript.ts @@ -83,6 +83,8 @@ export function renderJavaScriptTemplate(): ProbeTemplates { helperTemplate, ingest: "http", language: "javascript", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __DATA_EXPRESSION__: "Replace with a JSON-compatible JavaScript expression that has no secrets.", diff --git a/src/probes/kotlin.ts b/src/probes/kotlin.ts index defa3fa..00a4a03 100644 --- a/src/probes/kotlin.ts +++ b/src/probes/kotlin.ts @@ -269,6 +269,8 @@ export function renderKotlinTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "kotlin", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Kotlin value that has no secrets.", diff --git a/src/probes/php.ts b/src/probes/php.ts index 26c414f..cd6c713 100644 --- a/src/probes/php.ts +++ b/src/probes/php.ts @@ -76,6 +76,8 @@ export function renderPhpTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "php", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible PHP expression that has no secrets.", diff --git a/src/probes/powershell.ts b/src/probes/powershell.ts index 9397a92..5f731c0 100644 --- a/src/probes/powershell.ts +++ b/src/probes/powershell.ts @@ -125,6 +125,8 @@ export function renderPowerShellTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "powershell", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: diff --git a/src/probes/python.ts b/src/probes/python.ts index 329eaa8..409b0d5 100644 --- a/src/probes/python.ts +++ b/src/probes/python.ts @@ -85,6 +85,8 @@ export function renderPythonTemplate(): ProbeTemplates { helperTemplate, ingest: "file", language: "python", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Python expression that has no secrets.", diff --git a/src/probes/render.ts b/src/probes/render.ts index c622045..6112f77 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -31,12 +31,19 @@ export type TemplateLanguage = export type IngestMethod = "http" | "file"; +export type ProbeDataEncoding = "native-json-value" | "serialized-json"; + export interface ProbeTemplates { language: TemplateLanguage; ingest: IngestMethod; + dataEncoding: ProbeDataEncoding; helperTemplate: string; callTemplate: string; placeholders: Record; + placement: { + helper: "file-start" | "top-level"; + call: "statement"; + }; } export const TEMPLATE_EVENT_SCHEMA = { diff --git a/src/probes/ruby.ts b/src/probes/ruby.ts index edddbbc..2225695 100644 --- a/src/probes/ruby.ts +++ b/src/probes/ruby.ts @@ -60,6 +60,8 @@ export function renderRubyTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "ruby", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Ruby expression that has no secrets.", diff --git a/src/probes/rust.ts b/src/probes/rust.ts index 72bf09a..2159c3d 100644 --- a/src/probes/rust.ts +++ b/src/probes/rust.ts @@ -283,6 +283,8 @@ export function renderRustTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "rust", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Rust AgentValue that has no secrets.", diff --git a/src/probes/swift.ts b/src/probes/swift.ts index 6bdcbf7..7316b83 100644 --- a/src/probes/swift.ts +++ b/src/probes/swift.ts @@ -88,6 +88,8 @@ export function renderSwiftTemplate(): ProbeTemplates { ].join("\n"), ingest: "file", language: "swift", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", __DATA_EXPRESSION__: "Replace with a JSON-compatible Swift expression that has no secrets.", diff --git a/src/probes/typescript.ts b/src/probes/typescript.ts index 56e0aea..060da96 100644 --- a/src/probes/typescript.ts +++ b/src/probes/typescript.ts @@ -92,6 +92,8 @@ export function renderTypeScriptTemplate(): ProbeTemplates { helperTemplate, ingest: "http", language: "typescript", + dataEncoding: "native-json-value", + placement: { call: "statement", helper: "top-level" }, placeholders: { __DATA_EXPRESSION__: "Replace with a JSON-compatible TypeScript expression that has no secrets.", diff --git a/tests/contract/result-rendering.test.ts b/tests/contract/result-rendering.test.ts index c8b834b..d460026 100644 --- a/tests/contract/result-rendering.test.ts +++ b/tests/contract/result-rendering.test.ts @@ -359,6 +359,11 @@ describe("command result rendering", () => { expect(rendered).toContain("CALL TEMPLATE"); expect(rendered).toContain("PLACEHOLDERS"); expect(rendered).toContain("EVENT SCHEMA"); + expect(rendered).toContain("DATA ENCODING"); + expect(rendered).toContain("native-json-value"); + expect(rendered).toContain("PLACEMENT"); + expect(rendered).toContain("Helper top-level"); + expect(rendered).toContain("Call statement"); expect(rendered).toContain("timestamp Unix epoch milliseconds"); expect(rendered).not.toContain("helperTemplate"); expect(rendered).not.toContain("callTemplate"); diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index e4f5b41..8558b00 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -41,6 +41,11 @@ describe("session-independent template renderers", () => { const otherTarget = ingest === "http" ? "__APPEND_PATH__" : "__INGEST_URL__"; expect(template).toMatchObject({ ingest, language }); + expect(template.dataEncoding).toBe("native-json-value"); + expect(template.placement.call).toBe("statement"); + expect(template.placement.helper).toBe( + language === "c" || language === "cpp" ? "file-start" : "top-level", + ); expect(template.helperTemplate).toContain(target); expect(template.helperTemplate).not.toContain(otherTarget); expect(template.callTemplate).not.toContain(target); From 5ecb52030416de20fe455be8fe8683290ffeb40e Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 16:33:21 -0700 Subject: [PATCH 10/18] feat: migrate the Rust probe template to serialized JSON Replace the Rust helper's recursive AgentValue model, adbg! macro, depth handling, and client-side secret redaction with a small `mod agent_debug_mode` that only escapes envelope metadata, timestamps, bounds, and appends. The call template now accepts a serialized-JSON expression (__DATA_JSON_EXPRESSION__), and json_string provides the escaped-text fallback. The daemon performs redaction, so serialized-json callers may write raw caller text to the incoming file. Helper drops from 260 to 96 nonblank lines (63% smaller). Runtime coverage: raw arrays/strings/numbers/booleans/null passthrough, json_string escaping torture, malformed-JSON rejection, oversize drop, concurrent append, metadata with control characters, and a realistic insertion fixture with nested modules and an application type named AgentValue proving symbol isolation. The cycle and shared-reference cases (client-side value model) are removed for serialized-json fixtures and retained for the rest. Co-Authored-By: Claude Fable 5 --- src/probes/rust.ts | 345 ++++--------- tests/contract/template-renderers.test.ts | 52 +- tests/e2e/languages/live-probes.test.ts | 531 ++++++++++++++++----- tests/fixtures/languages/rust-realistic.rs | 38 ++ 4 files changed, 578 insertions(+), 388 deletions(-) create mode 100644 tests/fixtures/languages/rust-realistic.rs diff --git a/src/probes/rust.ts b/src/probes/rust.ts index 2159c3d..7e4c2e1 100644 --- a/src/probes/rust.ts +++ b/src/probes/rust.ts @@ -4,290 +4,117 @@ export function renderRustTemplate(): ProbeTemplates { return { callTemplate: [ "// #region agent log", - '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + 'agent_debug_mode::emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_JSON_EXPRESSION__);', "// #endregion", ].join("\n"), + dataEncoding: "serialized-json", helperTemplate: [ "// #region agent log", - "#[derive(Clone)]", "#[allow(dead_code)]", - "enum AgentValue {", - " Null,", - " Bool(bool),", - " Int(i64),", - " Float(f64),", - " Str(String),", - " Array(Vec),", - " Object(Vec<(String, AgentValue)>),", - "}", - "", - "impl From for AgentValue { fn from(value: bool) -> Self { AgentValue::Bool(value) } }", - "impl From for AgentValue { fn from(value: i64) -> Self { AgentValue::Int(value) } }", - "impl From for AgentValue { fn from(value: f64) -> Self { AgentValue::Float(value) } }", - "impl From<&str> for AgentValue { fn from(value: &str) -> Self { AgentValue::Str(value.to_string()) } }", - "impl From for AgentValue { fn from(value: String) -> Self { AgentValue::Str(value) } }", - "", - "macro_rules! adbg {", - " ({ $($key:tt : $value:tt),* $(,)? }) => {", - " AgentValue::Object(vec![ $( ($key.to_string(), adbg!($value)) ),* ])", - " };", - " ([ $($value:tt),* $(,)? ]) => {", - " AgentValue::Array(vec![ $( adbg!($value) ),* ])", - " };", - " ($other:expr) => {", - " AgentValue::from($other)", - " };", - "}", - "", - "const AGENT_DEBUG_SECRET_KEYS: [&str; 12] = [", - ' "authorization", "authorization_header", "cookie", "set_cookie",', - ' "password", "passwd", "pwd", "private_key", "secret", "token",', - ' "credential", "credentials",', - "];", - "", - "const AGENT_DEBUG_SECRET_SUFFIXES: [&str; 11] = [", - ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', - ' "client_secret", "access_token", "refresh_token", "id_token",', - ' "auth_token", "bearer_token",', - "];", - "", - "fn __agent_debug_split_acronym(input: &str) -> String {", - " let chars: Vec = input.chars().collect();", - " let mut result = String::new();", - " let mut i = 0;", - " while i < chars.len() {", - " if chars[i].is_ascii_uppercase() {", - " let mut j = i;", - " while j < chars.len() && chars[j].is_ascii_uppercase() {", - " j += 1;", - " }", - " if j - i >= 2 && j < chars.len() && chars[j].is_ascii_lowercase() {", - " for k in i..(j - 1) {", - " result.push(chars[k]);", - " }", - " result.push('_');", - " result.push(chars[j - 1]);", - " result.push(chars[j]);", - " i = j + 1;", - " continue;", - " }", - " for k in i..j {", - " result.push(chars[k]);", - " }", - " i = j;", - " continue;", - " }", - " result.push(chars[i]);", - " i += 1;", - " }", - " result", - "}", - "", - "fn __agent_debug_split_camel(input: &str) -> String {", - " let chars: Vec = input.chars().collect();", - " let mut result = String::new();", - " let mut i = 0;", - " while i < chars.len() {", - " result.push(chars[i]);", - " let boundary = chars[i].is_ascii_lowercase() || chars[i].is_ascii_digit();", - " if boundary && i + 1 < chars.len() && chars[i + 1].is_ascii_uppercase() {", - " result.push('_');", - " }", - " i += 1;", - " }", - " result", - "}", - "", - "fn __agent_debug_normalize_key(key: &str) -> String {", - " let split = __agent_debug_split_camel(&__agent_debug_split_acronym(key));", - " let mut normalized = String::new();", - " let mut pending_underscore = false;", - " for c in split.chars() {", - " if c.is_ascii_alphanumeric() {", - " if pending_underscore && !normalized.is_empty() {", - " normalized.push('_');", + "mod agent_debug_mode {", + " pub fn json_string(text: &str) -> String {", + " let backslash = char::from(92u8);", + " let quote = char::from(34u8);", + " let mut out = String::new();", + " out.push(quote);", + " for c in text.chars() {", + " let code = c as u32;", + " if c == quote {", + " out.push(backslash);", + " out.push(quote);", + " } else if c == backslash {", + " out.push(backslash);", + " out.push(backslash);", + " } else if code == 8 {", + " out.push(backslash);", + " out.push(char::from(98u8));", + " } else if code == 9 {", + " out.push(backslash);", + " out.push(char::from(116u8));", + " } else if code == 10 {", + " out.push(backslash);", + " out.push(char::from(110u8));", + " } else if code == 12 {", + " out.push(backslash);", + " out.push(char::from(102u8));", + " } else if code == 13 {", + " out.push(backslash);", + " out.push(char::from(114u8));", + " } else if code < 32 {", + ' let digits = "0123456789abcdef".as_bytes();', + " out.push(backslash);", + " out.push(char::from(117u8));", + " out.push(char::from(48u8));", + " out.push(char::from(48u8));", + " out.push(char::from(digits[((code >> 4) & 15) as usize]));", + " out.push(char::from(digits[(code & 15) as usize]));", + " } else {", + " out.push(c);", " }", - " pending_underscore = false;", - " for lower in c.to_lowercase() {", - " normalized.push(lower);", - " }", - " } else {", - " pending_underscore = true;", " }", + " out.push(quote);", + " out", " }", - " normalized", - "}", "", - "fn __agent_debug_is_secret_key(key: &str) -> bool {", - " let normalized = __agent_debug_normalize_key(key);", - " for exact in AGENT_DEBUG_SECRET_KEYS.iter() {", - " if normalized == *exact {", - " return true;", + " fn timestamp_ms() -> i64 {", + " match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {", + " Ok(elapsed) => elapsed.as_millis() as i64,", + " Err(_) => 0,", " }", " }", - " let bytes = normalized.as_bytes();", - " for suffix in AGENT_DEBUG_SECRET_SUFFIXES.iter() {", - " if normalized.ends_with(suffix) {", - " let boundary = normalized.len() - suffix.len();", - " if boundary == 0 || bytes[boundary - 1] == b'_' {", - " return true;", - " }", - " }", - " }", - " false", - "}", "", - "fn __agent_debug_write_string(out: &mut String, text: &str) {", - " let backslash = char::from(92u8);", - " let quote = char::from(34u8);", - " out.push(quote);", - " for c in text.chars() {", - " let code = c as u32;", - " if c == quote {", - " out.push(backslash);", - " out.push(quote);", - " } else if c == backslash {", - " out.push(backslash);", - " out.push(backslash);", - " } else if code == 8 {", - " out.push(backslash);", - " out.push(char::from(98u8));", - " } else if code == 9 {", - " out.push(backslash);", - " out.push(char::from(116u8));", - " } else if code == 10 {", - " out.push(backslash);", - " out.push(char::from(110u8));", - " } else if code == 12 {", - " out.push(backslash);", - " out.push(char::from(102u8));", - " } else if code == 13 {", - " out.push(backslash);", - " out.push(char::from(114u8));", - " } else if code < 32 {", - ' let digits = "0123456789abcdef".as_bytes();', - " out.push(backslash);", - " out.push(char::from(117u8));", - " out.push(char::from(48u8));", - " out.push(char::from(48u8));", - " out.push(char::from(digits[((code >> 4) & 15) as usize]));", - " out.push(char::from(digits[(code & 15) as usize]));", - " } else {", - " out.push(c);", + " pub fn emit(hypothesis_id: &str, location: &str, message: &str, data_json: &str) {", + " if data_json.is_empty() {", + " return;", " }", - " }", - " out.push(quote);", - "}", - "", - "fn __agent_debug_write_value(out: &mut String, value: &AgentValue, depth: usize) -> bool {", - " if depth > 64 {", - " return false;", - " }", - " match value {", - ' AgentValue::Null => out.push_str("null"),', - ' AgentValue::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),', - " AgentValue::Int(number) => out.push_str(&number.to_string()),", - " AgentValue::Float(number) => {", - " if !number.is_finite() {", - " return false;", - " }", - " out.push_str(&number.to_string());", + " let mut out = String::new();", + " out.push('{');", + ' out.push_str(&json_string("hypothesisId"));', + " out.push(':');", + " out.push_str(&json_string(hypothesis_id));", + " out.push(',');", + ' out.push_str(&json_string("location"));', + " out.push(':');", + " out.push_str(&json_string(location));", + " out.push(',');", + ' out.push_str(&json_string("message"));', + " out.push(':');", + " out.push_str(&json_string(message));", + " out.push(',');", + ' out.push_str(&json_string("data"));', + " out.push(':');", + " out.push_str(data_json);", + " out.push(',');", + ' out.push_str(&json_string("timestamp"));', + " out.push(':');", + " out.push_str(×tamp_ms().to_string());", + " out.push('}');", + " out.push(char::from(10u8));", + " if out.len() > 65536 {", + " return;", " }", - " AgentValue::Str(text) => __agent_debug_write_string(out, text),", - " AgentValue::Array(items) => {", - " out.push('[');", - " let mut index = 0;", - " for item in items.iter() {", - " if index > 0 {", - " out.push(',');", - " }", - " index += 1;", - " if !__agent_debug_write_value(out, item, depth + 1) {", - " return false;", - " }", - " }", - " out.push(']');", + " let mut options = std::fs::OpenOptions::new();", + " options.create(true).append(true);", + " #[cfg(unix)]", + " {", + " use std::os::unix::fs::OpenOptionsExt;", + " options.mode(0o600);", " }", - " AgentValue::Object(entries) => {", - " out.push('{');", - " let mut index = 0;", - " for entry in entries.iter() {", - " if index > 0 {", - " out.push(',');", - " }", - " index += 1;", - " __agent_debug_write_string(out, &entry.0);", - " out.push(':');", - " if __agent_debug_is_secret_key(&entry.0) {", - ' __agent_debug_write_string(out, "[REDACTED]");', - " } else if !__agent_debug_write_value(out, &entry.1, depth + 1) {", - " return false;", - " }", - " }", - " out.push('}');", + ' if let Ok(mut file) = options.open("__APPEND_PATH__") {', + " use std::io::Write;", + " let _ = file.write_all(out.as_bytes());", " }", " }", - " true", - "}", - "", - "fn __agent_debug_timestamp_ms() -> i64 {", - " match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {", - " Ok(elapsed) => elapsed.as_millis() as i64,", - " Err(_) => 0,", - " }", - "}", - "", - "#[allow(non_snake_case)]", - "fn __agentDebugEmit(hypothesis_id: &str, location: &str, message: &str, data: AgentValue) {", - " let mut out = String::new();", - " out.push('{');", - ' __agent_debug_write_string(&mut out, "hypothesisId");', - " out.push(':');", - " __agent_debug_write_string(&mut out, hypothesis_id);", - " out.push(',');", - ' __agent_debug_write_string(&mut out, "location");', - " out.push(':');", - " __agent_debug_write_string(&mut out, location);", - " out.push(',');", - ' __agent_debug_write_string(&mut out, "message");', - " out.push(':');", - " __agent_debug_write_string(&mut out, message);", - " out.push(',');", - ' __agent_debug_write_string(&mut out, "data");', - " out.push(':');", - " if !__agent_debug_write_value(&mut out, &data, 0) {", - " return;", - " }", - " out.push(',');", - ' __agent_debug_write_string(&mut out, "timestamp");', - " out.push(':');", - " out.push_str(&__agent_debug_timestamp_ms().to_string());", - " out.push('}');", - " out.push(char::from(10u8));", - " if out.len() > 65536 {", - " return;", - " }", - " let mut options = std::fs::OpenOptions::new();", - " options.create(true).append(true);", - " #[cfg(unix)]", - " {", - " use std::os::unix::fs::OpenOptionsExt;", - " options.mode(0o600);", - " }", - ' if let Ok(mut file) = options.open("__APPEND_PATH__") {', - " use std::io::Write;", - " let _ = file.write_all(out.as_bytes());", - " }", "}", "// #endregion", ].join("\n"), ingest: "file", language: "rust", - dataEncoding: "native-json-value", placement: { call: "statement", helper: "top-level" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", - __DATA_EXPRESSION__: "Replace with a JSON-compatible Rust AgentValue that has no secrets.", + __DATA_JSON_EXPRESSION__: + "Replace with an expression that returns one complete valid JSON value containing no secrets. Use the application's serializer when available; otherwise pass agent_debug_mode::json_string(text).", __HYPOTHESIS_ID__: "Replace with the hypothesis label.", __LOCATION__: "Replace with the observed source location.", __MESSAGE__: "Replace with a constant observation message.", diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 8558b00..4c87912 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -26,12 +26,9 @@ const supported = [ ["kotlin", "file"], ] as const satisfies readonly (readonly [TemplateLanguage, IngestMethod])[]; -const callPlaceholders = [ - "__HYPOTHESIS_ID__", - "__LOCATION__", - "__MESSAGE__", - "__DATA_EXPRESSION__", -] as const; +const callMetadataPlaceholders = ["__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__"] as const; + +const serializedJsonLanguages = new Set(["rust"]); describe("session-independent template renderers", () => { for (const [language, ingest] of supported) { @@ -41,7 +38,9 @@ describe("session-independent template renderers", () => { const otherTarget = ingest === "http" ? "__APPEND_PATH__" : "__INGEST_URL__"; expect(template).toMatchObject({ ingest, language }); - expect(template.dataEncoding).toBe("native-json-value"); + expect(template.dataEncoding).toBe( + serializedJsonLanguages.has(language) ? "serialized-json" : "native-json-value", + ); expect(template.placement.call).toBe("statement"); expect(template.placement.helper).toBe( language === "c" || language === "cpp" ? "file-start" : "top-level", @@ -50,6 +49,11 @@ describe("session-independent template renderers", () => { expect(template.helperTemplate).not.toContain(otherTarget); expect(template.callTemplate).not.toContain(target); expect(template.callTemplate).not.toContain(otherTarget); + const dataPlaceholder = + template.dataEncoding === "serialized-json" + ? "__DATA_JSON_EXPRESSION__" + : "__DATA_EXPRESSION__"; + const callPlaceholders = [...callMetadataPlaceholders, dataPlaceholder]; for (const placeholder of callPlaceholders) { expect(template.helperTemplate).not.toContain(placeholder); expect(template.callTemplate).toContain(placeholder); @@ -63,10 +67,42 @@ describe("session-independent template renderers", () => { expect(template.helperTemplate).toContain("agent log"); expect(template.callTemplate).toContain("agent log"); expect(template.helperTemplate).toMatch(/65_?536|65536|64 \* 1024/); - expect(template.helperTemplate.toLowerCase()).toMatch(/active|depth/); + // The recursive value model carries depth/active-set bookkeeping; the + // serialized-JSON emitters delegate value modeling to the caller and have + // neither. + if (template.dataEncoding === "native-json-value") { + expect(template.helperTemplate.toLowerCase()).toMatch(/active|depth/); + } }); } + test("rust emits serialized JSON through an isolated helper module", () => { + const template = renderTemplate("rust", "file"); + expect(template.dataEncoding).toBe("serialized-json"); + expect(template.placement).toEqual({ call: "statement", helper: "top-level" }); + expect(template.callTemplate).toContain("__DATA_JSON_EXPRESSION__"); + expect(template.callTemplate).not.toContain("__DATA_EXPRESSION__"); + expect(template.helperTemplate).not.toContain("__DATA_EXPRESSION__"); + expect(template.helperTemplate).toContain("mod agent_debug_mode"); + expect(template.helperTemplate).toContain("65536"); + expect(template.helperTemplate).toContain("// #region agent log"); + expect(template.helperTemplate).toContain("// #endregion"); + expect(template.callTemplate).toContain("// #region agent log"); + const combined = `${template.helperTemplate}\n${template.callTemplate}`; + for (const removed of ["AgentValue", "AgentKind", "adbg"]) { + expect(combined).not.toContain(removed); + } + expect(Object.keys(template.placeholders).sort()).toEqual( + [ + "__APPEND_PATH__", + "__DATA_JSON_EXPRESSION__", + "__HYPOTHESIS_ID__", + "__LOCATION__", + "__MESSAGE__", + ].sort(), + ); + }); + test("HTTP helpers clean up every fulfilled response body", () => { for (const language of ["javascript", "typescript"]) { const helper = renderTemplate(language, "http").helperTemplate; diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 29b07dd..4093ca4 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -66,15 +66,20 @@ interface Fixture { // interpreted languages use one. Avoiding a shell sidesteps cmd.exe quote // mangling of compound "&&" command strings on Windows. command: (fixturePath: string) => string[][]; - cycleData: string; - cyclePrelude: string; + // How the call site supplies `data`. native-json-value templates redact and + // serialize client-side; serialized-json templates pass raw caller JSON that + // the daemon redacts. The cycle/shared-reference cases only apply to the + // client-side value model, so they are omitted for serialized-json fixtures. + dataEncoding: "native-json-value" | "serialized-json"; + cycleData?: string; + cyclePrelude?: string; file: string; ingest: "file" | "http"; language: string; runtime: string | null; setup?: (workspace: string) => Promise; - sharedData: string; - sharedPrelude: string; + sharedData?: string; + sharedPrelude?: string; } const fixtures: Fixture[] = [ @@ -84,6 +89,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("node") ?? "", path]], cycleData: "__agentCycle", cyclePrelude: "const __agentCycle = {}; __agentCycle.self = __agentCycle;", + dataEncoding: "native-json-value", file: "javascript-http.mjs", ingest: "http", language: "javascript", @@ -98,6 +104,7 @@ const fixtures: Fixture[] = [ cycleData: "__agentCycle", cyclePrelude: "const __agentCycle: Record = {}; __agentCycle.self = __agentCycle;", + dataEncoding: "native-json-value", file: "typescript-http.ts", ingest: "http", language: "typescript", @@ -112,6 +119,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("python3") ?? "", path]], cycleData: "__agent_cycle", cyclePrelude: '__agent_cycle = {}; __agent_cycle["self"] = __agent_cycle', + dataEncoding: "native-json-value", file: "python-file.py", ingest: "file", language: "python", @@ -125,6 +133,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("go") ?? "", "run", path]], cycleData: "__agentCycle", cyclePrelude: '__agentCycle := map[string]any{}; __agentCycle["self"] = __agentCycle', + dataEncoding: "native-json-value", file: "go-file.go", ingest: "file", language: "go", @@ -138,6 +147,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("ruby") ?? "", path]], cycleData: "__agent_cycle", cyclePrelude: '__agent_cycle = {}; __agent_cycle["self"] = __agent_cycle', + dataEncoding: "native-json-value", file: "ruby-file.rb", ingest: "file", language: "ruby", @@ -151,6 +161,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("php") ?? "", path]], cycleData: "$__agentCycle", cyclePrelude: '$__agentCycle = []; $__agentCycle["self"] = &$__agentCycle;', + dataEncoding: "native-json-value", file: "php-file.php", ingest: "file", language: "php", @@ -164,6 +175,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("pwsh") ?? "", "-File", path]], cycleData: "$__agentCycle", cyclePrelude: "$__agentCycle = @{}; $__agentCycle.self = $__agentCycle", + dataEncoding: "native-json-value", file: "powershell-file.ps1", ingest: "file", language: "powershell", @@ -178,6 +190,7 @@ const fixtures: Fixture[] = [ cycleData: "__agentCycle", cyclePrelude: 'var __agentCycle = new Dictionary(); __agentCycle["self"] = __agentCycle;', + dataEncoding: "native-json-value", file: "Program.cs", ingest: "file", language: "csharp", @@ -203,6 +216,7 @@ const fixtures: Fixture[] = [ command: (path) => [[Bun.which("swift") ?? "", path]], cycleData: "__agentCycle", cyclePrelude: 'let __agentCycle = NSMutableDictionary(); __agentCycle["self"] = __agentCycle', + dataEncoding: "native-json-value", file: "swift-file.swift", ingest: "file", language: "swift", @@ -211,27 +225,22 @@ const fixtures: Fixture[] = [ sharedPrelude: 'let __agentShared: [String: Any] = ["APIKey": "source-shared-secret"]', }, { + // Serialized-json call sites hand the emitter one complete raw JSON value. + // The fixture emits the policy matrix verbatim (secrets included) as a raw + // string literal; the daemon performs redaction, so canonical evidence still + // equals canonicalPolicyData. callData: - 'adbg!({ "value": 42i64, "designToken": "visible-design-token", "fortuneCookie": "visible-fortune-cookie", "secretSauceName": "visible-secret-sauce", "tokenCount": 7i64, "passwordPolicy": "visible-password-policy", "password": "source-password-secret", "APIKey": "source-api-acronym-secret", "APIToken": "source-api-token-secret", "IDToken": "source-id-token-secret", "OAuthToken": "source-oauth-token-secret", "Client Secret": "source-client-secret", "nested": { "apiKey": "source-api-secret", "items": [ { "refresh-token": "source-refresh-secret" }, { "credentials": "source-credentials-secret" } ] } })', - // Rust's AgentValue is an owned tree, so a reference cycle is unrepresentable. - // The depth-64 cap is the analogous unbounded-structure rejection: a value - // nested past the cap is dropped without emitting, exactly like a cycle would be. + 'r#"{"value":42,"designToken":"visible-design-token","fortuneCookie":"visible-fortune-cookie","secretSauceName":"visible-secret-sauce","tokenCount":7,"passwordPolicy":"visible-password-policy","password":"source-password-secret","APIKey":"source-api-acronym-secret","APIToken":"source-api-token-secret","IDToken":"source-id-token-secret","OAuthToken":"source-oauth-token-secret","Client Secret":"source-client-secret","nested":{"apiKey":"source-api-secret","items":[{"refresh-token":"source-refresh-secret"},{"credentials":"source-credentials-secret"}]}}"#', command: (path) => { const rustc = Bun.which("rustc") ?? ""; const binary = path.replace(/\.rs$/, process.platform === "win32" ? ".exe" : ".out"); return [[rustc, "-A", "warnings", path, "-o", binary], [binary]]; }, - cycleData: "__agent_cycle", - cyclePrelude: - "let mut __agent_cycle = AgentValue::Int(0); let mut __agent_depth = 0; while __agent_depth < 100 { __agent_cycle = AgentValue::Array(vec![__agent_cycle]); __agent_depth += 1; }", + dataEncoding: "serialized-json", file: "rust-file.rs", ingest: "file", language: "rust", runtime: Bun.which("rustc"), - sharedData: - 'AgentValue::Object(vec![("left".to_string(), __agent_shared.clone()), ("right".to_string(), __agent_shared)])', - sharedPrelude: - 'let __agent_shared = AgentValue::Object(vec![("APIKey".to_string(), AgentValue::from("source-shared-secret"))]);', }, { callData: @@ -253,6 +262,7 @@ const fixtures: Fixture[] = [ // clang 18 / gcc 13 copy), so the nesting depth would vary by toolchain. cyclePrelude: "AgentValue __agent_cycle = AgentValue(0LL); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { AgentValue __agent_wrap; __agent_wrap.kind = AgentKind::Array; __agent_wrap.arrayValue.push_back(__agent_cycle); __agent_cycle = __agent_wrap; }", + dataEncoding: "native-json-value", file: "cpp-file.cpp", ingest: "file", language: "cpp", @@ -275,6 +285,7 @@ const fixtures: Fixture[] = [ cycleData: "__agent_cycle", cyclePrelude: "AgentValue *__agent_cycle = adbg_int(0); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = adbg_arr(__agent_cycle, NULL); }", + dataEncoding: "native-json-value", file: "c-file.c", ingest: "file", language: "c", @@ -293,6 +304,7 @@ const fixtures: Fixture[] = [ cycleData: "__agentCycle", cyclePrelude: 'java.util.Map __agentCycle = new java.util.LinkedHashMap<>(); __agentCycle.put("self", __agentCycle);', + dataEncoding: "native-json-value", file: "java-file.java", ingest: "file", language: "java", @@ -320,6 +332,7 @@ const fixtures: Fixture[] = [ cycleData: "__agentCycle", cyclePrelude: 'val __agentCycle = java.util.LinkedHashMap(); __agentCycle.put("self", __agentCycle)', + dataEncoding: "native-json-value", file: "kotlin-file.kt", ingest: "file", language: "kotlin", @@ -477,6 +490,7 @@ function materialize( const values: Record = { __APPEND_PATH__: target, __DATA_EXPRESSION__: dataExpression, + __DATA_JSON_EXPRESSION__: dataExpression, __HYPOTHESIS_ID__: "H-live", __INGEST_URL__: target, __LOCATION__: `${fixture.file}:1`, @@ -601,13 +615,22 @@ describe("live language templates", () => { const raw = capture ? capture.bodies.join("") : await readFile(created.data.appendPath, "utf8"); - for (const secret of sourceSecrets) { - expect(raw).not.toContain(secret); - } - expect(raw).toContain("[REDACTED]"); const [rawLine] = raw.trim().split("\n"); const rawEvent = JSON.parse(rawLine ?? "") as { data: unknown }; - expect(rawEvent.data).toEqual(canonicalPolicyData); + if (fixture.dataEncoding === "native-json-value") { + // The client redacts before transport, so no secret ever reaches the + // incoming record and its data already equals canonical evidence. + for (const secret of sourceSecrets) { + expect(raw).not.toContain(secret); + } + expect(raw).toContain("[REDACTED]"); + expect(rawEvent.data).toEqual(canonicalPolicyData); + } else { + // Serialized-json callers write raw JSON; the daemon redacts. The + // pre-normalization incoming record therefore carries the caller text + // verbatim (spec: Secret-handling contract). + expect(rawEvent.data).toEqual(policyInput); + } const [event] = await awaitRecords(home, created.data.sessionId, 1); expect(event?.data).toEqual(canonicalPolicyData); @@ -670,111 +693,114 @@ describe("live language templates", () => { 180_000, ); - runtimeTest( - `${fixture.language} rejects cyclic values without emitting`, - async () => { - expect(fixture.runtime, `${fixture.language} runtime must be installed`).not.toBeNull(); - const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); - const workspace = await mkdtemp(join(tmpdir(), `debug-mode-${fixture.language}-cycle-`)); - temporaryDirectories.push(home, workspace); - const created = await createSession(home); - const rendered = await render(home, fixture); - const source = await readFile( - join(root, "tests", "fixtures", "languages", fixture.file), - "utf8", - ); - await fixture.setup?.(workspace); - const fixturePath = join(workspace, fixture.file); - const capture = - fixture.ingest === "http" ? startCaptureProxy(created.data.ingestUrl) : undefined; - const target = capture?.url ?? created.data.appendPath.replaceAll("\\", "\\\\"); - - try { - await writeFile( - fixturePath, - materialize( - source, - rendered.data, - fixture, - target, - 1, - fixture.cycleData, - fixture.cyclePrelude, - ), + // The cycle and shared-reference cases exercise the client-side value model, + // which only native-json-value templates carry. Serialized-json templates + // delegate value modeling to the caller, so these cases do not apply. + if (fixture.dataEncoding === "native-json-value") { + const { cycleData, cyclePrelude, sharedData, sharedPrelude } = fixture; + if ( + cycleData === undefined || + cyclePrelude === undefined || + sharedData === undefined || + sharedPrelude === undefined + ) { + throw new Error(`${fixture.language} is missing value-model fixture data`); + } + runtimeTest( + `${fixture.language} rejects cyclic values without emitting`, + async () => { + expect(fixture.runtime, `${fixture.language} runtime must be installed`).not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), `debug-mode-${fixture.language}-cycle-`)); + temporaryDirectories.push(home, workspace); + const created = await createSession(home); + const rendered = await render(home, fixture); + const source = await readFile( + join(root, "tests", "fixtures", "languages", fixture.file), + "utf8", ); - const executed = await runSteps(fixture.command(fixturePath)); - expect(executed.exitCode, executed.stderr).toBe(0); - expect(`${executed.stdout}\n${executed.stderr}`).toContain("application-completed"); - await Bun.sleep(200); - expect(capture?.bodies ?? []).toHaveLength(0); - const raw = - fixture.ingest === "file" - ? await readFile(created.data.appendPath, "utf8").catch(() => "") - : ""; - expect(raw).toBe(""); - const logs = await runCli(home, ["logs", "--session", created.data.sessionId, "--json"]); - expect(logs.exitCode, logs.stderr).toBe(0); - expect(JSON.parse(logs.stdout)).toMatchObject({ - statistics: { totalRecords: 0 }, - }); - } finally { - capture?.stop(); - await runCli(home, ["stop", "--json"]); - } - }, - 180_000, - ); + await fixture.setup?.(workspace); + const fixturePath = join(workspace, fixture.file); + const capture = + fixture.ingest === "http" ? startCaptureProxy(created.data.ingestUrl) : undefined; + const target = capture?.url ?? created.data.appendPath.replaceAll("\\", "\\\\"); - runtimeTest( - `${fixture.language} accepts shared acyclic references`, - async () => { - expect(fixture.runtime, `${fixture.language} runtime must be installed`).not.toBeNull(); - const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); - const workspace = await mkdtemp(join(tmpdir(), `debug-mode-${fixture.language}-shared-`)); - temporaryDirectories.push(home, workspace); - const created = await createSession(home); - const rendered = await render(home, fixture); - const source = await readFile( - join(root, "tests", "fixtures", "languages", fixture.file), - "utf8", - ); - await fixture.setup?.(workspace); - const fixturePath = join(workspace, fixture.file); - const capture = - fixture.ingest === "http" ? startCaptureProxy(created.data.ingestUrl) : undefined; - const target = capture?.url ?? created.data.appendPath.replaceAll("\\", "\\\\"); + try { + await writeFile( + fixturePath, + materialize(source, rendered.data, fixture, target, 1, cycleData, cyclePrelude), + ); + const executed = await runSteps(fixture.command(fixturePath)); + expect(executed.exitCode, executed.stderr).toBe(0); + expect(`${executed.stdout}\n${executed.stderr}`).toContain("application-completed"); + await Bun.sleep(200); + expect(capture?.bodies ?? []).toHaveLength(0); + const raw = + fixture.ingest === "file" + ? await readFile(created.data.appendPath, "utf8").catch(() => "") + : ""; + expect(raw).toBe(""); + const logs = await runCli(home, [ + "logs", + "--session", + created.data.sessionId, + "--json", + ]); + expect(logs.exitCode, logs.stderr).toBe(0); + expect(JSON.parse(logs.stdout)).toMatchObject({ + statistics: { totalRecords: 0 }, + }); + } finally { + capture?.stop(); + await runCli(home, ["stop", "--json"]); + } + }, + 180_000, + ); - try { - await writeFile( - fixturePath, - materialize( - source, - rendered.data, - fixture, - target, - 1, - fixture.sharedData, - fixture.sharedPrelude, - ), + runtimeTest( + `${fixture.language} accepts shared acyclic references`, + async () => { + expect(fixture.runtime, `${fixture.language} runtime must be installed`).not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), `debug-mode-${fixture.language}-shared-`)); + temporaryDirectories.push(home, workspace); + const created = await createSession(home); + const rendered = await render(home, fixture); + const source = await readFile( + join(root, "tests", "fixtures", "languages", fixture.file), + "utf8", ); - const executed = await runSteps(fixture.command(fixturePath)); - expect(executed.exitCode, executed.stderr).toBe(0); - const raw = capture - ? capture.bodies.join("") - : await readFile(created.data.appendPath, "utf8"); - expect(raw).not.toContain("source-shared-secret"); - const [rawLine] = raw.trim().split("\n"); - const rawEvent = JSON.parse(rawLine ?? "") as { data: unknown }; - expect(rawEvent.data).toEqual(canonicalSharedPolicyData); - const [event] = await awaitRecords(home, created.data.sessionId, 1); - expect(event?.data).toEqual(canonicalSharedPolicyData); - } finally { - capture?.stop(); - await runCli(home, ["stop", "--json"]); - } - }, - 180_000, - ); + await fixture.setup?.(workspace); + const fixturePath = join(workspace, fixture.file); + const capture = + fixture.ingest === "http" ? startCaptureProxy(created.data.ingestUrl) : undefined; + const target = capture?.url ?? created.data.appendPath.replaceAll("\\", "\\\\"); + + try { + await writeFile( + fixturePath, + materialize(source, rendered.data, fixture, target, 1, sharedData, sharedPrelude), + ); + const executed = await runSteps(fixture.command(fixturePath)); + expect(executed.exitCode, executed.stderr).toBe(0); + const raw = capture + ? capture.bodies.join("") + : await readFile(created.data.appendPath, "utf8"); + expect(raw).not.toContain("source-shared-secret"); + const [rawLine] = raw.trim().split("\n"); + const rawEvent = JSON.parse(rawLine ?? "") as { data: unknown }; + expect(rawEvent.data).toEqual(canonicalSharedPolicyData); + const [event] = await awaitRecords(home, created.data.sessionId, 1); + expect(event?.data).toEqual(canonicalSharedPolicyData); + } finally { + capture?.stop(); + await runCli(home, ["stop", "--json"]); + } + }, + 180_000, + ); + } } for (const language of ["javascript", "typescript"]) { @@ -1059,6 +1085,269 @@ describe("live language templates", () => { } }); +// Serialized-JSON runtime coverage specific to Rust: the caller supplies raw +// JSON, so these exercise the emitter's raw-value passthrough, the json_string +// fallback, size/validity bounds, concurrency, and realistic insertion. +describe("rust serialized-JSON template", () => { + const rust = fixtures.find((candidate) => candidate.language === "rust"); + if (!rust) { + throw new Error("Missing rust fixture"); + } + const rustFixture = rust; + const rustRuntimeTest = rustFixture.runtime === null && !requireRuntimes ? test.skip : test; + + // Compiles the rendered helper plus a bespoke main body appending to + // appendPath, then runs it. Returns the process result. + async function compileAndRun( + home: string, + workspace: string, + fileName: string, + body: string, + appendPath: string, + ) { + const rendered = await render(home, rustFixture); + const helper = rendered.data.helperTemplate.replaceAll( + "__APPEND_PATH__", + appendPath.replaceAll("\\", "\\\\"), + ); + const source = `${helper}\n\nfn main() {\n${body}\n println!("application-completed");\n}\n`; + const fixturePath = join(workspace, fileName); + await writeFile(fixturePath, source); + return runSteps(rustFixture.command(fixturePath)); + } + + rustRuntimeTest( + "accepts serialized arrays, strings, numbers, booleans, and null", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-types-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "types.ndjson"); + const body = [ + ' agent_debug_mode::emit("H", "loc:1", "array", r#"[1,2,3]"#);', + ' agent_debug_mode::emit("H", "loc:2", "string", &agent_debug_mode::json_string("plain text"));', + ' agent_debug_mode::emit("H", "loc:3", "number", r#"42"#);', + ' agent_debug_mode::emit("H", "loc:4", "boolean", r#"true"#);', + ' agent_debug_mode::emit("H", "loc:5", "null", r#"null"#);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "rust-types.rs", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const lines = (await readFile(appendPath, "utf8")).trim().split("\n"); + const data = lines.map((line) => (JSON.parse(line) as { data: unknown }).data); + expect(data).toEqual([[1, 2, 3], "plain text", 42, true, null]); + }, + 180_000, + ); + + rustRuntimeTest( + "json_string escapes quotes, backslashes, control characters, and newlines", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-escape-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "escape.ndjson"); + // Build the tricky text from char codes so the Rust source needs no + // escaping: a, quote, backslash, newline, tab, U+0001, z. + const body = [ + " let mut tricky = String::new();", + " tricky.push('a');", + " tricky.push(char::from(34u8));", + " tricky.push(char::from(92u8));", + " tricky.push(char::from(10u8));", + " tricky.push(char::from(9u8));", + " tricky.push(char::from(1u8));", + " tricky.push('z');", + ' agent_debug_mode::emit("H", "loc", "escape", &agent_debug_mode::json_string(&tricky));', + ].join("\n"); + const result = await compileAndRun(home, workspace, "rust-escape.rs", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + const event = JSON.parse(line ?? "") as { data: unknown }; + expect(event.data).toBe(`a"\\\n\tz`); + }, + 180_000, + ); + + rustRuntimeTest( + "records malformed raw JSON as rejected without affecting the application", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-malformed-")); + temporaryDirectories.push(home, workspace); + const created = await createSession(home); + try { + const body = ' agent_debug_mode::emit("H", "loc:7", "broken", r#"{"broken":"#);'; + const result = await compileAndRun( + home, + workspace, + "rust-malformed.rs", + body, + created.data.appendPath, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + // The daemon needs a moment to observe the incoming line and classify it. + let diagnostics: unknown[] = []; + for (let attempt = 0; attempt < 80 && diagnostics.length === 0; attempt += 1) { + const status = await runCli(home, [ + "status", + "--session", + created.data.sessionId, + "--json", + ]); + if (status.exitCode === 0) { + const parsed = JSON.parse(status.stdout) as { data: { diagnostics?: unknown[] } }; + diagnostics = parsed.data.diagnostics ?? []; + } + if (diagnostics.length === 0) { + await Bun.sleep(50); + } + } + expect(diagnostics.length).toBeGreaterThan(0); + const logs = await runCli(home, ["logs", "--session", created.data.sessionId, "--json"]); + expect(logs.exitCode, logs.stderr).toBe(0); + // The malformed record is counted but not accepted: no valid record is + // ingested, and the daemon flags exactly one malformed record. + expect(JSON.parse(logs.stdout)).toMatchObject({ + statistics: { malformedRecords: 1, validRecords: 0 }, + }); + } finally { + await runCli(home, ["stop", "--json"]); + } + }, + 180_000, + ); + + rustRuntimeTest( + "drops an oversize event without affecting the application", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-oversize-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "oversize.ndjson"); + const body = [ + ' let big = format!("[{}0]", "0,".repeat(40000));', + ' agent_debug_mode::emit("H", "loc", "oversize", &big);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "rust-oversize.rs", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const raw = await readFile(appendPath, "utf8").catch(() => ""); + expect(raw).toBe(""); + }, + 180_000, + ); + + rustRuntimeTest( + "concurrent emitters append complete, independently parseable records", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const rustc = Bun.which("rustc"); + expect(rustc, "rustc must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-concurrent-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "concurrent.ndjson"); + const rendered = await render(home, rustFixture); + const helper = rendered.data.helperTemplate.replaceAll( + "__APPEND_PATH__", + appendPath.replaceAll("\\", "\\\\"), + ); + const source = `${helper}\n\nfn main() {\n agent_debug_mode::emit("H", "loc", "concurrent", r#"{"n":1}"#);\n}\n`; + const fixturePath = join(workspace, "rust-concurrent.rs"); + const binary = join( + workspace, + process.platform === "win32" ? "rust-concurrent.exe" : "rust-concurrent.out", + ); + await writeFile(fixturePath, source); + const compiled = await run([rustc ?? "", "-A", "warnings", fixturePath, "-o", binary]); + expect(compiled.exitCode, compiled.stderr).toBe(0); + const executions = await Promise.all(Array.from({ length: 32 }, () => run([binary]))); + for (const execution of executions) { + expect(execution.exitCode, execution.stderr).toBe(0); + } + const contents = await readFile(appendPath, "utf8"); + expect(contents.endsWith("\n")).toBe(true); + const lines = contents.split("\n").filter(Boolean); + expect(lines).toHaveLength(32); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }, + 180_000, + ); + + rustRuntimeTest( + "keeps metadata containing quotes and control characters valid", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-metadata-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "metadata.ndjson"); + const body = [ + " let mut hid = String::new();", + " hid.push('H');", + " hid.push(char::from(34u8));", + " hid.push(char::from(10u8));", + " let mut msg = String::new();", + " msg.push(char::from(9u8));", + ' msg.push_str("msg");', + " msg.push(char::from(92u8));", + ' agent_debug_mode::emit(&hid, "loc", &msg, r#"{"ok":true}"#);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "rust-metadata.rs", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + const event = JSON.parse(line ?? "") as { + hypothesisId: string; + message: string; + data: unknown; + }; + expect(event.hypothesisId).toBe(`H"\n`); + expect(event.message).toBe(`\tmsg\\`); + expect(event.data).toEqual({ ok: true }); + }, + 180_000, + ); + + rustRuntimeTest( + "compiles when the helper is inserted into a realistic Rust file", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-realistic-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "realistic.ndjson"); + const rendered = await render(home, rustFixture); + const source = await readFile( + join(root, "tests", "fixtures", "languages", "rust-realistic.rs"), + "utf8", + ); + const materialized = materialize( + source, + rendered.data, + rustFixture, + appendPath.replaceAll("\\", "\\\\"), + 1, + "&agent_debug_mode::json_string(&value.label)", + ); + const fixturePath = join(workspace, "rust-realistic.rs"); + await writeFile(fixturePath, materialized); + const result = await runSteps(rustFixture.command(fixturePath)); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + expect((JSON.parse(line ?? "") as { data: unknown }).data).toBe("inner-module"); + }, + 180_000, + ); +}); + // Fault-injection coverage for the capture proxy's forward retry. This is the // load-bearing proof that the retry recovers the exact failure CI hit — a // transient forward failure that the previous bare `catch {}` swallowed, so the diff --git a/tests/fixtures/languages/rust-realistic.rs b/tests/fixtures/languages/rust-realistic.rs new file mode 100644 index 0000000..351d21c --- /dev/null +++ b/tests/fixtures/languages/rust-realistic.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +__HELPER_TEMPLATE__ + +// An application type deliberately named AgentValue, plus nested modules, to +// prove the inserted helper introduces no conflicting global symbols and sits +// legally at module scope alongside existing items. +#[derive(Debug)] +struct AgentValue { + label: String, + counts: HashMap, +} + +mod domain { + pub mod inner { + pub fn describe() -> &'static str { + "inner-module" + } + } +} + +impl AgentValue { + fn total(&self) -> i64 { + self.counts.values().copied().sum() + } +} + +fn main() { + let mut counts = HashMap::new(); + counts.insert("hits".to_string(), 3i64); + counts.insert("misses".to_string(), 1i64); + let value = AgentValue { + label: domain::inner::describe().to_string(), + counts, + }; + __CALL_TEMPLATE__ + println!("application-completed: {} {}", value.label, value.total()); +} From 2bbbbb8e3dfe2ce839e8671d5197f6dc7d85489f Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 16:45:31 -0700 Subject: [PATCH 11/18] feat: migrate the C++ probe template to serialized JSON Replace the C++ helper's recursive AgentValue model, AgentKind enum, initializer-list object detection, depth handling, and client-side secret normalization/redaction with a small `namespace agent_debug_mode` that only escapes envelope metadata, timestamps, bounds, and appends. The call template now accepts a serialized-JSON expression (__DATA_JSON_EXPRESSION__); json_string provides the escaped-text fallback and AGENT_DEBUG_STRINGIFY builds a __FILE__ ":" __LINE__ location. The POSIX append ports C's atomic open(O_WRONLY|O_APPEND|O_CREAT, 0600) with an ofstream fallback under _WIN32, and catch(...) suppresses every failure. The daemon performs redaction, so serialized-json callers may write raw caller text to the incoming file. Helper drops from 297 to 113 nonblank lines (62% smaller). Runtime coverage mirrors Rust: raw arrays/strings/numbers/booleans/null passthrough, json_string escaping torture, malformed-JSON rejection, oversize drop, concurrent append, metadata with control characters, and a realistic insertion fixture with a namespace and an application type named AgentValue proving symbol isolation. The cycle and shared-reference cases (client-side value model) are removed for the serialized-json cpp fixture. Co-Authored-By: Claude Fable 5 --- src/probes/cpp.ts | 346 +++++---------------- tests/contract/template-renderers.test.ts | 31 +- tests/e2e/languages/live-probes.test.ts | 290 ++++++++++++++++- tests/fixtures/languages/cpp-file.cpp | 2 + tests/fixtures/languages/cpp-realistic.cpp | 41 +++ 5 files changed, 425 insertions(+), 285 deletions(-) create mode 100644 tests/fixtures/languages/cpp-realistic.cpp diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts index 6d82d8d..7b09dca 100644 --- a/src/probes/cpp.ts +++ b/src/probes/cpp.ts @@ -4,328 +4,138 @@ export function renderCppTemplate(): ProbeTemplates { return { callTemplate: [ "// #region agent log", - '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + 'agent_debug_mode::emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_JSON_EXPRESSION__);', "// #endregion", ].join("\n"), + dataEncoding: "serialized-json", helperTemplate: [ "// #region agent log", "#include ", - "#include ", - "#include ", - "#include ", - "#include ", - "#include ", - "#include ", "#include ", - "#include ", - "#include ", - "#ifndef _WIN32", - "#include ", + "#ifdef _WIN32", + "#include ", + "#include ", + "#else", + "#include ", + "#include ", "#endif", "", - "enum class AgentKind { Null, Bool, Int, Double, Str, Array, Object };", - "", - "struct AgentValue {", - " AgentKind kind = AgentKind::Null;", - " bool boolValue = false;", - " long long intValue = 0;", - " double doubleValue = 0.0;", - " std::string strValue;", - " std::vector arrayValue;", - " std::vector> objectValue;", - "", - " AgentValue() {}", - " AgentValue(bool v) { kind = AgentKind::Bool; boolValue = v; }", - " AgentValue(int v) { kind = AgentKind::Int; intValue = v; }", - " AgentValue(long long v) { kind = AgentKind::Int; intValue = v; }", - " AgentValue(double v) { kind = AgentKind::Double; doubleValue = v; }", - " AgentValue(const char* v) { kind = AgentKind::Str; strValue = v; }", - " AgentValue(const std::string& v) { kind = AgentKind::Str; strValue = v; }", - " AgentValue(std::initializer_list items) {", - " bool isObject = items.size() > 0;", - " for (const auto& item : items) {", - " if (item.kind != AgentKind::Array || item.arrayValue.size() != 2 ||", - " item.arrayValue[0].kind != AgentKind::Str) {", - " isObject = false;", - " break;", - " }", - " }", - " if (isObject) {", - " kind = AgentKind::Object;", - " for (const auto& item : items) {", - " objectValue.emplace_back(item.arrayValue[0].strValue, item.arrayValue[1]);", - " }", - " } else {", - " kind = AgentKind::Array;", - " arrayValue = std::vector(items);", - " }", - " }", - "};", - "", - "static const char* const AGENT_DEBUG_SECRET_KEYS[] = {", - ' "authorization", "authorization_header", "cookie", "set_cookie",', - ' "password", "passwd", "pwd", "private_key", "secret", "token",', - ' "credential", "credentials"};', + "#define AGENT_DEBUG_STRINGIFY_IMPL(value) #value", + "#define AGENT_DEBUG_STRINGIFY(value) AGENT_DEBUG_STRINGIFY_IMPL(value)", "", - "static const char* const AGENT_DEBUG_SECRET_SUFFIXES[] = {", - ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', - ' "client_secret", "access_token", "refresh_token", "id_token",', - ' "auth_token", "bearer_token"};', + "namespace agent_debug_mode {", "", - "static std::string __agent_debug_split_acronym(const std::string& input) {", - " std::string result;", - " size_t n = input.size();", - " size_t i = 0;", - " while (i < n) {", - " unsigned char ci = static_cast(input[i]);", - " if (ci >= 'A' && ci <= 'Z') {", - " size_t j = i;", - " while (j < n) {", - " unsigned char cj = static_cast(input[j]);", - " if (cj >= 'A' && cj <= 'Z') {", - " j += 1;", - " } else {", - " break;", - " }", - " }", - " unsigned char after = (j < n) ? static_cast(input[j]) : 0;", - " if (j - i >= 2 && j < n && after >= 'a' && after <= 'z') {", - " for (size_t k = i; k < j - 1; k += 1) {", - " result.push_back(input[k]);", - " }", - " result.push_back('_');", - " result.push_back(input[j - 1]);", - " result.push_back(input[j]);", - " i = j + 1;", - " continue;", - " }", - " for (size_t k = i; k < j; k += 1) {", - " result.push_back(input[k]);", - " }", - " i = j;", - " continue;", - " }", - " result.push_back(input[i]);", - " i += 1;", - " }", - " return result;", - "}", - "", - "static std::string __agent_debug_split_camel(const std::string& input) {", - " std::string result;", - " size_t n = input.size();", - " for (size_t i = 0; i < n; i += 1) {", - " unsigned char ci = static_cast(input[i]);", - " result.push_back(input[i]);", - " bool boundary = (ci >= 'a' && ci <= 'z') || (ci >= '0' && ci <= '9');", - " if (boundary && i + 1 < n) {", - " unsigned char next = static_cast(input[i + 1]);", - " if (next >= 'A' && next <= 'Z') {", - " result.push_back('_');", - " }", - " }", - " }", - " return result;", - "}", - "", - "static std::string __agent_debug_normalize_key(const std::string& key) {", - " std::string split = __agent_debug_split_camel(__agent_debug_split_acronym(key));", - " std::string normalized;", - " bool pending_underscore = false;", - " for (unsigned char c : split) {", - " bool alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');", - " if (alnum) {", - " if (pending_underscore && !normalized.empty()) {", - " normalized.push_back('_');", - " }", - " pending_underscore = false;", - " if (c >= 'A' && c <= 'Z') {", - " normalized.push_back(static_cast(c - 'A' + 'a'));", - " } else {", - " normalized.push_back(static_cast(c));", - " }", - " } else {", - " pending_underscore = true;", - " }", - " }", - " return normalized;", - "}", - "", - "static bool __agent_debug_is_secret_key(const std::string& key) {", - " std::string normalized = __agent_debug_normalize_key(key);", - " for (const auto& exact : AGENT_DEBUG_SECRET_KEYS) {", - " if (normalized == exact) {", - " return true;", - " }", - " }", - " for (const auto& suffix : AGENT_DEBUG_SECRET_SUFFIXES) {", - " std::string tail(suffix);", - " if (normalized.size() >= tail.size() &&", - " normalized.compare(normalized.size() - tail.size(), tail.size(), tail) == 0) {", - " size_t boundary = normalized.size() - tail.size();", - " if (boundary == 0 || normalized[boundary - 1] == '_') {", - " return true;", - " }", - " }", - " }", - " return false;", - "}", - "", - "static void __agent_debug_write_string(std::ostringstream& out, const std::string& text) {", + "inline std::string json_string(const std::string& text) {", " const char backslash = static_cast(0x5C);", " const char quote = static_cast(0x22);", - " out << quote;", + " std::string out;", + " out.push_back(quote);", " for (unsigned char c : text) {", " unsigned int code = static_cast(c);", " if (c == quote) {", - " out << backslash << quote;", + " out.push_back(backslash);", + " out.push_back(quote);", " } else if (c == backslash) {", - " out << backslash << backslash;", + " out.push_back(backslash);", + " out.push_back(backslash);", " } else if (code == 8) {", - " out << backslash << 'b';", + " out.push_back(backslash);", + " out.push_back('b');", " } else if (code == 9) {", - " out << backslash << 't';", + " out.push_back(backslash);", + " out.push_back('t');", " } else if (code == 10) {", - " out << backslash << 'n';", + " out.push_back(backslash);", + " out.push_back('n');", " } else if (code == 12) {", - " out << backslash << 'f';", + " out.push_back(backslash);", + " out.push_back('f');", " } else if (code == 13) {", - " out << backslash << 'r';", + " out.push_back(backslash);", + " out.push_back('r');", " } else if (code < 32) {", ' const char* digits = "0123456789abcdef";', - " out << backslash << 'u' << '0' << '0';", - " out << digits[(code >> 4) & 15] << digits[code & 15];", + " out.push_back(backslash);", + " out.push_back('u');", + " out.push_back('0');", + " out.push_back('0');", + " out.push_back(digits[(code >> 4) & 15]);", + " out.push_back(digits[code & 15]);", " } else {", - " out << static_cast(c);", + " out.push_back(static_cast(c));", " }", " }", - " out << quote;", + " out.push_back(quote);", + " return out;", "}", "", - "static bool __agent_debug_write_value(std::ostringstream& out, const AgentValue& value, int depth) {", - " if (depth > 64) {", - " return false;", - " }", - " switch (value.kind) {", - " case AgentKind::Null:", - ' out << "null";', - " break;", - " case AgentKind::Bool:", - ' out << (value.boolValue ? "true" : "false");', - " break;", - " case AgentKind::Int:", - " out << value.intValue;", - " break;", - " case AgentKind::Double: {", - " if (!std::isfinite(value.doubleValue)) {", - " return false;", - " }", - " std::ostringstream number;", - " number << std::setprecision(17) << value.doubleValue;", - " out << number.str();", - " break;", - " }", - " case AgentKind::Str:", - " __agent_debug_write_string(out, value.strValue);", - " break;", - " case AgentKind::Array: {", - " out << '[';", - " int index = 0;", - " for (const auto& item : value.arrayValue) {", - " if (index > 0) {", - " out << ',';", - " }", - " index += 1;", - " if (!__agent_debug_write_value(out, item, depth + 1)) {", - " return false;", - " }", - " }", - " out << ']';", - " break;", - " }", - " case AgentKind::Object: {", - " out << '{';", - " int index = 0;", - " for (const auto& entry : value.objectValue) {", - " if (index > 0) {", - " out << ',';", - " }", - " index += 1;", - " __agent_debug_write_string(out, entry.first);", - " out << ':';", - " if (__agent_debug_is_secret_key(entry.first)) {", - ' __agent_debug_write_string(out, "[REDACTED]");', - " } else if (!__agent_debug_write_value(out, entry.second, depth + 1)) {", - " return false;", - " }", - " }", - " out << '}';", - " break;", - " }", - " }", - " return true;", - "}", - "", - "static long long __agent_debug_timestamp_ms() {", + "inline long long timestamp_ms() {", " return std::chrono::duration_cast(", " std::chrono::system_clock::now().time_since_epoch())", " .count();", "}", "", - "static void __agentDebugEmit(const std::string& hypothesisId, const std::string& location,", - " const std::string& message, const AgentValue& data) {", + "inline void emit(const std::string& hypothesis_id, const std::string& location,", + " const std::string& message, const std::string& data_json) {", " try {", - " std::ostringstream out;", - " out << '{';", - ' __agent_debug_write_string(out, "hypothesisId");', - " out << ':';", - " __agent_debug_write_string(out, hypothesisId);", - " out << ',';", - ' __agent_debug_write_string(out, "location");', - " out << ':';", - " __agent_debug_write_string(out, location);", - " out << ',';", - ' __agent_debug_write_string(out, "message");', - " out << ':';", - " __agent_debug_write_string(out, message);", - " out << ',';", - ' __agent_debug_write_string(out, "data");', - " out << ':';", - " if (!__agent_debug_write_value(out, data, 0)) {", + " if (data_json.empty()) {", " return;", " }", - " out << ',';", - ' __agent_debug_write_string(out, "timestamp");', - " out << ':';", - " out << __agent_debug_timestamp_ms();", - " out << '}';", - " out << static_cast(0x0A);", - " std::string payload = out.str();", - " if (payload.size() > 65536) {", + " std::string out;", + " out.push_back('{');", + ' out += json_string("hypothesisId");', + " out.push_back(':');", + " out += json_string(hypothesis_id);", + " out.push_back(',');", + ' out += json_string("location");', + " out.push_back(':');", + " out += json_string(location);", + " out.push_back(',');", + ' out += json_string("message");', + " out.push_back(':');", + " out += json_string(message);", + " out.push_back(',');", + ' out += json_string("data");', + " out.push_back(':');", + " out += data_json;", + " out.push_back(',');", + ' out += json_string("timestamp");', + " out.push_back(':');", + " out += std::to_string(timestamp_ms());", + " out.push_back('}');", + " out.push_back(static_cast(0x0A));", + " if (out.size() > 65536) {", " return;", " }", + "#ifdef _WIN32", ' std::ofstream file("__APPEND_PATH__", std::ios::out | std::ios::app | std::ios::binary);', " if (!file.is_open()) {", " return;", " }", - " file << payload;", - " file.close();", - "#ifndef _WIN32", - ' chmod("__APPEND_PATH__", 0600);', + " file << out;", + "#else", + ' int fd = open("__APPEND_PATH__", O_WRONLY | O_APPEND | O_CREAT, 0600);', + " if (fd >= 0) {", + " ssize_t wrote = write(fd, out.data(), out.size());", + " (void)wrote;", + " close(fd);", + " }", "#endif", " } catch (...) {", " return;", " }", "}", + "", + "} // namespace agent_debug_mode", "// #endregion", ].join("\n"), ingest: "file", language: "cpp", - dataEncoding: "native-json-value", placement: { call: "statement", helper: "file-start" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", - __DATA_EXPRESSION__: "Replace with a JSON-compatible C++ AgentValue that has no secrets.", + __DATA_JSON_EXPRESSION__: + "Replace with an expression that returns one complete valid JSON value containing no secrets. Use the application's serializer when available; otherwise pass agent_debug_mode::json_string(text).", __HYPOTHESIS_ID__: "Replace with the hypothesis label.", __LOCATION__: "Replace with the observed source location.", __MESSAGE__: "Replace with a constant observation message.", diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 4c87912..a2ecad0 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -28,7 +28,7 @@ const supported = [ const callMetadataPlaceholders = ["__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__"] as const; -const serializedJsonLanguages = new Set(["rust"]); +const serializedJsonLanguages = new Set(["rust", "cpp"]); describe("session-independent template renderers", () => { for (const [language, ingest] of supported) { @@ -103,6 +103,35 @@ describe("session-independent template renderers", () => { ); }); + test("cpp emits serialized JSON through an isolated helper namespace", () => { + const template = renderTemplate("cpp", "file"); + expect(template.dataEncoding).toBe("serialized-json"); + expect(template.placement).toEqual({ call: "statement", helper: "file-start" }); + expect(template.callTemplate).toContain("__DATA_JSON_EXPRESSION__"); + expect(template.callTemplate).not.toContain("__DATA_EXPRESSION__"); + expect(template.helperTemplate).not.toContain("__DATA_EXPRESSION__"); + expect(template.helperTemplate).toContain("namespace agent_debug_mode"); + expect(template.helperTemplate).toContain("65536"); + expect(template.helperTemplate).toContain("// #region agent log"); + expect(template.helperTemplate).toContain("// #endregion"); + expect(template.callTemplate).toContain("// #region agent log"); + const combined = `${template.helperTemplate}\n${template.callTemplate}`; + // The deleted value model, its initializer-list object detection, and the + // client-side secret redaction must all be gone. + for (const removed of ["AgentValue", "AgentKind", "initializer_list", "REDACTED", "secret"]) { + expect(combined).not.toContain(removed); + } + expect(Object.keys(template.placeholders).sort()).toEqual( + [ + "__APPEND_PATH__", + "__DATA_JSON_EXPRESSION__", + "__HYPOTHESIS_ID__", + "__LOCATION__", + "__MESSAGE__", + ].sort(), + ); + }); + test("HTTP helpers clean up every fulfilled response body", () => { for (const language of ["javascript", "typescript"]) { const helper = renderTemplate(language, "http").helperTemplate; diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 4093ca4..b5e30e3 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -243,32 +243,22 @@ const fixtures: Fixture[] = [ runtime: Bun.which("rustc"), }, { + // Serialized-json call sites hand the emitter one complete raw JSON value. + // The fixture emits the policy matrix verbatim (secrets included) as a raw + // string literal; the daemon performs redaction, so canonical evidence still + // equals canonicalPolicyData. callData: - 'AgentValue{ {"value", 42LL}, {"designToken", "visible-design-token"}, {"fortuneCookie", "visible-fortune-cookie"}, {"secretSauceName", "visible-secret-sauce"}, {"tokenCount", 7LL}, {"passwordPolicy", "visible-password-policy"}, {"password", "source-password-secret"}, {"APIKey", "source-api-acronym-secret"}, {"APIToken", "source-api-token-secret"}, {"IDToken", "source-id-token-secret"}, {"OAuthToken", "source-oauth-token-secret"}, {"Client Secret", "source-client-secret"}, {"nested", AgentValue{ {"apiKey", "source-api-secret"}, {"items", AgentValue{ AgentValue{ {"refresh-token", "source-refresh-secret"} }, AgentValue{ {"credentials", "source-credentials-secret"} } }} }} }', - // C++'s AgentValue is an owned value tree, so a reference cycle is - // unrepresentable. The depth-64 cap is the analogous unbounded-structure - // rejection: a value nested past the cap is dropped without emitting, - // exactly like a cycle would be. + 'R"({"value":42,"designToken":"visible-design-token","fortuneCookie":"visible-fortune-cookie","secretSauceName":"visible-secret-sauce","tokenCount":7,"passwordPolicy":"visible-password-policy","password":"source-password-secret","APIKey":"source-api-acronym-secret","APIToken":"source-api-token-secret","IDToken":"source-id-token-secret","OAuthToken":"source-oauth-token-secret","Client Secret":"source-client-secret","nested":{"apiKey":"source-api-secret","items":[{"refresh-token":"source-refresh-secret"},{"credentials":"source-credentials-secret"}]}})"', command: (path) => { const compiler = Bun.which("clang++") ?? Bun.which("g++") ?? ""; const binary = path.replace(/\.cpp$/, process.platform === "win32" ? ".exe" : ".out"); return [[compiler, "-std=c++17", path, "-o", binary], [binary]]; }, - cycleData: "__agent_cycle", - // Build the deep structure by explicitly appending to arrayValue rather than - // AgentValue{ __agent_cycle }: a single-element braced-init-list is ambiguous - // between the initializer_list constructor and the copy - // constructor, and compilers disagree on which wins (Apple clang wraps, - // clang 18 / gcc 13 copy), so the nesting depth would vary by toolchain. - cyclePrelude: - "AgentValue __agent_cycle = AgentValue(0LL); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { AgentValue __agent_wrap; __agent_wrap.kind = AgentKind::Array; __agent_wrap.arrayValue.push_back(__agent_cycle); __agent_cycle = __agent_wrap; }", - dataEncoding: "native-json-value", + dataEncoding: "serialized-json", file: "cpp-file.cpp", ingest: "file", language: "cpp", runtime: Bun.which("clang++") ?? Bun.which("g++"), - sharedData: 'AgentValue{ {"left", __agent_shared}, {"right", __agent_shared} }', - sharedPrelude: 'AgentValue __agent_shared = AgentValue{ {"APIKey", "source-shared-secret"} };', }, { callData: @@ -1348,6 +1338,274 @@ describe("rust serialized-JSON template", () => { ); }); +// Serialized-JSON runtime coverage specific to C++: the caller supplies raw +// JSON, so these exercise the emitter's raw-value passthrough, the json_string +// fallback, size/validity bounds, concurrency, and realistic insertion. +describe("cpp serialized-JSON template", () => { + const cpp = fixtures.find((candidate) => candidate.language === "cpp"); + if (!cpp) { + throw new Error("Missing cpp fixture"); + } + const cppFixture = cpp; + const cppRuntimeTest = cppFixture.runtime === null && !requireRuntimes ? test.skip : test; + + // Compiles the rendered helper plus a bespoke main body appending to + // appendPath, then runs it. Returns the process result. + async function compileAndRun( + home: string, + workspace: string, + fileName: string, + body: string, + appendPath: string, + ) { + const rendered = await render(home, cppFixture); + const helper = rendered.data.helperTemplate.replaceAll( + "__APPEND_PATH__", + appendPath.replaceAll("\\", "\\\\"), + ); + const source = `${helper}\n\n#include \n#include \n\nint main() {\n${body}\n std::cout << "application-completed" << std::endl;\n return 0;\n}\n`; + const fixturePath = join(workspace, fileName); + await writeFile(fixturePath, source); + return runSteps(cppFixture.command(fixturePath)); + } + + cppRuntimeTest( + "accepts serialized arrays, strings, numbers, booleans, and null", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-types-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "types.ndjson"); + const body = [ + ' agent_debug_mode::emit("H", "loc:1", "array", R"([1,2,3])");', + ' agent_debug_mode::emit("H", "loc:2", "string", agent_debug_mode::json_string("plain text"));', + ' agent_debug_mode::emit("H", "loc:3", "number", R"(42)");', + ' agent_debug_mode::emit("H", "loc:4", "boolean", R"(true)");', + ' agent_debug_mode::emit("H", "loc:5", "null", R"(null)");', + ].join("\n"); + const result = await compileAndRun(home, workspace, "cpp-types.cpp", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const lines = (await readFile(appendPath, "utf8")).trim().split("\n"); + const data = lines.map((line) => (JSON.parse(line) as { data: unknown }).data); + expect(data).toEqual([[1, 2, 3], "plain text", 42, true, null]); + }, + 180_000, + ); + + cppRuntimeTest( + "json_string escapes quotes, backslashes, control characters, and newlines", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-escape-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "escape.ndjson"); + // Build the tricky text from char codes so the C++ source needs no + // escaping: a, quote, backslash, newline, tab, U+0001, z. + const body = [ + " std::string tricky;", + " tricky.push_back('a');", + " tricky.push_back(static_cast(34));", + " tricky.push_back(static_cast(92));", + " tricky.push_back(static_cast(10));", + " tricky.push_back(static_cast(9));", + " tricky.push_back(static_cast(1));", + " tricky.push_back('z');", + ' agent_debug_mode::emit("H", "loc", "escape", agent_debug_mode::json_string(tricky));', + ].join("\n"); + const result = await compileAndRun(home, workspace, "cpp-escape.cpp", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + const event = JSON.parse(line ?? "") as { data: unknown }; + const expected = `a"\\\n\t${String.fromCharCode(1)}z`; + expect(event.data).toBe(expected); + }, + 180_000, + ); + + cppRuntimeTest( + "records malformed raw JSON as rejected without affecting the application", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-malformed-")); + temporaryDirectories.push(home, workspace); + const created = await createSession(home); + try { + const body = ' agent_debug_mode::emit("H", "loc:7", "broken", R"({"broken":)");'; + const result = await compileAndRun( + home, + workspace, + "cpp-malformed.cpp", + body, + created.data.appendPath, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + // The daemon needs a moment to observe the incoming line and classify it. + let diagnostics: unknown[] = []; + for (let attempt = 0; attempt < 80 && diagnostics.length === 0; attempt += 1) { + const status = await runCli(home, [ + "status", + "--session", + created.data.sessionId, + "--json", + ]); + if (status.exitCode === 0) { + const parsed = JSON.parse(status.stdout) as { data: { diagnostics?: unknown[] } }; + diagnostics = parsed.data.diagnostics ?? []; + } + if (diagnostics.length === 0) { + await Bun.sleep(50); + } + } + expect(diagnostics.length).toBeGreaterThan(0); + const logs = await runCli(home, ["logs", "--session", created.data.sessionId, "--json"]); + expect(logs.exitCode, logs.stderr).toBe(0); + // The malformed record is counted but not accepted: no valid record is + // ingested, and the daemon flags exactly one malformed record. + expect(JSON.parse(logs.stdout)).toMatchObject({ + statistics: { malformedRecords: 1, validRecords: 0 }, + }); + } finally { + await runCli(home, ["stop", "--json"]); + } + }, + 180_000, + ); + + cppRuntimeTest( + "drops an oversize event without affecting the application", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-oversize-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "oversize.ndjson"); + const body = [ + ' std::string big = "[";', + " for (int i = 0; i < 40000; i += 1) {", + ' big += "0,";', + " }", + ' big += "0]";', + ' agent_debug_mode::emit("H", "loc", "oversize", big);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "cpp-oversize.cpp", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const raw = await readFile(appendPath, "utf8").catch(() => ""); + expect(raw).toBe(""); + }, + 180_000, + ); + + cppRuntimeTest( + "concurrent emitters append complete, independently parseable records", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const compiler = Bun.which("clang++") ?? Bun.which("g++"); + expect(compiler, "a C++ compiler must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-concurrent-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "concurrent.ndjson"); + const rendered = await render(home, cppFixture); + const helper = rendered.data.helperTemplate.replaceAll( + "__APPEND_PATH__", + appendPath.replaceAll("\\", "\\\\"), + ); + const source = `${helper}\n\nint main() {\n agent_debug_mode::emit("H", "loc", "concurrent", R"({"n":1})");\n return 0;\n}\n`; + const fixturePath = join(workspace, "cpp-concurrent.cpp"); + const binary = join( + workspace, + process.platform === "win32" ? "cpp-concurrent.exe" : "cpp-concurrent.out", + ); + await writeFile(fixturePath, source); + const compiled = await run([compiler ?? "", "-std=c++17", fixturePath, "-o", binary]); + expect(compiled.exitCode, compiled.stderr).toBe(0); + const executions = await Promise.all(Array.from({ length: 32 }, () => run([binary]))); + for (const execution of executions) { + expect(execution.exitCode, execution.stderr).toBe(0); + } + const contents = await readFile(appendPath, "utf8"); + expect(contents.endsWith("\n")).toBe(true); + const lines = contents.split("\n").filter(Boolean); + expect(lines).toHaveLength(32); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }, + 180_000, + ); + + cppRuntimeTest( + "keeps metadata containing quotes and control characters valid", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-metadata-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "metadata.ndjson"); + const body = [ + " std::string hid;", + " hid.push_back('H');", + " hid.push_back(static_cast(34));", + " hid.push_back(static_cast(10));", + " std::string msg;", + " msg.push_back(static_cast(9));", + ' msg += "msg";', + " msg.push_back(static_cast(92));", + ' agent_debug_mode::emit(hid, "loc", msg, R"({"ok":true})");', + ].join("\n"); + const result = await compileAndRun(home, workspace, "cpp-metadata.cpp", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + const event = JSON.parse(line ?? "") as { + hypothesisId: string; + message: string; + data: unknown; + }; + expect(event.hypothesisId).toBe(`H"\n`); + expect(event.message).toBe(`\tmsg\\`); + expect(event.data).toEqual({ ok: true }); + }, + 180_000, + ); + + cppRuntimeTest( + "compiles when the helper is inserted into a realistic C++ file", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-realistic-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "realistic.ndjson"); + const rendered = await render(home, cppFixture); + const source = await readFile( + join(root, "tests", "fixtures", "languages", "cpp-realistic.cpp"), + "utf8", + ); + const materialized = materialize( + source, + rendered.data, + cppFixture, + appendPath.replaceAll("\\", "\\\\"), + 1, + "agent_debug_mode::json_string(value.label)", + ); + const fixturePath = join(workspace, "cpp-realistic.cpp"); + await writeFile(fixturePath, materialized); + const result = await runSteps(cppFixture.command(fixturePath)); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + expect((JSON.parse(line ?? "") as { data: unknown }).data).toBe("inner-module"); + }, + 180_000, + ); +}); + // Fault-injection coverage for the capture proxy's forward retry. This is the // load-bearing proof that the retry recovers the exact failure CI hit — a // transient forward failure that the previous bare `catch {}` swallowed, so the diff --git a/tests/fixtures/languages/cpp-file.cpp b/tests/fixtures/languages/cpp-file.cpp index b4ffe3e..a97fa5e 100644 --- a/tests/fixtures/languages/cpp-file.cpp +++ b/tests/fixtures/languages/cpp-file.cpp @@ -1,3 +1,5 @@ +#include + __HELPER_TEMPLATE__ int main() { diff --git a/tests/fixtures/languages/cpp-realistic.cpp b/tests/fixtures/languages/cpp-realistic.cpp new file mode 100644 index 0000000..6a93afd --- /dev/null +++ b/tests/fixtures/languages/cpp-realistic.cpp @@ -0,0 +1,41 @@ +#include +#include +#include + +__HELPER_TEMPLATE__ + +// An application type deliberately named AgentValue, alongside a namespace, to +// prove the inserted helper introduces no conflicting symbols and that its +// includes and declarations sit legally before existing application code. The +// helper keeps its symbols inside the agent_debug_mode namespace, so this global +// AgentValue is untouched. +struct AgentValue { + std::string label; + std::unordered_map counts; + + long long total() const { + long long sum = 0; + for (const auto& entry : counts) { + sum += entry.second; + } + return sum; + } +}; + +namespace domain { +namespace inner { +inline std::string describe() { + return "inner-module"; +} +} // namespace inner +} // namespace domain + +int main() { + AgentValue value; + value.label = domain::inner::describe(); + value.counts["hits"] = 3; + value.counts["misses"] = 1; + __CALL_TEMPLATE__ + std::cout << "application-completed: " << value.label << " " << value.total() << std::endl; + return 0; +} From 95f0c861a5091fdeb81c5e621ac982640dbe0312 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 16:58:15 -0700 Subject: [PATCH 12/18] feat: migrate the C probe template to serialized JSON Replace the C probe's allocated AgentValue pointer tree, adbg_* variadic builders, recursive serializer, depth handling, and secret-key redaction with a raw-JSON passthrough emitter that mirrors the migrated Rust and C++ templates. The caller now supplies one complete serialized JSON value; the helper only escapes envelope metadata, stamps the timestamp, enforces the 65,536-byte cap, and atomically appends. Symbols carry a distinctive agent_debug_ prefix in place of the removed generic global names. The generated helper drops from 538 to 142 nonblank lines (74% smaller). Co-Authored-By: Claude Fable 5 --- src/probes/c.ts | 579 +++------------------- tests/contract/template-renderers.test.ts | 33 +- tests/e2e/languages/live-probes.test.ts | 306 +++++++++++- tests/fixtures/languages/c-realistic.c | 33 ++ 4 files changed, 437 insertions(+), 514 deletions(-) create mode 100644 tests/fixtures/languages/c-realistic.c diff --git a/src/probes/c.ts b/src/probes/c.ts index 4fd3b03..5cd0d34 100644 --- a/src/probes/c.ts +++ b/src/probes/c.ts @@ -4,16 +4,14 @@ export function renderCTemplate(): ProbeTemplates { return { callTemplate: [ "// #region agent log", - '__agentDebugEmit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_EXPRESSION__);', + 'agent_debug_emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_JSON_EXPRESSION__);', "// #endregion", ].join("\n"), + dataEncoding: "serialized-json", helperTemplate: [ "// #region agent log", "#define _POSIX_C_SOURCE 200809L", - "#include ", - "#include ", "#include ", - "#include ", "#include ", "#include ", "#ifndef _WIN32", @@ -21,28 +19,6 @@ export function renderCTemplate(): ProbeTemplates { "#include ", "#endif", "", - "enum {", - " AGENT_KIND_NULL,", - " AGENT_KIND_BOOL,", - " AGENT_KIND_INT,", - " AGENT_KIND_DOUBLE,", - " AGENT_KIND_STR,", - " AGENT_KIND_ARRAY,", - " AGENT_KIND_OBJECT", - "};", - "", - "typedef struct AgentValue AgentValue;", - "struct AgentValue {", - " int kind;", - " int boolValue;", - " long long intValue;", - " double doubleValue;", - " char *strValue;", - " char **keys;", - " AgentValue **items;", - " size_t count;", - "};", - "", "typedef struct {", " char *buffer;", " size_t length;", @@ -50,316 +26,7 @@ export function renderCTemplate(): ProbeTemplates { " int overflow;", "} AgentDebugBuffer;", "", - "static const char *const AGENT_DEBUG_SECRET_KEYS[] = {", - ' "authorization", "authorization_header", "cookie", "set_cookie",', - ' "password", "passwd", "pwd", "private_key", "secret", "token",', - ' "credential", "credentials"};', - "", - "static const char *const AGENT_DEBUG_SECRET_SUFFIXES[] = {", - ' "api_key", "api_token", "oauth_token", "o_auth_token", "private_key",', - ' "client_secret", "access_token", "refresh_token", "id_token",', - ' "auth_token", "bearer_token"};', - "", - "static AgentValue *__agent_debug_new(int kind) {", - " AgentValue *value = (AgentValue *)malloc(sizeof(AgentValue));", - " if (value == NULL) {", - " return NULL;", - " }", - " value->kind = kind;", - " value->boolValue = 0;", - " value->intValue = 0;", - " value->doubleValue = 0.0;", - " value->strValue = NULL;", - " value->keys = NULL;", - " value->items = NULL;", - " value->count = 0;", - " return value;", - "}", - "", - "static char *__agent_debug_strdup(const char *text) {", - " if (text == NULL) {", - " return NULL;", - " }", - " size_t n = strlen(text);", - " char *copy = (char *)malloc(n + 1);", - " if (copy == NULL) {", - " return NULL;", - " }", - " for (size_t i = 0; i <= n; i += 1) {", - " copy[i] = text[i];", - " }", - " return copy;", - "}", - "", - "static void __agent_debug_free(AgentValue *value) {", - " if (value == NULL) {", - " return;", - " }", - " if (value->strValue != NULL) {", - " free(value->strValue);", - " }", - " if (value->keys != NULL) {", - " for (size_t i = 0; i < value->count; i += 1) {", - " if (value->keys[i] != NULL) {", - " free(value->keys[i]);", - " }", - " }", - " free(value->keys);", - " }", - " if (value->items != NULL) {", - " for (size_t i = 0; i < value->count; i += 1) {", - " __agent_debug_free(value->items[i]);", - " }", - " free(value->items);", - " }", - " free(value);", - "}", - "", - "static AgentValue *adbg_null(void) {", - " return __agent_debug_new(AGENT_KIND_NULL);", - "}", - "", - "static AgentValue *adbg_bool(int flag) {", - " AgentValue *value = __agent_debug_new(AGENT_KIND_BOOL);", - " if (value != NULL) {", - " value->boolValue = flag ? 1 : 0;", - " }", - " return value;", - "}", - "", - "static AgentValue *adbg_int(long long number) {", - " AgentValue *value = __agent_debug_new(AGENT_KIND_INT);", - " if (value != NULL) {", - " value->intValue = number;", - " }", - " return value;", - "}", - "", - "static AgentValue *adbg_double(double number) {", - " AgentValue *value = __agent_debug_new(AGENT_KIND_DOUBLE);", - " if (value != NULL) {", - " value->doubleValue = number;", - " }", - " return value;", - "}", - "", - "static AgentValue *adbg_str(const char *text) {", - " AgentValue *value = __agent_debug_new(AGENT_KIND_STR);", - " if (value != NULL) {", - " value->strValue = __agent_debug_strdup(text);", - " }", - " return value;", - "}", - "", - "static AgentValue *adbg_arr(AgentValue *first, ...) {", - " AgentValue *value = __agent_debug_new(AGENT_KIND_ARRAY);", - " va_list args;", - " va_start(args, first);", - " AgentValue *current = first;", - " while (current != NULL) {", - " if (value != NULL) {", - " AgentValue **grown = (AgentValue **)realloc(value->items, (value->count + 1) * sizeof(AgentValue *));", - " if (grown != NULL) {", - " value->items = grown;", - " value->items[value->count] = current;", - " value->count += 1;", - " } else {", - " __agent_debug_free(current);", - " }", - " } else {", - " __agent_debug_free(current);", - " }", - " current = va_arg(args, AgentValue *);", - " }", - " va_end(args);", - " return value;", - "}", - "", - "static AgentValue *adbg_obj(const char *first_key, ...) {", - " AgentValue *value = __agent_debug_new(AGENT_KIND_OBJECT);", - " va_list args;", - " va_start(args, first_key);", - " const char *key = first_key;", - " while (key != NULL) {", - " AgentValue *child = va_arg(args, AgentValue *);", - " if (value != NULL) {", - " char **grown_keys = (char **)realloc(value->keys, (value->count + 1) * sizeof(char *));", - " if (grown_keys != NULL) {", - " value->keys = grown_keys;", - " }", - " AgentValue **grown_items = (AgentValue **)realloc(value->items, (value->count + 1) * sizeof(AgentValue *));", - " if (grown_items != NULL) {", - " value->items = grown_items;", - " }", - " if (grown_keys != NULL && grown_items != NULL) {", - " value->keys[value->count] = __agent_debug_strdup(key);", - " value->items[value->count] = child;", - " value->count += 1;", - " } else {", - " __agent_debug_free(child);", - " }", - " } else {", - " __agent_debug_free(child);", - " }", - " key = va_arg(args, const char *);", - " }", - " va_end(args);", - " return value;", - "}", - "", - "static char *__agent_debug_split_acronym(const char *input) {", - " size_t n = strlen(input);", - " char *result = (char *)malloc(n * 2 + 1);", - " if (result == NULL) {", - " return NULL;", - " }", - " size_t out = 0;", - " size_t i = 0;", - " while (i < n) {", - " unsigned char ci = (unsigned char)input[i];", - " if (ci >= 'A' && ci <= 'Z') {", - " size_t j = i;", - " while (j < n) {", - " unsigned char cj = (unsigned char)input[j];", - " if (cj >= 'A' && cj <= 'Z') {", - " j += 1;", - " } else {", - " break;", - " }", - " }", - " unsigned char after = (j < n) ? (unsigned char)input[j] : 0;", - " if (j - i >= 2 && j < n && after >= 'a' && after <= 'z') {", - " for (size_t k = i; k < j - 1; k += 1) {", - " result[out] = input[k];", - " out += 1;", - " }", - " result[out] = '_';", - " out += 1;", - " result[out] = input[j - 1];", - " out += 1;", - " result[out] = input[j];", - " out += 1;", - " i = j + 1;", - " continue;", - " }", - " for (size_t k = i; k < j; k += 1) {", - " result[out] = input[k];", - " out += 1;", - " }", - " i = j;", - " continue;", - " }", - " result[out] = input[i];", - " out += 1;", - " i += 1;", - " }", - " result[out] = 0;", - " return result;", - "}", - "", - "static char *__agent_debug_split_camel(const char *input) {", - " size_t n = strlen(input);", - " char *result = (char *)malloc(n * 2 + 1);", - " if (result == NULL) {", - " return NULL;", - " }", - " size_t out = 0;", - " for (size_t i = 0; i < n; i += 1) {", - " unsigned char ci = (unsigned char)input[i];", - " result[out] = input[i];", - " out += 1;", - " int boundary = (ci >= 'a' && ci <= 'z') || (ci >= '0' && ci <= '9');", - " if (boundary && i + 1 < n) {", - " unsigned char next = (unsigned char)input[i + 1];", - " if (next >= 'A' && next <= 'Z') {", - " result[out] = '_';", - " out += 1;", - " }", - " }", - " }", - " result[out] = 0;", - " return result;", - "}", - "", - "static char *__agent_debug_normalize_key(const char *key) {", - " char *acronym = __agent_debug_split_acronym(key);", - " if (acronym == NULL) {", - " return NULL;", - " }", - " char *split = __agent_debug_split_camel(acronym);", - " free(acronym);", - " if (split == NULL) {", - " return NULL;", - " }", - " size_t n = strlen(split);", - " char *normalized = (char *)malloc(n + 1);", - " if (normalized == NULL) {", - " free(split);", - " return NULL;", - " }", - " size_t out = 0;", - " int pending_underscore = 0;", - " for (size_t i = 0; i < n; i += 1) {", - " unsigned char c = (unsigned char)split[i];", - " int alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');", - " if (alnum) {", - " if (pending_underscore && out > 0) {", - " normalized[out] = '_';", - " out += 1;", - " }", - " pending_underscore = 0;", - " if (c >= 'A' && c <= 'Z') {", - " normalized[out] = (char)(c - 'A' + 'a');", - " out += 1;", - " } else {", - " normalized[out] = (char)c;", - " out += 1;", - " }", - " } else {", - " pending_underscore = 1;", - " }", - " }", - " normalized[out] = 0;", - " free(split);", - " return normalized;", - "}", - "", - "static int __agent_debug_is_secret_key(const char *key) {", - " if (key == NULL) {", - " return 0;", - " }", - " char *normalized = __agent_debug_normalize_key(key);", - " if (normalized == NULL) {", - " return 0;", - " }", - " size_t norm_len = strlen(normalized);", - " int secret = 0;", - " size_t exact_count = sizeof(AGENT_DEBUG_SECRET_KEYS) / sizeof(AGENT_DEBUG_SECRET_KEYS[0]);", - " for (size_t i = 0; i < exact_count; i += 1) {", - " if (strcmp(normalized, AGENT_DEBUG_SECRET_KEYS[i]) == 0) {", - " secret = 1;", - " break;", - " }", - " }", - " if (!secret) {", - " size_t suffix_count = sizeof(AGENT_DEBUG_SECRET_SUFFIXES) / sizeof(AGENT_DEBUG_SECRET_SUFFIXES[0]);", - " for (size_t i = 0; i < suffix_count; i += 1) {", - " const char *suffix = AGENT_DEBUG_SECRET_SUFFIXES[i];", - " size_t suffix_len = strlen(suffix);", - " if (norm_len >= suffix_len && strcmp(normalized + (norm_len - suffix_len), suffix) == 0) {", - " size_t boundary = norm_len - suffix_len;", - " if (boundary == 0 || normalized[boundary - 1] == '_') {", - " secret = 1;", - " break;", - " }", - " }", - " }", - " }", - " free(normalized);", - " return secret;", - "}", - "", - "static void __agent_debug_append(AgentDebugBuffer *buf, const char *text, size_t n) {", + "static void agent_debug_append(AgentDebugBuffer *buf, const char *text, size_t n) {", " if (buf->overflow) {", " return;", " }", @@ -367,146 +34,61 @@ export function renderCTemplate(): ProbeTemplates { " buf->overflow = 1;", " return;", " }", - " for (size_t i = 0; i < n; i += 1) {", - " buf->buffer[buf->length + i] = text[i];", - " }", + " memcpy(buf->buffer + buf->length, text, n);", " buf->length += n;", "}", "", - "static void __agent_debug_append_char(AgentDebugBuffer *buf, char c) {", - " __agent_debug_append(buf, &c, 1);", - "}", - "", - "static void __agent_debug_append_ll(AgentDebugBuffer *buf, long long number) {", - " char temp[32];", - ' int written = snprintf(temp, sizeof(temp), "%lld", number);', - " if (written < 0) {", - " buf->overflow = 1;", - " return;", - " }", - " __agent_debug_append(buf, temp, (size_t)written);", - "}", - "", - "static void __agent_debug_append_double(AgentDebugBuffer *buf, double number) {", - " char temp[64];", - ' int written = snprintf(temp, sizeof(temp), "%.17g", number);', - " if (written < 0) {", - " buf->overflow = 1;", - " return;", - " }", - " __agent_debug_append(buf, temp, (size_t)written);", + "static void agent_debug_append_char(AgentDebugBuffer *buf, char c) {", + " agent_debug_append(buf, &c, 1);", "}", "", - "static void __agent_debug_write_string(AgentDebugBuffer *buf, const char *text) {", + "static void agent_debug_write_string(AgentDebugBuffer *buf, const char *text) {", " char backslash = (char)0x5C;", " char quote = (char)0x22;", - " __agent_debug_append_char(buf, quote);", + " agent_debug_append_char(buf, quote);", " if (text != NULL) {", " for (const unsigned char *p = (const unsigned char *)text; *p != 0; p += 1) {", " unsigned int code = (unsigned int)(*p);", " char c = (char)(*p);", " if (c == quote) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, quote);", + " agent_debug_append_char(buf, backslash);", + " agent_debug_append_char(buf, quote);", " } else if (c == backslash) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, backslash);", - " } else if (code == 8) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, 'b');", - " } else if (code == 9) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, 't');", - " } else if (code == 10) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, 'n');", - " } else if (code == 12) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, 'f');", - " } else if (code == 13) {", - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, 'r');", + " agent_debug_append_char(buf, backslash);", + " agent_debug_append_char(buf, backslash);", + " } else if (code == 8 || code == 9 || code == 10 || code == 12 || code == 13) {", + ' const char *escapes = "btn\\0fr";', + " agent_debug_append_char(buf, backslash);", + " agent_debug_append_char(buf, escapes[code - 8]);", " } else if (code < 32) {", ' const char *digits = "0123456789abcdef";', - " __agent_debug_append_char(buf, backslash);", - " __agent_debug_append_char(buf, 'u');", - " __agent_debug_append_char(buf, '0');", - " __agent_debug_append_char(buf, '0');", - " __agent_debug_append_char(buf, digits[(code >> 4) & 15]);", - " __agent_debug_append_char(buf, digits[code & 15]);", + " agent_debug_append_char(buf, backslash);", + " agent_debug_append_char(buf, 'u');", + " agent_debug_append_char(buf, '0');", + " agent_debug_append_char(buf, '0');", + " agent_debug_append_char(buf, digits[(code >> 4) & 15]);", + " agent_debug_append_char(buf, digits[code & 15]);", " } else {", - " __agent_debug_append_char(buf, c);", + " agent_debug_append_char(buf, c);", " }", " }", " }", - " __agent_debug_append_char(buf, quote);", + " agent_debug_append_char(buf, quote);", "}", "", - "static int __agent_debug_write_value(AgentDebugBuffer *buf, const AgentValue *value, int depth) {", - " if (depth > 64) {", - " return 0;", - " }", - " if (value == NULL) {", - " return 0;", - " }", - " switch (value->kind) {", - " case AGENT_KIND_NULL:", - ' __agent_debug_append(buf, "null", 4);', - " break;", - " case AGENT_KIND_BOOL:", - " if (value->boolValue) {", - ' __agent_debug_append(buf, "true", 4);', - " } else {", - ' __agent_debug_append(buf, "false", 5);', - " }", - " break;", - " case AGENT_KIND_INT:", - " __agent_debug_append_ll(buf, value->intValue);", - " break;", - " case AGENT_KIND_DOUBLE:", - " if (!isfinite(value->doubleValue)) {", - " return 0;", - " }", - " __agent_debug_append_double(buf, value->doubleValue);", - " break;", - " case AGENT_KIND_STR:", - " __agent_debug_write_string(buf, value->strValue);", - " break;", - " case AGENT_KIND_ARRAY: {", - " __agent_debug_append_char(buf, '[');", - " for (size_t i = 0; i < value->count; i += 1) {", - " if (i > 0) {", - " __agent_debug_append_char(buf, ',');", - " }", - " if (!__agent_debug_write_value(buf, value->items[i], depth + 1)) {", - " return 0;", - " }", - " }", - " __agent_debug_append_char(buf, ']');", - " break;", - " }", - " case AGENT_KIND_OBJECT: {", - " __agent_debug_append_char(buf, '{');", - " for (size_t i = 0; i < value->count; i += 1) {", - " if (i > 0) {", - " __agent_debug_append_char(buf, ',');", - " }", - " __agent_debug_write_string(buf, value->keys[i]);", - " __agent_debug_append_char(buf, ':');", - " if (__agent_debug_is_secret_key(value->keys[i])) {", - ' __agent_debug_write_string(buf, "[REDACTED]");', - " } else if (!__agent_debug_write_value(buf, value->items[i], depth + 1)) {", - " return 0;", - " }", - " }", - " __agent_debug_append_char(buf, '}');", - " break;", - " }", - " }", - " return 1;", + "static const char *agent_debug_json_string(const char *text) {", + " static char storage[65536 + 4096];", + " AgentDebugBuffer buf;", + " buf.buffer = storage;", + " buf.length = 0;", + " buf.capacity = sizeof(storage) - 1;", + " buf.overflow = 0;", + " agent_debug_write_string(&buf, text);", + " storage[buf.length] = 0;", + " return storage;", "}", "", - "static long long __agent_debug_timestamp_ms(void) {", + "static long long agent_debug_timestamp_ms(void) {", "#ifdef _WIN32", " return (long long)time(NULL) * 1000;", "#else", @@ -518,69 +100,72 @@ export function renderCTemplate(): ProbeTemplates { "#endif", "}", "", - "static void __agentDebugEmit(const char *hypothesisId, const char *location,", - " const char *message, AgentValue *data) {", + "static void agent_debug_emit(const char *hypothesis_id, const char *location,", + " const char *message, const char *data_json) {", + " if (data_json == NULL || data_json[0] == 0) {", + " return;", + " }", " char storage[65536 + 4096];", " AgentDebugBuffer buf;", " buf.buffer = storage;", " buf.length = 0;", " buf.capacity = sizeof(storage);", " buf.overflow = 0;", - " int serialized = 1;", - " __agent_debug_append_char(&buf, '{');", - ' __agent_debug_write_string(&buf, "hypothesisId");', - " __agent_debug_append_char(&buf, ':');", - " __agent_debug_write_string(&buf, hypothesisId);", - " __agent_debug_append_char(&buf, ',');", - ' __agent_debug_write_string(&buf, "location");', - " __agent_debug_append_char(&buf, ':');", - " __agent_debug_write_string(&buf, location);", - " __agent_debug_append_char(&buf, ',');", - ' __agent_debug_write_string(&buf, "message");', - " __agent_debug_append_char(&buf, ':');", - " __agent_debug_write_string(&buf, message);", - " __agent_debug_append_char(&buf, ',');", - ' __agent_debug_write_string(&buf, "data");', - " __agent_debug_append_char(&buf, ':');", - " if (!__agent_debug_write_value(&buf, data, 0)) {", - " serialized = 0;", + " char timestamp[32];", + ' int stamped = snprintf(timestamp, sizeof(timestamp), "%lld", agent_debug_timestamp_ms());', + " if (stamped < 0) {", + " return;", " }", - " if (serialized) {", - " __agent_debug_append_char(&buf, ',');", - ' __agent_debug_write_string(&buf, "timestamp");', - " __agent_debug_append_char(&buf, ':');", - " __agent_debug_append_ll(&buf, __agent_debug_timestamp_ms());", - " __agent_debug_append_char(&buf, '}');", - " __agent_debug_append_char(&buf, (char)0x0A);", + " agent_debug_append_char(&buf, '{');", + ' agent_debug_write_string(&buf, "hypothesisId");', + " agent_debug_append_char(&buf, ':');", + " agent_debug_write_string(&buf, hypothesis_id);", + " agent_debug_append_char(&buf, ',');", + ' agent_debug_write_string(&buf, "location");', + " agent_debug_append_char(&buf, ':');", + " agent_debug_write_string(&buf, location);", + " agent_debug_append_char(&buf, ',');", + ' agent_debug_write_string(&buf, "message");', + " agent_debug_append_char(&buf, ':');", + " agent_debug_write_string(&buf, message);", + " agent_debug_append_char(&buf, ',');", + ' agent_debug_write_string(&buf, "data");', + " agent_debug_append_char(&buf, ':');", + " agent_debug_append(&buf, data_json, strlen(data_json));", + " agent_debug_append_char(&buf, ',');", + ' agent_debug_write_string(&buf, "timestamp");', + " agent_debug_append_char(&buf, ':');", + " agent_debug_append(&buf, timestamp, (size_t)stamped);", + " agent_debug_append_char(&buf, '}');", + " agent_debug_append_char(&buf, (char)0x0A);", + " if (buf.overflow || buf.length > 65536) {", + " return;", " }", - " if (serialized && !buf.overflow && buf.length <= 65536) {", "#ifdef _WIN32", - ' FILE *file = fopen("__APPEND_PATH__", "ab");', - " if (file != NULL) {", - " size_t wrote = fwrite(storage, 1, buf.length, file);", - " (void)wrote;", - " fclose(file);", - " }", + ' FILE *file = fopen("__APPEND_PATH__", "ab");', + " if (file != NULL) {", + " size_t wrote = fwrite(storage, 1, buf.length, file);", + " (void)wrote;", + " fclose(file);", + " }", "#else", - ' int fd = open("__APPEND_PATH__", O_WRONLY | O_APPEND | O_CREAT, 0600);', - " if (fd >= 0) {", - " ssize_t wrote = write(fd, storage, buf.length);", - " (void)wrote;", - " close(fd);", - " }", - "#endif", + ' int fd = open("__APPEND_PATH__", O_WRONLY | O_APPEND | O_CREAT, 0600);', + " if (fd >= 0) {", + " ssize_t wrote = write(fd, storage, buf.length);", + " (void)wrote;", + " close(fd);", " }", - " __agent_debug_free(data);", + "#endif", "}", "// #endregion", ].join("\n"), ingest: "file", language: "c", - dataEncoding: "native-json-value", placement: { call: "statement", helper: "file-start" }, placeholders: { __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", - __DATA_EXPRESSION__: "Replace with a JSON-compatible C AgentValue that has no secrets.", + __DATA_JSON_EXPRESSION__: + "Replace with an expression that returns one complete valid JSON value containing no secrets. Use the application's serializer when available; otherwise pass agent_debug_json_string(text).", __HYPOTHESIS_ID__: "Replace with the hypothesis label.", __LOCATION__: "Replace with the observed source location.", __MESSAGE__: "Replace with a constant observation message.", diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index a2ecad0..1927a0c 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -28,7 +28,7 @@ const supported = [ const callMetadataPlaceholders = ["__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__"] as const; -const serializedJsonLanguages = new Set(["rust", "cpp"]); +const serializedJsonLanguages = new Set(["rust", "cpp", "c"]); describe("session-independent template renderers", () => { for (const [language, ingest] of supported) { @@ -132,6 +132,37 @@ describe("session-independent template renderers", () => { ); }); + test("c emits serialized JSON through a distinctively prefixed helper", () => { + const template = renderTemplate("c", "file"); + expect(template.dataEncoding).toBe("serialized-json"); + expect(template.placement).toEqual({ call: "statement", helper: "file-start" }); + expect(template.callTemplate).toContain("__DATA_JSON_EXPRESSION__"); + expect(template.callTemplate).not.toContain("__DATA_EXPRESSION__"); + expect(template.helperTemplate).not.toContain("__DATA_EXPRESSION__"); + expect(template.helperTemplate).toContain("agent_debug_emit"); + expect(template.helperTemplate).toContain("agent_debug_json_string"); + expect(template.helperTemplate).toContain("65536"); + expect(template.helperTemplate).toContain("// #region agent log"); + expect(template.helperTemplate).toContain("// #endregion"); + expect(template.callTemplate).toContain("// #region agent log"); + const combined = `${template.helperTemplate}\n${template.callTemplate}`; + // C has no namespaces, so the migration removes the old global value model, + // its variadic builders, and the client-side secret redaction, isolating the + // remaining helper behind a distinctive agent_debug_ prefix. + for (const removed of ["AgentValue", "AgentKind", "adbg", "REDACTED", "secret"]) { + expect(combined).not.toContain(removed); + } + expect(Object.keys(template.placeholders).sort()).toEqual( + [ + "__APPEND_PATH__", + "__DATA_JSON_EXPRESSION__", + "__HYPOTHESIS_ID__", + "__LOCATION__", + "__MESSAGE__", + ].sort(), + ); + }); + test("HTTP helpers clean up every fulfilled response body", () => { for (const language of ["javascript", "typescript"]) { const helper = renderTemplate(language, "http").helperTemplate; diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index b5e30e3..1356b49 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -261,31 +261,22 @@ const fixtures: Fixture[] = [ runtime: Bun.which("clang++") ?? Bun.which("g++"), }, { - callData: - 'adbg_obj("value", adbg_int(42), "designToken", adbg_str("visible-design-token"), "fortuneCookie", adbg_str("visible-fortune-cookie"), "secretSauceName", adbg_str("visible-secret-sauce"), "tokenCount", adbg_int(7), "passwordPolicy", adbg_str("visible-password-policy"), "password", adbg_str("source-password-secret"), "APIKey", adbg_str("source-api-acronym-secret"), "APIToken", adbg_str("source-api-token-secret"), "IDToken", adbg_str("source-id-token-secret"), "OAuthToken", adbg_str("source-oauth-token-secret"), "Client Secret", adbg_str("source-client-secret"), "nested", adbg_obj("apiKey", adbg_str("source-api-secret"), "items", adbg_arr(adbg_obj("refresh-token", adbg_str("source-refresh-secret"), NULL), adbg_obj("credentials", adbg_str("source-credentials-secret"), NULL), NULL), NULL), NULL)', - // C's AgentValue is an owned pointer tree, so a reference cycle is - // unrepresentable. The depth-64 cap is the analogous unbounded-structure - // rejection: a value nested past the cap is dropped without emitting, - // exactly like a cycle would be. + // Serialized-json call sites hand the emitter one complete raw JSON value. C + // has no raw-string literals, so the policy matrix (secrets included) is an + // escaped C string literal — the double JSON.stringify of policyInput yields + // exactly `"{\"value\":42,...}"`. The daemon performs redaction, so canonical + // evidence still equals canonicalPolicyData. + callData: JSON.stringify(JSON.stringify(policyInput)), command: (path) => { const compiler = Bun.which("clang") ?? Bun.which("gcc") ?? ""; const binary = path.replace(/\.c$/, process.platform === "win32" ? ".exe" : ".out"); return [[compiler, "-std=c99", path, "-o", binary], [binary]]; }, - cycleData: "__agent_cycle", - cyclePrelude: - "AgentValue *__agent_cycle = adbg_int(0); for (int __agent_depth = 0; __agent_depth < 100; __agent_depth += 1) { __agent_cycle = adbg_arr(__agent_cycle, NULL); }", - dataEncoding: "native-json-value", + dataEncoding: "serialized-json", file: "c-file.c", ingest: "file", language: "c", runtime: Bun.which("clang") ?? Bun.which("gcc"), - // C stores an owned pointer tree; sharing one pointer twice would double-free - // on cleanup, so the shared-reference case builds two independent equal - // subtrees (the test compares output equality, not pointer identity). - sharedData: - 'adbg_obj("left", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), "right", adbg_obj("APIKey", adbg_str("source-shared-secret"), NULL), NULL)', - sharedPrelude: "", }, { callData: @@ -1606,6 +1597,289 @@ describe("cpp serialized-JSON template", () => { ); }); +// Serialized-JSON runtime coverage specific to C: the caller supplies raw JSON, +// so these exercise the emitter's raw-value passthrough, the json_string +// fallback, size/validity bounds, concurrency, and realistic insertion. +describe("c serialized-JSON template", () => { + const c = fixtures.find((candidate) => candidate.language === "c"); + if (!c) { + throw new Error("Missing c fixture"); + } + const cFixture = c; + const cRuntimeTest = cFixture.runtime === null && !requireRuntimes ? test.skip : test; + + // Compiles the rendered helper plus a bespoke main body appending to + // appendPath, then runs it. Returns the process result. + async function compileAndRun( + home: string, + workspace: string, + fileName: string, + body: string, + appendPath: string, + ) { + const rendered = await render(home, cFixture); + const helper = rendered.data.helperTemplate.replaceAll( + "__APPEND_PATH__", + appendPath.replaceAll("\\", "\\\\"), + ); + const source = `${helper}\n\nint main(void) {\n${body}\n printf("application-completed\\n");\n return 0;\n}\n`; + const fixturePath = join(workspace, fileName); + await writeFile(fixturePath, source); + return runSteps(cFixture.command(fixturePath)); + } + + cRuntimeTest( + "accepts serialized arrays, strings, numbers, booleans, and null", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-types-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "types.ndjson"); + const body = [ + ' agent_debug_emit("H", "loc:1", "array", "[1,2,3]");', + ' agent_debug_emit("H", "loc:2", "string", agent_debug_json_string("plain text"));', + ' agent_debug_emit("H", "loc:3", "number", "42");', + ' agent_debug_emit("H", "loc:4", "boolean", "true");', + ' agent_debug_emit("H", "loc:5", "null", "null");', + ].join("\n"); + const result = await compileAndRun(home, workspace, "c-types.c", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const lines = (await readFile(appendPath, "utf8")).trim().split("\n"); + const data = lines.map((line) => (JSON.parse(line) as { data: unknown }).data); + expect(data).toEqual([[1, 2, 3], "plain text", 42, true, null]); + }, + 180_000, + ); + + cRuntimeTest( + "json_string escapes quotes, backslashes, control characters, and newlines", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-escape-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "escape.ndjson"); + // Build the tricky text from char codes so the C source needs no escaping: + // a, quote, backslash, newline, tab, U+0001, z. + const body = [ + " char tricky[8];", + " tricky[0] = 'a';", + " tricky[1] = (char)34;", + " tricky[2] = (char)92;", + " tricky[3] = (char)10;", + " tricky[4] = (char)9;", + " tricky[5] = (char)1;", + " tricky[6] = 'z';", + " tricky[7] = 0;", + ' agent_debug_emit("H", "loc", "escape", agent_debug_json_string(tricky));', + ].join("\n"); + const result = await compileAndRun(home, workspace, "c-escape.c", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + const event = JSON.parse(line ?? "") as { data: unknown }; + const expected = `a"\\\n\t${String.fromCharCode(1)}z`; + expect(event.data).toBe(expected); + }, + 180_000, + ); + + cRuntimeTest( + "records malformed raw JSON as rejected without affecting the application", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-malformed-")); + temporaryDirectories.push(home, workspace); + const created = await createSession(home); + try { + const body = ' agent_debug_emit("H", "loc:7", "broken", "{\\"broken\\":");'; + const result = await compileAndRun( + home, + workspace, + "c-malformed.c", + body, + created.data.appendPath, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + // The daemon needs a moment to observe the incoming line and classify it. + let diagnostics: unknown[] = []; + for (let attempt = 0; attempt < 80 && diagnostics.length === 0; attempt += 1) { + const status = await runCli(home, [ + "status", + "--session", + created.data.sessionId, + "--json", + ]); + if (status.exitCode === 0) { + const parsed = JSON.parse(status.stdout) as { data: { diagnostics?: unknown[] } }; + diagnostics = parsed.data.diagnostics ?? []; + } + if (diagnostics.length === 0) { + await Bun.sleep(50); + } + } + expect(diagnostics.length).toBeGreaterThan(0); + const logs = await runCli(home, ["logs", "--session", created.data.sessionId, "--json"]); + expect(logs.exitCode, logs.stderr).toBe(0); + // The malformed record is counted but not accepted: no valid record is + // ingested, and the daemon flags exactly one malformed record. + expect(JSON.parse(logs.stdout)).toMatchObject({ + statistics: { malformedRecords: 1, validRecords: 0 }, + }); + } finally { + await runCli(home, ["stop", "--json"]); + } + }, + 180_000, + ); + + cRuntimeTest( + "drops an oversize event without affecting the application", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-oversize-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "oversize.ndjson"); + const body = [ + " static char big[90002];", + " size_t bi = 0;", + " big[bi] = '[';", + " bi += 1;", + " for (int i = 0; i < 40000; i += 1) {", + " big[bi] = '0';", + " bi += 1;", + " big[bi] = ',';", + " bi += 1;", + " }", + " big[bi] = '0';", + " bi += 1;", + " big[bi] = ']';", + " bi += 1;", + " big[bi] = 0;", + ' agent_debug_emit("H", "loc", "oversize", big);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "c-oversize.c", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const raw = await readFile(appendPath, "utf8").catch(() => ""); + expect(raw).toBe(""); + }, + 180_000, + ); + + cRuntimeTest( + "concurrent emitters append complete, independently parseable records", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const compiler = Bun.which("clang") ?? Bun.which("gcc"); + expect(compiler, "a C compiler must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-concurrent-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "concurrent.ndjson"); + const rendered = await render(home, cFixture); + const helper = rendered.data.helperTemplate.replaceAll( + "__APPEND_PATH__", + appendPath.replaceAll("\\", "\\\\"), + ); + const source = `${helper}\n\nint main(void) {\n agent_debug_emit("H", "loc", "concurrent", "{\\"n\\":1}");\n return 0;\n}\n`; + const fixturePath = join(workspace, "c-concurrent.c"); + const binary = join( + workspace, + process.platform === "win32" ? "c-concurrent.exe" : "c-concurrent.out", + ); + await writeFile(fixturePath, source); + const compiled = await run([compiler ?? "", "-std=c99", fixturePath, "-o", binary]); + expect(compiled.exitCode, compiled.stderr).toBe(0); + const executions = await Promise.all(Array.from({ length: 32 }, () => run([binary]))); + for (const execution of executions) { + expect(execution.exitCode, execution.stderr).toBe(0); + } + const contents = await readFile(appendPath, "utf8"); + expect(contents.endsWith("\n")).toBe(true); + const lines = contents.split("\n").filter(Boolean); + expect(lines).toHaveLength(32); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }, + 180_000, + ); + + cRuntimeTest( + "keeps metadata containing quotes and control characters valid", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-metadata-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "metadata.ndjson"); + const body = [ + " char hid[4];", + " hid[0] = 'H';", + " hid[1] = (char)34;", + " hid[2] = (char)10;", + " hid[3] = 0;", + " char msg[6];", + " msg[0] = (char)9;", + " msg[1] = 'm';", + " msg[2] = 's';", + " msg[3] = 'g';", + " msg[4] = (char)92;", + " msg[5] = 0;", + ' agent_debug_emit(hid, "loc", msg, "{\\"ok\\":true}");', + ].join("\n"); + const result = await compileAndRun(home, workspace, "c-metadata.c", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + const event = JSON.parse(line ?? "") as { + hypothesisId: string; + message: string; + data: unknown; + }; + expect(event.hypothesisId).toBe(`H"\n`); + expect(event.message).toBe(`\tmsg\\`); + expect(event.data).toEqual({ ok: true }); + }, + 180_000, + ); + + cRuntimeTest( + "compiles when the helper is inserted into a realistic C file", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-realistic-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "realistic.ndjson"); + const rendered = await render(home, cFixture); + const source = await readFile( + join(root, "tests", "fixtures", "languages", "c-realistic.c"), + "utf8", + ); + const materialized = materialize( + source, + rendered.data, + cFixture, + appendPath.replaceAll("\\", "\\\\"), + 1, + "agent_debug_json_string(value.label)", + ); + const fixturePath = join(workspace, "c-realistic.c"); + await writeFile(fixturePath, materialized); + const result = await runSteps(cFixture.command(fixturePath)); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const [line] = (await readFile(appendPath, "utf8")).trim().split("\n"); + expect((JSON.parse(line ?? "") as { data: unknown }).data).toBe("inner-module"); + }, + 180_000, + ); +}); + // Fault-injection coverage for the capture proxy's forward retry. This is the // load-bearing proof that the retry recovers the exact failure CI hit — a // transient forward failure that the previous bare `catch {}` swallowed, so the diff --git a/tests/fixtures/languages/c-realistic.c b/tests/fixtures/languages/c-realistic.c new file mode 100644 index 0000000..9d40c96 --- /dev/null +++ b/tests/fixtures/languages/c-realistic.c @@ -0,0 +1,33 @@ +#include +#include + +/* __HELPER_TEMPLATE__ */ + +/* Application declarations deliberately named AgentValue and adbg_str, matching + the generic global names the migration removed. The lightweight helper keeps + every symbol behind an agent_debug_ prefix, so these application symbols are + untouched and the file compiles — proving the inserted helper introduces no + conflicting names and that its includes and declarations sit legally before + existing application code. */ +struct AgentValue { + const char *label; + long long counts[2]; +}; + +static const char *adbg_str(const char *text) { + return text; +} + +static long long agent_value_total(const struct AgentValue *value) { + return value->counts[0] + value->counts[1]; +} + +int main(void) { + struct AgentValue value; + value.label = adbg_str("inner-module"); + value.counts[0] = 3; + value.counts[1] = 1; + /* __CALL_TEMPLATE__ */ + printf("application-completed: %s %lld\n", value.label, agent_value_total(&value)); + return 0; +} From b223d6f40f211448e60ac60011af3f1e293223a9 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 17:01:55 -0700 Subject: [PATCH 13/18] docs: describe the serialized-JSON probe contract Document the migrated native probe contract across the agent-facing docs: the native-json-value vs serialized-json data encodings and their __DATA_EXPRESSION__ / __DATA_JSON_EXPRESSION__ placeholders, the json_string escaped-text fallback, the caller's no-secrets responsibility (daemon redaction is defense-in-depth; Rust/C++/C do no client-side redaction), the file-start vs top-level helper placement and statement call placement, per-language region markers, and application/x-ndjson as the actual HTTP content type. Java, Kotlin, and the other languages with a safe standard serializer keep native structured values. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 47 ++++++++++++++++++-- skills/agentic-debug-mode/EXAMPLES.md | 60 ++++++++++++++++++++++++++ skills/agentic-debug-mode/REFERENCE.md | 19 ++++++++ skills/agentic-debug-mode/SKILL.md | 31 ++++++++----- specs/building-a-debug-mode-agent.md | 30 ++++++++++--- 5 files changed, 168 insertions(+), 19 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f62dbe3..d1030e0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -124,6 +124,45 @@ The first supported combinations are: Every advertised combination must pass a live end-to-end test with its real runtime. Shell is not advertised until a safe serializer contract is defined. +#### Data encoding + +Each template declares how its call site supplies the `data` field: + +- `native-json-value`: the call site passes a native language value, and the helper serializes and + redacts it client-side. JavaScript, TypeScript, and the languages with a safe standard JSON + facility (Python, Go, Ruby, PHP, PowerShell, C#, Swift, Java, Kotlin) keep this encoding. Their + data placeholder is `__DATA_EXPRESSION__`. +- `serialized-json`: the call site passes an expression that already evaluates to one complete + serialized JSON value, inserted verbatim after `"data":`. Rust, C++, and C use this encoding + because their languages have no standard structured JSON value; embedding a recursive serializer + and redactor in every instrumented file was disproportionately large. Their data placeholder is + `__DATA_JSON_EXPRESSION__`. + +Serialized-JSON helpers expose one small fallback, `json_string(text)` (C++/Rust +`agent_debug_mode::json_string`, C `agent_debug_json_string`), that encodes arbitrary text as a +JSON string when the application has no serializer. The agent must never concatenate unescaped +strings into raw JSON by hand. + +#### Placement + +Each template declares machine-readable placement so the helper lands at a legal location: + +- `helper: "file-start"` — C and C++ (their includes and `#define`s must precede declarations). +- `helper: "top-level"` — Rust and every other language (module or file scope). +- `call: "statement"` — every language inserts each call in statement position. + +The agent inserts the helper once at the required location and wraps every call and the helper in +its own foldable `// #region agent log` / `// #endregion` region (or the language's line-comment +equivalent) so instrumentation is easy to find and remove. + +#### Secret responsibility + +The call site is responsible for excluding secrets: the agent chooses the smallest diagnostic value +that tests a hypothesis and never places credentials, tokens, or unnecessary personally +identifiable information in `data`. Helpers do not scan or redact before transport. The daemon still +validates and redacts accepted JSON before canonical persistence, so daemon redaction is +defense-in-depth, not permission to send secrets. + ### `debug-mode reset --session ` Clears events, diagnostics, ingestion spool state, and sequence state while preserving the @@ -282,11 +321,13 @@ is stored once in session metadata. ```text POST /ingest/ -Content-Type: application/json +Content-Type: application/x-ndjson ``` -There is no separate token, capability terminology, session header, or session field in the body. -The loopback route determines the session. +The HTTP helpers post newline-delimited JSON, so the actual request content type is +`application/x-ndjson`; the daemon parses the body as one-or-more NDJSON records. There is no +separate token, capability terminology, session header, or session field in the body. The loopback +route determines the session. ### File diff --git a/skills/agentic-debug-mode/EXAMPLES.md b/skills/agentic-debug-mode/EXAMPLES.md index bdd5951..5f2019c 100644 --- a/skills/agentic-debug-mode/EXAMPLES.md +++ b/skills/agentic-debug-mode/EXAMPLES.md @@ -115,6 +115,66 @@ debug-mode status --session After the fix, `reset`, reproduce, and re-run the grouping query. When the skipped cluster is gone and expected tasks appear, remove the regions and `debug-mode stop`. +## Example 3 — Rust worker computes a wrong queue depth (serialized JSON, file) + +**Symptom.** A background worker occasionally pops from an empty queue. Hypothesis: `H1` the depth +snapshot is stale by the time the pop runs. + +```bash +debug-mode create +debug-mode template --language rust --ingest file +``` + +Rust, C++, and C are `serialized-json` templates: the call placeholder is `__DATA_JSON_EXPRESSION__`, +and you pass an expression that already evaluates to one complete JSON value. Insert the helper once +at module scope (its declared placement is `top-level`), then a call region per hypothesis. Prefer +the application's serializer — here `serde_json` — and keep the emit inside the region: + +```rust +// #region agent log +if let Ok(__agent_debug_data) = serde_json::to_string(&serde_json::json!({ + "queueDepth": queue.len(), + "ready": ready, +})) { + agent_debug_mode::emit( + "H1", + &format!("{}:{}", file!(), line!()), + "Depth snapshot before pop", + &__agent_debug_data, + ); +} +// #endregion +``` + +When no serializer is available, fall back to the helper's `json_string` on a concise text summary +rather than hand-building JSON (this is also how C uses `agent_debug_json_string`, and C++ +`agent_debug_mode::json_string`): + +```rust +// #region agent log +agent_debug_mode::emit( + "H1", + &format!("{}:{}", file!(), line!()), + "Depth snapshot before pop", + &agent_debug_mode::json_string(&format!("queueDepth={} ready={}", queue.len(), ready)), +); +// #endregion +``` + +The `data` field is then a JSON string: you can still filter by hypothesis, location, message, and +timestamp, but not by fields inside the text. Because these helpers do no client-side redaction, +choose the smallest diagnostic value and never place secrets in `data`. + +```bash +debug-mode reset --session +cargo run --bin worker +debug-mode query --session 'select(.hypothesisId == "H1" and .data.queueDepth == 0)' +``` + +Rows where `queueDepth == 0` immediately before a pop confirm the stale snapshot. `H1` **CONFIRMED**. +Fix, record the baseline, `reset`, reproduce, and confirm the rows are gone before removing the +regions and `debug-mode stop`. + ## Recovering a lost session If you no longer have the Session ID: diff --git a/skills/agentic-debug-mode/REFERENCE.md b/skills/agentic-debug-mode/REFERENCE.md index 18f8540..006fad7 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -40,6 +40,25 @@ schema** for one language and transport. Templates never take `--session`. HTTP templates use an ingest-URL placeholder; file templates use an append-path placeholder. Other languages are unsupported until a safe serializer contract is defined. +The template output includes machine-readable **data encoding** and **placement** metadata: + +- **Data encoding.** `native-json-value` templates take a native value in `__DATA_EXPRESSION__` and + serialize plus redact it client-side (JavaScript, TypeScript, Python, Go, Ruby, PHP, PowerShell, + C#, Swift, Java, Kotlin). `serialized-json` templates (Rust, C++, C) take `__DATA_JSON_EXPRESSION__`, + an expression already evaluating to one complete serialized JSON value inserted verbatim after + `"data":`. These languages have no standard structured JSON value, so the recursive serializer and + redactor were removed from the instrumented file; use the application's serializer, or the helper's + `json_string(text)` fallback (`agent_debug_mode::json_string` for C++/Rust, `agent_debug_json_string` + for C) to encode plain text. Never concatenate unescaped strings into raw JSON. +- **Placement.** `helper: "file-start"` for C and C++ (includes and `#define`s precede + declarations); `helper: "top-level"` for Rust and every other language; `call: "statement"` for + all. Each call and the helper belong in their own `agent log` region. + +Regardless of encoding, the call site is responsible for excluding secrets and choosing the smallest +diagnostic value. Serialized-JSON helpers perform no client-side redaction; the background service +still validates and redacts accepted JSON before canonical persistence (defense-in-depth). HTTP +helpers post newline-delimited JSON with content type `application/x-ndjson`. + ### `debug-mode reset --session ` Clears events, diagnostics, and sequence state for the session while preserving the session ID, diff --git a/skills/agentic-debug-mode/SKILL.md b/skills/agentic-debug-mode/SKILL.md index 615eac8..fdca170 100644 --- a/skills/agentic-debug-mode/SKILL.md +++ b/skills/agentic-debug-mode/SKILL.md @@ -37,9 +37,14 @@ Terms used throughout, defined once: track these; the CLI never registers, declares, or validates them. It only groups and filters the labels it sees in evidence. - **Helper template** — a block of code, inserted **once** per runtime, that owns transport, - serialization, size limits, secret redaction, and failure suppression. Treat it as opaque. -- **Call template** — a small block copied **once per observation**. You replace only its - placeholders. + envelope construction, size limits, and failure suppression. Treat it as opaque. Its declared + **placement** says where it must go: at file start for C and C++ (their includes must precede + declarations), at module or file scope for every other language. +- **Call template** — a small block copied **once per observation**, always in statement position. + You replace only its placeholders. Most languages take a native value in `__DATA_EXPRESSION__` + and serialize it for you; Rust, C++, and C instead take `__DATA_JSON_EXPRESSION__`, an expression + that already evaluates to one complete serialized JSON value (use the application's serializer, or + the helper's `json_string(text)` fallback for plain text — never hand-concatenate raw JSON). - **Observation** — one `agent log` region emitting one event that tests one hypothesis. - **Ingest URL** — the loopback address an HTTP helper posts to. The session is in the URL path; it is not a secret and needs no header. @@ -140,9 +145,10 @@ __agentDebugEmit({ // #endregion ``` -Python and other file runtimes use `# region agent log` / `# endregion`. Never put production -behavior inside a region, never `await` an observation, and never instrument a generated file whose -syntax cannot hold the markers. +Python and Ruby use `# region agent log` / `# endregion`; Rust, C, C++, Go, Java, Kotlin, C#, and +Swift use the `// #region agent log` / `// #endregion` line-comment form. Keep whichever markers the +template prints. Never put production behavior inside a region, never `await` an observation, and +never instrument a generated file whose syntax cannot hold the markers. ### Event schema @@ -157,11 +163,14 @@ Every observation carries exactly five fields: Stored evidence adds `id`, `sequence` (order within the reset cycle), and `receivedAt` (receipt time, also Unix epoch milliseconds). When timestamps tie, `sequence` breaks the tie. -Keep `message` constant and put changing values in `data`. Keep `data` small and bounded. Never -record passwords, cookies, authorization headers, private keys, full request bodies, or unrelated -personal data — the helper redacts obvious secrets, but you choose what to observe. A failed -observation must never change application control flow; the helper isolates transport failures, so -missing events mean an unexecuted path or a failed run, never a crash. +Keep `message` constant and put changing values in `data`. Keep `data` small and bounded — choose +the smallest diagnostic value that tests the hypothesis. Excluding secrets is your responsibility: +never record passwords, cookies, authorization headers, private keys, full request bodies, or +unrelated personal data. Redaction before stored evidence is a safety net, not permission to send +secrets — and the Rust, C++, and C helpers do no client-side redaction at all, so whatever the call +site passes is what leaves the process. A failed observation must never change application control +flow; the helper isolates transport failures, so missing events mean an unexecuted path or a failed +run, never a crash. ### 3. Reset and reproduce diff --git a/specs/building-a-debug-mode-agent.md b/specs/building-a-debug-mode-agent.md index ab7dcde..5c01b59 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -154,6 +154,21 @@ advertised, end-to-end-tested combinations are: Other languages are not advertised until a safe serializer contract is defined. +The template also reports **data encoding** and **placement** metadata: + +- **Data encoding.** `native-json-value` templates take a native value in `__DATA_EXPRESSION__` and + serialize plus redact it client-side (JavaScript, TypeScript, Python, Go, Ruby, PHP, PowerShell, + C#, Swift, Java, Kotlin). `serialized-json` templates (Rust, C++, C) take `__DATA_JSON_EXPRESSION__`, + an expression already evaluating to one complete serialized JSON value inserted verbatim after + `"data":`; these languages have no standard structured JSON value, so the recursive serializer and + redactor no longer live in the instrumented file. Use the application's serializer or the helper's + `json_string(text)` fallback (`agent_debug_mode::json_string` for C++/Rust, `agent_debug_json_string` + for C), and never hand-concatenate raw JSON. +- **Placement.** `helper: "file-start"` for C and C++ (includes and `#define`s precede + declarations), `helper: "top-level"` for Rust and the rest, and `call: "statement"` for every + language. Consumers should read placeholder names from the template's placeholder map rather than + assuming a fixed name, because the serialized-JSON rename is deliberately breaking. + #### `debug-mode reset --session ` Clears events, diagnostics, and sequence state while preserving the session ID, append path, and @@ -239,10 +254,14 @@ tie-breaker. The schema version is stored once in session metadata. ### Data rules -- Use a native JSON serializer; never build JSON by string interpolation. -- Keep `message` constant; put changing values in `data`. -- Never record passwords, tokens, cookies, authorization headers, private keys, full request - bodies, or unrelated personal data. Secrets are redacted before canonical persistence. +- Use a native serializer, or the serialized-JSON helper's `json_string(text)` fallback for plain + text; never build JSON by string interpolation. +- Keep `message` constant; put changing values in `data`, and choose the smallest diagnostic value + that tests the hypothesis. +- Excluding secrets is the call site's responsibility: never record passwords, tokens, cookies, + authorization headers, private keys, full request bodies, or unrelated personal data. Redaction + before canonical persistence is defense-in-depth, not permission to send secrets, and the Rust, + C++, and C helpers do no client-side redaction at all. - Bound strings, arrays, and object depth. A failed observation must never change control flow. ## 4. Ingestion @@ -251,9 +270,10 @@ tie-breaker. The schema version is stored once in session metadata. ```text POST /ingest/ -Content-Type: application/json +Content-Type: application/x-ndjson ``` +The HTTP helpers post newline-delimited JSON, so the actual content type is `application/x-ndjson`. The loopback route determines the session. There is no separate token, header, or session field in the body. From 25670cd6819708bf53eacae20d0e9479480edbcd Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 17:21:43 -0700 Subject: [PATCH 14/18] fix: reject raw control characters in probe data to protect NDJSON framing Co-Authored-By: Claude Opus 4.8 --- src/probes/c.ts | 6 ++ src/probes/cpp.ts | 5 + src/probes/rust.ts | 3 + tests/contract/template-renderers.test.ts | 6 ++ tests/e2e/languages/live-probes.test.ts | 111 ++++++++++++++++++++++ 5 files changed, 131 insertions(+) diff --git a/src/probes/c.ts b/src/probes/c.ts index 5cd0d34..1ad8e29 100644 --- a/src/probes/c.ts +++ b/src/probes/c.ts @@ -76,6 +76,7 @@ export function renderCTemplate(): ProbeTemplates { " agent_debug_append_char(buf, quote);", "}", "", + "// Returns a pointer to a shared static buffer; not thread-safe.", "static const char *agent_debug_json_string(const char *text) {", " static char storage[65536 + 4096];", " AgentDebugBuffer buf;", @@ -105,6 +106,11 @@ export function renderCTemplate(): ProbeTemplates { " if (data_json == NULL || data_json[0] == 0) {", " return;", " }", + " for (const unsigned char *scan = (const unsigned char *)data_json; *scan != 0; scan += 1) {", + " if (*scan < 0x20) {", + " return;", + " }", + " }", " char storage[65536 + 4096];", " AgentDebugBuffer buf;", " buf.buffer = storage;", diff --git a/src/probes/cpp.ts b/src/probes/cpp.ts index 7b09dca..681239a 100644 --- a/src/probes/cpp.ts +++ b/src/probes/cpp.ts @@ -81,6 +81,11 @@ export function renderCppTemplate(): ProbeTemplates { " if (data_json.empty()) {", " return;", " }", + " for (unsigned char c : data_json) {", + " if (c < 0x20) {", + " return;", + " }", + " }", " std::string out;", " out.push_back('{');", ' out += json_string("hypothesisId");', diff --git a/src/probes/rust.ts b/src/probes/rust.ts index 7e4c2e1..03b62d7 100644 --- a/src/probes/rust.ts +++ b/src/probes/rust.ts @@ -67,6 +67,9 @@ export function renderRustTemplate(): ProbeTemplates { " if data_json.is_empty() {", " return;", " }", + " if data_json.bytes().any(|byte| byte < 32) {", + " return;", + " }", " let mut out = String::new();", " out.push('{');", ' out.push_str(&json_string("hypothesisId"));', diff --git a/tests/contract/template-renderers.test.ts b/tests/contract/template-renderers.test.ts index 1927a0c..2cb0a2f 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -84,6 +84,8 @@ describe("session-independent template renderers", () => { expect(template.callTemplate).not.toContain("__DATA_EXPRESSION__"); expect(template.helperTemplate).not.toContain("__DATA_EXPRESSION__"); expect(template.helperTemplate).toContain("mod agent_debug_mode"); + // Rejects raw control bytes in data_json to protect NDJSON framing. + expect(template.helperTemplate).toContain("byte < 32"); expect(template.helperTemplate).toContain("65536"); expect(template.helperTemplate).toContain("// #region agent log"); expect(template.helperTemplate).toContain("// #endregion"); @@ -111,6 +113,8 @@ describe("session-independent template renderers", () => { expect(template.callTemplate).not.toContain("__DATA_EXPRESSION__"); expect(template.helperTemplate).not.toContain("__DATA_EXPRESSION__"); expect(template.helperTemplate).toContain("namespace agent_debug_mode"); + // Rejects raw control bytes in data_json to protect NDJSON framing. + expect(template.helperTemplate).toContain("c < 0x20"); expect(template.helperTemplate).toContain("65536"); expect(template.helperTemplate).toContain("// #region agent log"); expect(template.helperTemplate).toContain("// #endregion"); @@ -141,6 +145,8 @@ describe("session-independent template renderers", () => { expect(template.helperTemplate).not.toContain("__DATA_EXPRESSION__"); expect(template.helperTemplate).toContain("agent_debug_emit"); expect(template.helperTemplate).toContain("agent_debug_json_string"); + // Rejects raw control bytes in data_json to protect NDJSON framing. + expect(template.helperTemplate).toContain("*scan < 0x20"); expect(template.helperTemplate).toContain("65536"); expect(template.helperTemplate).toContain("// #region agent log"); expect(template.helperTemplate).toContain("// #endregion"); diff --git a/tests/e2e/languages/live-probes.test.ts b/tests/e2e/languages/live-probes.test.ts index 1356b49..cd8cf85 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -1327,6 +1327,40 @@ describe("rust serialized-JSON template", () => { }, 180_000, ); + + rustRuntimeTest( + "drops data with a raw control character to protect NDJSON framing", + async () => { + expect(rustFixture.runtime, "rust runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-rust-control-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "control.ndjson"); + // A raw newline inside data_json would break NDJSON framing: the + // direct-append observer splits incoming.ndjson on 0x0A, so a forged second + // line would be ingested as a genuine event. The guard rejects any raw + // control byte (< 0x20), so nothing is ever written. Build the payloads from + // char codes so the Rust source needs no escaping: a raw newline splicing in + // a forged record, then a lone 0x01. + const body = [ + " let mut forged = String::new();", + ' forged.push_str("1");', + " forged.push(char::from(10u8));", + ' forged.push_str("{\\"hypothesisId\\":\\"FORGED\\",\\"location\\":\\"x\\",\\"message\\":\\"x\\",\\"data\\":1,\\"timestamp\\":1}");', + ' agent_debug_mode::emit("H", "loc:1", "newline", &forged);', + " let mut ctl = String::new();", + ' ctl.push_str("1");', + " ctl.push(char::from(1u8));", + ' agent_debug_mode::emit("H", "loc:2", "control", &ctl);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "rust-control.rs", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const raw = await readFile(appendPath, "utf8").catch(() => ""); + expect(raw).toBe(""); + }, + 180_000, + ); }); // Serialized-JSON runtime coverage specific to C++: the caller supplies raw @@ -1595,6 +1629,40 @@ describe("cpp serialized-JSON template", () => { }, 180_000, ); + + cppRuntimeTest( + "drops data with a raw control character to protect NDJSON framing", + async () => { + expect(cppFixture.runtime, "cpp runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-cpp-control-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "control.ndjson"); + // A raw newline inside data_json would break NDJSON framing: the + // direct-append observer splits incoming.ndjson on 0x0A, so a forged second + // line would be ingested as a genuine event. The guard rejects any raw + // control byte (< 0x20), so nothing is ever written. Build the payloads from + // char codes so the C++ source needs no escaping: a raw newline splicing in + // a forged record, then a lone 0x01. + const body = [ + " std::string forged;", + ' forged += "1";', + " forged.push_back(static_cast(10));", + ' forged += "{\\"hypothesisId\\":\\"FORGED\\",\\"location\\":\\"x\\",\\"message\\":\\"x\\",\\"data\\":1,\\"timestamp\\":1}";', + ' agent_debug_mode::emit("H", "loc:1", "newline", forged);', + " std::string ctl;", + ' ctl += "1";', + " ctl.push_back(static_cast(1));", + ' agent_debug_mode::emit("H", "loc:2", "control", ctl);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "cpp-control.cpp", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const raw = await readFile(appendPath, "utf8").catch(() => ""); + expect(raw).toBe(""); + }, + 180_000, + ); }); // Serialized-JSON runtime coverage specific to C: the caller supplies raw JSON, @@ -1878,6 +1946,49 @@ describe("c serialized-JSON template", () => { }, 180_000, ); + + cRuntimeTest( + "drops data with a raw control character to protect NDJSON framing", + async () => { + expect(cFixture.runtime, "c runtime must be installed").not.toBeNull(); + const home = await mkdtemp(join(tmpdir(), "debug-mode-home-")); + const workspace = await mkdtemp(join(tmpdir(), "debug-mode-c-control-")); + temporaryDirectories.push(home, workspace); + const appendPath = join(workspace, "control.ndjson"); + // A raw newline inside data_json would break NDJSON framing: the + // direct-append observer splits incoming.ndjson on 0x0A, so a forged second + // line would be ingested as a genuine event. The guard rejects any raw + // control byte (< 0x20), so nothing is ever written. Build the payloads by + // index so the C source needs no escaping: a raw newline splicing in a + // forged record, then a lone 0x01. + const body = [ + " char forged[80];", + " size_t fi = 0;", + " forged[fi] = '1';", + " fi += 1;", + " forged[fi] = (char)10;", + " fi += 1;", + ' const char *rest = "{\\"hypothesisId\\":\\"FORGED\\",\\"data\\":1}";', + " for (const char *p = rest; *p != 0; p += 1) {", + " forged[fi] = *p;", + " fi += 1;", + " }", + " forged[fi] = 0;", + ' agent_debug_emit("H", "loc:1", "newline", forged);', + " char ctl[4];", + " ctl[0] = '1';", + " ctl[1] = (char)1;", + " ctl[2] = 0;", + ' agent_debug_emit("H", "loc:2", "control", ctl);', + ].join("\n"); + const result = await compileAndRun(home, workspace, "c-control.c", body, appendPath); + expect(result.exitCode, result.stderr).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("application-completed"); + const raw = await readFile(appendPath, "utf8").catch(() => ""); + expect(raw).toBe(""); + }, + 180_000, + ); }); // Fault-injection coverage for the capture proxy's forward retry. This is the From 0f61c4ff24014cce1a5ee94a35c1cd057711c367 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 17:22:56 -0700 Subject: [PATCH 15/18] docs: update changeset for the serialized-JSON template interface Co-Authored-By: Claude Fable 5 --- .changeset/systems-language-templates.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.changeset/systems-language-templates.md b/.changeset/systems-language-templates.md index c299281..eea2d52 100644 --- a/.changeset/systems-language-templates.md +++ b/.changeset/systems-language-templates.md @@ -2,6 +2,10 @@ "agentic-debug-mode": minor --- -Add probe templates for five systems languages: C, C++, Rust, Java, and Kotlin. Each renders a -hypothesis-tagged, at-source-redacting fire-and-forget helper that appends bounded NDJSON evidence, -bringing the advertised helper/runtime pairs from nine to fourteen. +Add probe templates for five more languages — C, C++, Rust, Java, and Kotlin — bringing the +advertised helper/runtime pairs from nine to fourteen (all file ingest). Java and Kotlin build +structured values with at-source redaction; C, C++, and Rust use a lightweight serialized-JSON +interface: the call site passes a complete JSON value (from the application's own serializer, or +the helper's `json_string` text fallback), the daemon performs canonical redaction, and the helper +stays small — envelope, timestamp, size cap, control-character/framing guard, and secure append +only. From 45fd17a92c017e4d69a5a3c11b2d57ef042a0990 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 17:44:00 -0700 Subject: [PATCH 16/18] fix: make C probe timestamps robust to header ordering The injected helper's `#define _POSIX_C_SOURCE` cannot take effect when a real application file includes system headers (e.g. ) before the helper: glibc's locks the feature-test macros on first inclusion, so under strict -std=c99 clock_gettime, CLOCK_REALTIME, and struct timespec stay hidden and the file fails to compile on Linux (macOS libc exposes them regardless). Gate the clock_gettime path on CLOCK_REALTIME's preprocessor visibility (which shares the __USE_POSIX199309 guard with clock_gettime and struct timespec) and fall back to ISO C time(NULL)*1000 when it is unavailable, keeping millisecond precision wherever the POSIX clock API is visible. Co-Authored-By: Claude Fable 5 --- src/probes/c.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/probes/c.ts b/src/probes/c.ts index 1ad8e29..afc5916 100644 --- a/src/probes/c.ts +++ b/src/probes/c.ts @@ -10,7 +10,9 @@ export function renderCTemplate(): ProbeTemplates { dataEncoding: "serialized-json", helperTemplate: [ "// #region agent log", + "#ifndef _POSIX_C_SOURCE", "#define _POSIX_C_SOURCE 200809L", + "#endif", "#include ", "#include ", "#include ", @@ -89,16 +91,20 @@ export function renderCTemplate(): ProbeTemplates { " return storage;", "}", "", + "// Feature-test macros defined above only take effect when this helper", + "// precedes every system header. Real files often include first,", + "// which locks glibc's and hides clock_gettime/CLOCK_REALTIME", + "// under strict -std=c99. Gate on CLOCK_REALTIME's visibility (it shares the", + "// __USE_POSIX199309 guard with clock_gettime and struct timespec) and fall", + "// back to ISO C time() so the code compiles regardless of header ordering.", "static long long agent_debug_timestamp_ms(void) {", - "#ifdef _WIN32", - " return (long long)time(NULL) * 1000;", - "#else", + "#if !defined(_WIN32) && defined(CLOCK_REALTIME)", " struct timespec ts;", - " if (clock_gettime(CLOCK_REALTIME, &ts) != 0) {", - " return 0;", + " if (clock_gettime(CLOCK_REALTIME, &ts) == 0) {", + " return (long long)ts.tv_sec * 1000 + (long long)ts.tv_nsec / 1000000;", " }", - " return (long long)ts.tv_sec * 1000 + (long long)ts.tv_nsec / 1000000;", "#endif", + " return (long long)time(NULL) * 1000;", "}", "", "static void agent_debug_emit(const char *hypothesis_id, const char *location,", From d7641f5a46d2dc4e594ea75a8ffc9f0b2f5aebb3 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 18:29:50 -0700 Subject: [PATCH 17/18] fix: make daemon shutdown wait for process exit on Windows requestDaemonShutdown returned as soon as the daemon stopped answering health checks (its listener socket closed), but on Windows the process briefly outlives that moment while it unwinds and the OS releases the mandatory locks it holds on files under the daemon home. Callers -- the stop command and integration tests -- delete that home directory the instant shutdown returns, racing the dying process and failing with EBUSY/EACCES, or EFAULT under Bun's recursive rm. This surfaced as chronic windows-2025 flakiness where a different daemon test's afterEach rm failed each run. Wait (bounded by the existing 5s shutdown deadline) for the recorded process to actually exit on win32, verified by process identity so a reused pid cannot be mistaken for the daemon. This is a single synchronization point that also makes the stop command synchronous on Windows. POSIX has no mandatory locks and returns as soon as health is down -- behavior unchanged. Also harden retryOnWindowsLock: treat EFAULT as a transient lock code and widen the budget (25 attempts, escalating backoff capped at 200ms) so a just-exited binary's lock-release window is covered, fixing the npm-install EBUSY timeout. Co-Authored-By: Claude Fable 5 --- src/cli/daemon-client.ts | 34 +++++++++++++++++++ src/platform/windows-lock-retry.ts | 18 ++++++---- .../persistence/windows-lock-retry.test.ts | 16 +++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts index fe7b8b1..d551414 100644 --- a/src/cli/daemon-client.ts +++ b/src/cli/daemon-client.ts @@ -1,4 +1,5 @@ import type { DaemonConnection, DaemonMetadata } from "../daemon/protocol"; +import { inspectProcess } from "../native/system"; export class DaemonControlError extends Error { constructor( @@ -73,9 +74,42 @@ export async function requestDaemonShutdown(connection: DaemonConnection): Promi const deadline = Date.now() + 5_000; while (Date.now() < deadline) { if (!(await readDaemonHealth(connection))) { + await waitForRecordedProcessExit(connection, deadline); return; } await Bun.sleep(20); } throw new Error("Daemon did not stop before the shutdown deadline"); } + +// Once the daemon stops answering health checks its listener socket is closed, but +// on Windows the process can briefly outlive that moment while it unwinds and the +// OS releases the mandatory locks it holds on files under the daemon home (spool, +// state, native addon). Callers — the `stop` command and integration tests alike — +// routinely delete that home directory the instant shutdown returns, so on win32 +// wait for the recorded process to actually exit first; otherwise the delete races +// a dying process and surfaces as EBUSY/EACCES/EFAULT. The recorded identity guards +// against a reused pid. POSIX has no such mandatory locks, so it returns as soon as +// the daemon stops answering. If the process somehow lingers past the shutdown +// deadline it has still acknowledged and stopped serving, so return rather than +// fail; the callers' filesystem retries cover the residual window. +async function waitForRecordedProcessExit( + connection: Pick, + deadline: number, +): Promise { + if (process.platform !== "win32") { + return; + } + while (Date.now() < deadline) { + const current = inspectProcess(connection.pid); + if ( + !current.exists || + current.zombie || + current.startTime !== connection.processIdentity.startTime || + current.executable !== connection.processIdentity.executable + ) { + return; + } + await Bun.sleep(20); + } +} diff --git a/src/platform/windows-lock-retry.ts b/src/platform/windows-lock-retry.ts index 0d2551c..0afb810 100644 --- a/src/platform/windows-lock-retry.ts +++ b/src/platform/windows-lock-retry.ts @@ -1,11 +1,15 @@ /** * Windows keeps a mandatory lock on files while a handle is open (a running * executable, a just-closed writer that has not fully released, etc.). Filesystem - * operations against a transiently locked path surface as EPERM/EACCES/EBUSY and - * clear within a few milliseconds once the holder releases. POSIX has no such - * behavior, so these codes are rethrown immediately there. + * operations against a transiently locked path surface as EPERM/EACCES/EBUSY — + * and, for a recursive delete under Bun, occasionally as EFAULT ("bad address in + * system call argument", errno -14) when a child handle is still closing — and + * clear once the holder releases. A just-exited process (a spawned binary or the + * daemon) can hold its locks for a noticeable fraction of a second under CI load, + * so the retry window spans up to a few seconds. POSIX has no such behavior, so + * these codes are rethrown immediately there. */ -const WINDOWS_LOCK_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]); +const WINDOWS_LOCK_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY", "EFAULT"]); function defaultSleep(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -27,7 +31,7 @@ export async function retryOnWindowsLock( operation: () => Promise, { sleep = defaultSleep, - attempts = 10, + attempts = 25, platform = process.platform, }: RetryOnWindowsLockOptions = {}, ): Promise { @@ -44,7 +48,9 @@ export async function retryOnWindowsLock( if (!retryable) { throw error; } - await sleep(5 + Math.floor(Math.random() * 5)); + // Escalate the backoff (capped) so early retries stay fast for the common + // case while the overall budget still spans a slow handle release. + await sleep(Math.min(10 * (attempt + 1), 200) + Math.floor(Math.random() * 10)); } } // Unreachable: the loop returns on success or rethrows on the final attempt. diff --git a/tests/unit/persistence/windows-lock-retry.test.ts b/tests/unit/persistence/windows-lock-retry.test.ts index 8739e49..06dd803 100644 --- a/tests/unit/persistence/windows-lock-retry.test.ts +++ b/tests/unit/persistence/windows-lock-retry.test.ts @@ -59,6 +59,22 @@ describe("retryOnWindowsLock", () => { expect(calls).toBe(1); }); + test("retries EFAULT from a recursive delete on win32", async () => { + let calls = 0; + const result = await retryOnWindowsLock( + async () => { + calls += 1; + if (calls < 3) { + throw errorWithCode("EFAULT"); + } + return "removed"; + }, + { platform: "win32", sleep: async () => undefined }, + ); + expect(result).toBe("removed"); + expect(calls).toBe(3); + }); + test("rethrows non-lock error codes without retrying on win32", async () => { let calls = 0; await expect( From b4397f1db7015278be4520d1262f839730af7387 Mon Sep 17 00:00:00 2001 From: Toubat Date: Mon, 20 Jul 2026 18:41:43 -0700 Subject: [PATCH 18/18] fix: skip Windows shutdown process-wait for in-process daemons The win32 wait added to requestDaemonShutdown polled inspectProcess for the recorded pid to exit. Integration tests that run the daemon in-process via startDaemonServer record the current process's pid, which never exits, so the wait spun to the 5s deadline and timed out the SSE backpressure test. A real daemon is always a separate spawned process, so skip the wait when the recorded pid is our own -- closing the listener is all an in-process server can observe. Co-Authored-By: Claude Fable 5 --- src/cli/daemon-client.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts index d551414..f9630f9 100644 --- a/src/cli/daemon-client.ts +++ b/src/cli/daemon-client.ts @@ -97,7 +97,12 @@ async function waitForRecordedProcessExit( connection: Pick, deadline: number, ): Promise { - if (process.platform !== "win32") { + // A real daemon is always a separate spawned process; only then can waiting for + // it to exit release locks the caller is about to act on. An in-process server + // (integration tests that call startDaemonServer directly) records the current + // pid, which will never exit here — closing its listener is all the caller can + // observe, so return once health is already down rather than spin to the deadline. + if (process.platform !== "win32" || connection.pid === process.pid) { return; } while (Date.now() < deadline) {