diff --git a/.changeset/systems-language-templates.md b/.changeset/systems-language-templates.md new file mode 100644 index 0000000..eea2d52 --- /dev/null +++ b/.changeset/systems-language-templates.md @@ -0,0 +1,11 @@ +--- +"agentic-debug-mode": minor +--- + +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. diff --git a/DESIGN.md b/DESIGN.md index 4f7a0e4..d1030e0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,9 +115,53 @@ The first supported combinations are: - PowerShell + file - C# + file - Swift + file +- Rust + file +- C++ + file +- C + file +- Java + file +- Kotlin + 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. 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 ` @@ -277,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/README.md b/README.md index be06eec..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. @@ -86,6 +86,11 @@ runtime: | PowerShell | file | | C# | file | | Swift | file | +| Rust | file | +| C++ | file | +| C | file | +| Java | file | +| Kotlin | file | ## Development 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( 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 b3da0a4..006fad7 100644 --- a/skills/agentic-debug-mode/REFERENCE.md +++ b/skills/agentic-debug-mode/REFERENCE.md @@ -31,10 +31,34 @@ 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` | +| 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. +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 868768a..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. @@ -114,7 +119,9 @@ 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 | +| C++ | file | C | file | +| Java | file | Kotlin | file | The output has four sections: **HELPER TEMPLATE**, **CALL TEMPLATE**, **PLACEHOLDERS**, and **EVENT SCHEMA**. @@ -138,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 @@ -155,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 496ea74..5c01b59 100644 --- a/specs/building-a-debug-mode-agent.md +++ b/specs/building-a-debug-mode-agent.md @@ -146,9 +146,29 @@ advertised, end-to-end-tested combinations are: - PowerShell + file - C# + file - Swift + file +- Rust + file +- C++ + file +- C + file +- Java + file +- Kotlin + file 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 @@ -234,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 @@ -246,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. @@ -266,7 +291,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, 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/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/daemon-client.ts b/src/cli/daemon-client.ts index fe7b8b1..f9630f9 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,47 @@ 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 { + // 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) { + 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/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/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/src/probes/c.ts b/src/probes/c.ts new file mode 100644 index 0000000..afc5916 --- /dev/null +++ b/src/probes/c.ts @@ -0,0 +1,186 @@ +import type { ProbeTemplates } from "./render"; + +export function renderCTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'agent_debug_emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_JSON_EXPRESSION__);', + "// #endregion", + ].join("\n"), + dataEncoding: "serialized-json", + helperTemplate: [ + "// #region agent log", + "#ifndef _POSIX_C_SOURCE", + "#define _POSIX_C_SOURCE 200809L", + "#endif", + "#include ", + "#include ", + "#include ", + "#ifndef _WIN32", + "#include ", + "#include ", + "#endif", + "", + "typedef struct {", + " char *buffer;", + " size_t length;", + " size_t capacity;", + " int overflow;", + "} AgentDebugBuffer;", + "", + "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;", + " }", + " 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_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 || 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]);", + " } else {", + " agent_debug_append_char(buf, c);", + " }", + " }", + " }", + " 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;", + " 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;", + "}", + "", + "// 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) {", + "#if !defined(_WIN32) && defined(CLOCK_REALTIME)", + " struct timespec ts;", + " if (clock_gettime(CLOCK_REALTIME, &ts) == 0) {", + " 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,", + " const char *message, const char *data_json) {", + " 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;", + " buf.length = 0;", + " buf.capacity = sizeof(storage);", + " buf.overflow = 0;", + " char timestamp[32];", + ' int stamped = snprintf(timestamp, sizeof(timestamp), "%lld", agent_debug_timestamp_ms());', + " if (stamped < 0) {", + " return;", + " }", + " 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;", + " }", + "#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", + "}", + "// #endregion", + ].join("\n"), + ingest: "file", + language: "c", + placement: { call: "statement", helper: "file-start" }, + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __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/src/probes/cpp.ts b/src/probes/cpp.ts new file mode 100644 index 0000000..681239a --- /dev/null +++ b/src/probes/cpp.ts @@ -0,0 +1,149 @@ +import type { ProbeTemplates } from "./render"; + +export function renderCppTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'agent_debug_mode::emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_JSON_EXPRESSION__);', + "// #endregion", + ].join("\n"), + dataEncoding: "serialized-json", + helperTemplate: [ + "// #region agent log", + "#include ", + "#include ", + "#ifdef _WIN32", + "#include ", + "#include ", + "#else", + "#include ", + "#include ", + "#endif", + "", + "#define AGENT_DEBUG_STRINGIFY_IMPL(value) #value", + "#define AGENT_DEBUG_STRINGIFY(value) AGENT_DEBUG_STRINGIFY_IMPL(value)", + "", + "namespace agent_debug_mode {", + "", + "inline std::string json_string(const std::string& text) {", + " const char backslash = static_cast(0x5C);", + " const char quote = static_cast(0x22);", + " std::string out;", + " out.push_back(quote);", + " for (unsigned char c : text) {", + " unsigned int code = static_cast(c);", + " if (c == quote) {", + " out.push_back(backslash);", + " out.push_back(quote);", + " } else if (c == backslash) {", + " out.push_back(backslash);", + " out.push_back(backslash);", + " } else if (code == 8) {", + " out.push_back(backslash);", + " out.push_back('b');", + " } else if (code == 9) {", + " out.push_back(backslash);", + " out.push_back('t');", + " } else if (code == 10) {", + " out.push_back(backslash);", + " out.push_back('n');", + " } else if (code == 12) {", + " out.push_back(backslash);", + " out.push_back('f');", + " } else if (code == 13) {", + " out.push_back(backslash);", + " out.push_back('r');", + " } else if (code < 32) {", + ' const char* digits = "0123456789abcdef";', + " 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.push_back(static_cast(c));", + " }", + " }", + " out.push_back(quote);", + " return out;", + "}", + "", + "inline long long timestamp_ms() {", + " return std::chrono::duration_cast(", + " std::chrono::system_clock::now().time_since_epoch())", + " .count();", + "}", + "", + "inline void emit(const std::string& hypothesis_id, const std::string& location,", + " const std::string& message, const std::string& data_json) {", + " try {", + " 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");', + " 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 << 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", + placement: { call: "statement", helper: "file-start" }, + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __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/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 new file mode 100644 index 0000000..8666a76 --- /dev/null +++ b/src/probes/java.ts @@ -0,0 +1,276 @@ +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", + 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.", + __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/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 new file mode 100644 index 0000000..00a4a03 --- /dev/null +++ b/src/probes/kotlin.ts @@ -0,0 +1,282 @@ +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", + 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.", + __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/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 549cf95..6112f77 100644 --- a/src/probes/render.ts +++ b/src/probes/render.ts @@ -1,10 +1,15 @@ +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 { renderKotlinTemplate } from "./kotlin"; 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,16 +22,28 @@ export type TemplateLanguage = | "php" | "powershell" | "csharp" - | "swift"; + | "swift" + | "rust" + | "cpp" + | "c" + | "java" + | "kotlin"; 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 = { @@ -77,6 +94,20 @@ function normalizeLanguage(language: string): TemplateLanguage | undefined { return "csharp"; case "swift": return "swift"; + case "rust": + case "rs": + return "rust"; + case "cpp": + case "c++": + case "cxx": + return "cpp"; + case "c": + return "c"; + case "java": + return "java"; + case "kotlin": + case "kt": + return "kotlin"; default: return undefined; } @@ -109,6 +140,16 @@ export function renderTemplate(language: string, ingest: string): ProbeTemplates return renderCSharpTemplate(); case "swift:file": return renderSwiftTemplate(); + case "rust:file": + return renderRustTemplate(); + case "cpp:file": + return renderCppTemplate(); + case "c:file": + return renderCTemplate(); + case "java:file": + return renderJavaTemplate(); + case "kotlin:file": + return renderKotlinTemplate(); default: throw new UnsupportedTemplateError(language, ingest); } 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 new file mode 100644 index 0000000..03b62d7 --- /dev/null +++ b/src/probes/rust.ts @@ -0,0 +1,126 @@ +import type { ProbeTemplates } from "./render"; + +export function renderRustTemplate(): ProbeTemplates { + return { + callTemplate: [ + "// #region agent log", + 'agent_debug_mode::emit("__HYPOTHESIS_ID__", "__LOCATION__", "__MESSAGE__", __DATA_JSON_EXPRESSION__);', + "// #endregion", + ].join("\n"), + dataEncoding: "serialized-json", + helperTemplate: [ + "// #region agent log", + "#[allow(dead_code)]", + "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);", + " }", + " }", + " out.push(quote);", + " out", + " }", + "", + " fn timestamp_ms() -> i64 {", + " match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {", + " Ok(elapsed) => elapsed.as_millis() as i64,", + " Err(_) => 0,", + " }", + " }", + "", + " pub fn emit(hypothesis_id: &str, location: &str, message: &str, data_json: &str) {", + " 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"));', + " 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;", + " }", + " 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", + placement: { call: "statement", helper: "top-level" }, + placeholders: { + __APPEND_PATH__: "Replace with the appendPath returned by debug-mode create.", + __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/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 0c7192f..2cb0a2f 100644 --- a/tests/contract/template-renderers.test.ts +++ b/tests/contract/template-renderers.test.ts @@ -19,14 +19,16 @@ const supported = [ ["powershell", "file"], ["csharp", "file"], ["swift", "file"], + ["rust", "file"], + ["cpp", "file"], + ["c", "file"], + ["java", "file"], + ["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", "cpp", "c"]); describe("session-independent template renderers", () => { for (const [language, ingest] of supported) { @@ -36,10 +38,22 @@ describe("session-independent template renderers", () => { const otherTarget = ingest === "http" ? "__APPEND_PATH__" : "__INGEST_URL__"; expect(template).toMatchObject({ ingest, language }); + 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", + ); expect(template.helperTemplate).toContain(target); 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); @@ -53,10 +67,108 @@ 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"); + // 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"); + 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("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"); + // 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"); + 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("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"); + // 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"); + 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; @@ -109,6 +221,17 @@ 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"); + 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"); + 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", () => { @@ -122,7 +245,11 @@ describe("session-independent template renderers", () => { ["powershell", "http"], ["csharp", "http"], ["swift", "http"], - ["rust", "file"], + ["rust", "http"], + ["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 49c3aa4..cd8cf85 100644 --- a/tests/e2e/languages/live-probes.test.ts +++ b/tests/e2e/languages/live-probes.test.ts @@ -61,25 +61,35 @@ interface TemplateOutput extends ProbeTemplates { interface Fixture { callData: string; - command: (fixturePath: string) => string[]; - cycleData: string; - cyclePrelude: 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[][]; + // 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[] = [ { 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;", + dataEncoding: "native-json-value", file: "javascript-http.mjs", ingest: "http", language: "javascript", @@ -90,10 +100,11 @@ 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;", + dataEncoding: "native-json-value", file: "typescript-http.ts", ingest: "http", language: "typescript", @@ -105,9 +116,10 @@ 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', + dataEncoding: "native-json-value", file: "python-file.py", ingest: "file", language: "python", @@ -118,9 +130,10 @@ 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', + dataEncoding: "native-json-value", file: "go-file.go", ingest: "file", language: "go", @@ -131,9 +144,10 @@ 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', + dataEncoding: "native-json-value", file: "ruby-file.rb", ingest: "file", language: "ruby", @@ -144,9 +158,10 @@ 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;', + dataEncoding: "native-json-value", file: "php-file.php", ingest: "file", language: "php", @@ -157,9 +172,10 @@ 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", + dataEncoding: "native-json-value", file: "powershell-file.ps1", ingest: "file", language: "powershell", @@ -170,10 +186,11 @@ 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;', + dataEncoding: "native-json-value", file: "Program.cs", ingest: "file", language: "csharp", @@ -196,9 +213,10 @@ 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', + dataEncoding: "native-json-value", file: "swift-file.swift", ingest: "file", language: "swift", @@ -206,6 +224,105 @@ const fixtures: Fixture[] = [ sharedData: '["left": __agentShared, "right": __agentShared]', 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: + '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]]; + }, + dataEncoding: "serialized-json", + file: "rust-file.rs", + ingest: "file", + language: "rust", + 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: + '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]]; + }, + dataEncoding: "serialized-json", + file: "cpp-file.cpp", + ingest: "file", + language: "cpp", + runtime: Bun.which("clang++") ?? Bun.which("g++"), + }, + { + // 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]]; + }, + dataEncoding: "serialized-json", + file: "c-file.c", + ingest: "file", + language: "c", + runtime: Bun.which("clang") ?? Bun.which("gcc"), + }, + { + 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);', + dataEncoding: "native-json-value", + 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");', + }, + { + 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"); + // 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: + 'val __agentCycle = java.util.LinkedHashMap(); __agentCycle.put("self", __agentCycle)', + dataEncoding: "native-json-value", + 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) { @@ -223,6 +340,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, @@ -335,6 +471,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`, @@ -453,19 +590,28 @@ 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 ? 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); @@ -495,7 +641,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); runtimeTest( @@ -521,118 +667,121 @@ 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"); }, - 90_000, + 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 run(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"]); - } - }, - 90_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 run(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"]); - } - }, - 90_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"]) { @@ -699,7 +848,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); @@ -726,10 +875,89 @@ 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"), + }, + 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: [ + "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", "kotlin"]) { 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`, @@ -737,6 +965,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-`)); @@ -749,41 +980,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, @@ -793,13 +991,13 @@ describe("live language templates", () => { fixture, created.data.appendPath.replaceAll("\\", "\\\\"), 1, - isCSharp ? "__agentValue" : "$__agentValue", - prelude, + valueTypeCase.dataExpression, + valueTypeCase.prelude, ), ); 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"); @@ -817,7 +1015,7 @@ describe("live language templates", () => { await runCli(home, ["stop", "--json"]); } }, - 90_000, + 180_000, ); } @@ -863,9 +1061,934 @@ describe("live language templates", () => { expect(() => JSON.parse(line)).not.toThrow(); } }, - 90_000, + 180_000, + ); + } +}); + +// 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, + ); + + 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 +// 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, + ); + + 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, +// 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, + ); + + 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 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; +} 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; +} diff --git a/tests/fixtures/languages/cpp-file.cpp b/tests/fixtures/languages/cpp-file.cpp new file mode 100644 index 0000000..a97fa5e --- /dev/null +++ b/tests/fixtures/languages/cpp-file.cpp @@ -0,0 +1,9 @@ +#include + +__HELPER_TEMPLATE__ + +int main() { + __CALL_TEMPLATE__ + std::cout << "application-completed" << std::endl; + return 0; +} 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; +} 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__ 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__ 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"); +} 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()); +} 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(