From 8a2cc4d15bde836651db8511407862ba04030b1c Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 29 May 2026 18:35:16 +0000 Subject: [PATCH 1/7] Redesign client as a structured-chat-document renderer; verify-only attestation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes the native client from a raw byte pump into a renderer for a structured chat document (markdown blocks + menus), with the raw terminal as an escape hatch. Chat-style coding TUIs (Claude Code, Codex, …) map naturally onto markdown + menus; the client derives that model and renders it, flippable between read-only Watch, live Interact, and full Raw passthrough. New crate dd-client-session (the engine, shared by every frontend): - block model + streaming BlockEvent log with delta replay - universal "floor" deriver: incremental ANSI strip → Markdown/Code/Diff blocks - vt100 screen + menu detection (reverse-video / numbered / yes-no heuristics) - ViewMode FSM (Watch=Clean / Interact,Raw=Controlled) + Ctrl-] chord parser + keystroke synthesis - ClaudeCodeAdapter: lossless blocks from --output-format stream-json - transport pump split out of core (NoiseConnection::split) CLI: session_ui flips Watch ⇄ Interact ⇄ Raw over one live attachment (ratatui structured view + real-tty Raw), --adapter floor|claude. Attestation: the client now VERIFIES the agent's ITA token (served as noise.ita_token) against Intel's public JWKS — no API key, no Intel account — instead of minting its own. Pairs with the dd agent change. Falls back to a clear error or --insecure-skip-quote-verify when the token is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 431 ++++++++++++++- Cargo.toml | 1 + crates/dd-client-cli/Cargo.toml | 5 +- crates/dd-client-cli/src/main.rs | 33 +- crates/dd-client-cli/src/session_ui.rs | 503 ++++++++++++++++++ crates/dd-client-core/Cargo.toml | 3 +- crates/dd-client-core/src/ita.rs | 34 -- crates/dd-client-core/src/lib.rs | 232 +++----- crates/dd-client-session/Cargo.toml | 16 + crates/dd-client-session/src/block.rs | 165 ++++++ .../src/derive/claude_code.rs | 291 ++++++++++ crates/dd-client-session/src/derive/floor.rs | 379 +++++++++++++ crates/dd-client-session/src/derive/menu.rs | 216 ++++++++ crates/dd-client-session/src/derive/mod.rs | 46 ++ crates/dd-client-session/src/derive/screen.rs | 135 +++++ crates/dd-client-session/src/engine.rs | 186 +++++++ crates/dd-client-session/src/input.rs | 196 +++++++ crates/dd-client-session/src/lib.rs | 22 + crates/dd-client-session/src/mode.rs | 97 ++++ crates/dd-client-session/src/stream.rs | 228 ++++++++ crates/dd-client-session/src/transport.rs | 61 +++ 21 files changed, 3081 insertions(+), 199 deletions(-) create mode 100644 crates/dd-client-cli/src/session_ui.rs create mode 100644 crates/dd-client-session/Cargo.toml create mode 100644 crates/dd-client-session/src/block.rs create mode 100644 crates/dd-client-session/src/derive/claude_code.rs create mode 100644 crates/dd-client-session/src/derive/floor.rs create mode 100644 crates/dd-client-session/src/derive/menu.rs create mode 100644 crates/dd-client-session/src/derive/mod.rs create mode 100644 crates/dd-client-session/src/derive/screen.rs create mode 100644 crates/dd-client-session/src/engine.rs create mode 100644 crates/dd-client-session/src/input.rs create mode 100644 crates/dd-client-session/src/lib.rs create mode 100644 crates/dd-client-session/src/mode.rs create mode 100644 crates/dd-client-session/src/stream.rs create mode 100644 crates/dd-client-session/src/transport.rs diff --git a/Cargo.lock b/Cargo.lock index ae2018c..3dcadfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -102,6 +108,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -156,6 +168,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -273,6 +300,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -288,6 +329,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -334,6 +400,40 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -347,6 +447,9 @@ dependencies = [ "anyhow", "clap", "dd-client-core", + "dd-client-session", + "libc", + "ratatui", "serde_json", "tokio", ] @@ -361,7 +464,6 @@ dependencies = [ "futures-util", "hex", "jsonwebtoken", - "libc", "rand 0.8.6", "reqwest", "serde", @@ -383,6 +485,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "dd-client-session" +version = "0.1.0" +dependencies = [ + "anyhow", + "dd-client-core", + "serde_json", + "tokio", + "vt100", +] + [[package]] name = "deranged" version = "0.5.8" @@ -414,6 +527,18 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -442,6 +567,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -550,6 +681,17 @@ dependencies = [ "polyval", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -766,6 +908,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -787,6 +935,15 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -796,6 +953,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -808,6 +978,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -847,6 +1026,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -859,12 +1044,30 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -884,6 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -940,6 +1144,35 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -1147,6 +1380,36 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1214,6 +1477,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1223,7 +1499,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1274,6 +1550,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1363,6 +1645,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1419,12 +1732,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1471,7 +1812,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1704,6 +2045,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "universal-hash" version = "0.5.1" @@ -1762,6 +2132,39 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "want" version = "0.3.1" @@ -1879,6 +2282,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 0ebf93c..45550fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/dd-client-core", + "crates/dd-client-session", "crates/dd-client-cli", "crates/dd-client-ffi", ] diff --git a/crates/dd-client-cli/Cargo.toml b/crates/dd-client-cli/Cargo.toml index 3f5377d..e92e6ba 100644 --- a/crates/dd-client-cli/Cargo.toml +++ b/crates/dd-client-cli/Cargo.toml @@ -13,6 +13,9 @@ path = "src/main.rs" anyhow = "1" clap = { version = "4", features = ["derive", "env"] } dd-client-core = { path = "../dd-client-core" } +dd-client-session = { path = "../dd-client-session" } +libc = "0.2" +ratatui = "0.29" serde_json = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] } diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 6778815..ad1181d 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -1,14 +1,16 @@ use std::path::PathBuf; -use anyhow::{anyhow, Context}; +use anyhow::anyhow; use clap::{Args, Parser, Subcommand}; use dd_client_core::{ - attach_session, close_session, connect, create_session, enrollment_url, exec, list_recipes, - list_sessions, public_key_hex, replay_session, resize_session, session_id, ConnectionOptions, + close_session, connect, create_session, enrollment_url, exec, list_recipes, list_sessions, + public_key_hex, replay_session, resize_session, session_id, ConnectionOptions, CreateSessionRequest, ExecRequest, IntelTrustAuthority, QuoteVerification, }; +use dd_client_session::ViewMode; + +mod session_ui; -const DEFAULT_ITA_BASE_URL: &str = "https://api.trustauthority.intel.com"; const DEFAULT_ITA_JWKS_URL: &str = "https://portal.trustauthority.intel.com/certs"; const DEFAULT_ITA_ISSUER: &str = "https://portal.trustauthority.intel.com"; @@ -31,6 +33,7 @@ enum Command { Resize(ResizeArgs), Close(SessionArgs), Attach(SessionArgs), + Watch(SessionArgs), Shell(CreateArgs), Exec(ExecArgs), } @@ -59,14 +62,13 @@ struct ConnectArgs { key: PathBuf, #[arg(long)] insecure_skip_quote_verify: bool, - #[arg(long, env = "DD_ITA_API_KEY")] - ita_api_key: Option, - #[arg(long, env = "DD_ITA_BASE_URL", default_value = DEFAULT_ITA_BASE_URL)] - ita_base_url: String, #[arg(long, env = "DD_ITA_JWKS_URL", default_value = DEFAULT_ITA_JWKS_URL)] ita_jwks_url: String, #[arg(long, env = "DD_ITA_ISSUER", default_value = DEFAULT_ITA_ISSUER)] ita_issuer: String, + /// Structured deriver: "floor" (any TUI) or "claude" (Claude Code stream-json). + #[arg(long, default_value = "floor")] + adapter: String, } #[derive(Args)] @@ -154,14 +156,21 @@ async fn main() -> anyhow::Result<()> { print_json(close_session(&mut conn, &args.id).await?)?; } Command::Attach(args) => { + let adapter = args.connect.adapter.clone(); + let conn = connect(&connection_options(args.connect)?).await?; + session_ui::run(conn, &args.id, ViewMode::Watch, &adapter).await?; + } + Command::Watch(args) => { + let adapter = args.connect.adapter.clone(); let conn = connect(&connection_options(args.connect)?).await?; - attach_session(conn, &args.id).await?; + session_ui::run(conn, &args.id, ViewMode::Watch, &adapter).await?; } Command::Shell(args) => { + let adapter = args.connect.adapter.clone(); let mut conn = connect(&connection_options(args.connect.clone())?).await?; let session = create_session(&mut conn, &create_request(&args)).await?; let id = session_id(&session)?; - attach_session(conn, &id).await?; + session_ui::run(conn, &id, ViewMode::Watch, &adapter).await?; } Command::Exec(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; @@ -193,10 +202,6 @@ fn connection_options(args: ConnectArgs) -> anyhow::Result { QuoteVerification::InsecureSkip } else { QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { - api_key: args - .ita_api_key - .context("DD_ITA_API_KEY or --ita-api-key is required")?, - base_url: args.ita_base_url, jwks_url: args.ita_jwks_url, issuer: args.ita_issuer, }) diff --git a/crates/dd-client-cli/src/session_ui.rs b/crates/dd-client-cli/src/session_ui.rs new file mode 100644 index 0000000..1b90ebf --- /dev/null +++ b/crates/dd-client-cli/src/session_ui.rs @@ -0,0 +1,503 @@ +//! The flippable session UI: Watch ⇄ Interact ⇄ Raw over one live attachment. +//! +//! A single pump task feeds every output frame into the [`SessionEngine`] (and, +//! while in Raw, straight to the tty). The frontend switches *lenses* over that +//! one engine: a ratatui structured view for Watch/Interact, and a real-tty +//! passthrough for Raw. `Tab`/`Shift-Tab` cycle while structured; `Ctrl-]`+`Tab` +//! pops out of Raw. The engine keeps deriving in every mode, so flipping is +//! lossless. +//! +//! NOTE: the multi-regime live flip is wired against the tested engine/chord/mode +//! logic but has not been exercised against a live PTY here. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::anyhow; +use dd_client_core::NoiseConnection; +use dd_client_session::block::Block as SBlock; +use dd_client_session::derive::ClaudeCodeAdapter; +use dd_client_session::input::{ChordParser, RawAction}; +use dd_client_session::transport::{self, Outbound}; +use dd_client_session::{SessionEngine, ViewMode}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Block as TuiBlock, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use tokio::io::AsyncReadExt; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc; + +/// What a regime returns to the orchestrator. +enum Flow { + Quit, + SwitchTo(ViewMode), +} + +pub async fn run( + mut conn: NoiseConnection, + id: &str, + start: ViewMode, + adapter: &str, +) -> anyhow::Result<()> { + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": id, + "tail": true, + "readonly": start.is_readonly(), + })) + .await?; + if ack.get("error").is_some() { + anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); + } + + let engine = match adapter { + "claude" | "claude-code" => SessionEngine::with_adapter(Box::new(ClaudeCodeAdapter::new())), + _ => SessionEngine::new(), + }; + let mode = Arc::new(Mutex::new(start)); + let (in_tx, in_rx) = mpsc::channel::(256); + + // Persistent pump: feed the engine always; mirror to the tty only in Raw. + let pump_engine = engine.clone(); + let pump_mode = mode.clone(); + let pump = tokio::spawn(async move { + let mut out = std::io::stdout(); + let r = transport::run(conn, in_rx, |bytes| { + pump_engine.feed_output(bytes); + if *pump_mode.lock().expect("mode poisoned") == ViewMode::Raw { + let _ = out.write_all(bytes); + let _ = out.flush(); + } + }) + .await; + pump_engine.finish(); + r + }); + + let result = loop { + let current = *mode.lock().expect("mode poisoned"); + let outcome = if current.is_structured() { + run_structured(&engine, &mode, &in_tx).await + } else { + run_raw(&engine, &mode, &in_tx).await + }; + match outcome { + Ok(Flow::Quit) => break Ok(()), + Ok(Flow::SwitchTo(m)) => *mode.lock().expect("mode poisoned") = m, + Err(e) => break Err(e), + } + }; + + drop(in_tx); + let _ = pump.await; + result +} + +// ── Structured regime (Watch / Interact) ─────────────────────────────────── + +async fn run_structured( + engine: &SessionEngine, + mode: &Arc>, + in_tx: &mpsc::Sender, +) -> anyhow::Result { + let (snapshot, mut rx) = engine.subscribe(); + + // Key reader on a blocking thread (poll so it exits promptly on quit). + let stop = Arc::new(AtomicBool::new(false)); + let (key_tx, mut key_rx) = mpsc::channel::(64); + let stop_reader = stop.clone(); + let reader = tokio::task::spawn_blocking(move || loop { + if stop_reader.load(Ordering::Relaxed) { + break; + } + if let Ok(true) = event::poll(Duration::from_millis(100)) { + match event::read() { + Ok(ev) => { + if key_tx.blocking_send(ev).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut terminal = ratatui::init(); + let mut blocks = snapshot; + let mut scroll: u16 = 0; + let mut follow = true; + + let flow = loop { + let current = *mode.lock().expect("mode poisoned"); + let menu = if current == ViewMode::Interact { + engine.detect_menu() + } else { + None + }; + + if let Err(e) = + terminal.draw(|f| draw(f, current, &blocks, menu.as_ref(), &mut scroll, follow)) + { + break Err(e.into()); + } + + tokio::select! { + ev = async { + match rx.recv().await { + Ok(_) | Err(RecvError::Lagged(_)) => Some(()), + Err(RecvError::Closed) => None, + } + } => { + if ev.is_some() { + blocks = engine.snapshot(); + } // Closed: pump ended; keep the view until the user quits. + } + key = key_rx.recv() => { + let Some(Event::Key(k)) = key else { + if key.is_none() { break Ok(Flow::Quit); } + if let Some(Event::Resize(cols, rows)) = key { engine.resize(rows, cols); } + continue; + }; + match classify_structured_key(current, &k) { + StructAction::Quit => break Ok(Flow::Quit), + StructAction::CycleNext | StructAction::CyclePrev => { + let nm = if matches!(classify_structured_key(current, &k), StructAction::CycleNext) { + current.next() + } else { + current.prev() + }; + *mode.lock().expect("mode poisoned") = nm; + if !nm.is_structured() { + break Ok(Flow::SwitchTo(nm)); + } + // Watch ⇄ Interact: stay in this regime. + } + StructAction::ScrollUp => { follow = false; scroll = scroll.saturating_sub(1); } + StructAction::ScrollDown => scroll = scroll.saturating_add(1), + StructAction::PageUp => { follow = false; scroll = scroll.saturating_sub(10); } + StructAction::PageDown => scroll = scroll.saturating_add(10), + StructAction::Follow => follow = true, + StructAction::ForwardKey => { + if let Some(bytes) = key_to_bytes(&k) { + if in_tx.send(Outbound::Bytes(bytes)).await.is_err() { + break Ok(Flow::Quit); + } + } + } + StructAction::Ignore => {} + } + } + } + }; + + ratatui::restore(); + stop.store(true, Ordering::Relaxed); + let _ = reader.await; + flow +} + +enum StructAction { + Quit, + CycleNext, + CyclePrev, + ScrollUp, + ScrollDown, + PageUp, + PageDown, + Follow, + ForwardKey, + Ignore, +} + +fn classify_structured_key(mode: ViewMode, k: &KeyEvent) -> StructAction { + // Tab/Shift-Tab always cycle (reserved from the app in structured modes). + match k.code { + KeyCode::Tab => return StructAction::CycleNext, + KeyCode::BackTab => return StructAction::CyclePrev, + KeyCode::Char('c') if k.modifiers.contains(KeyModifiers::CONTROL) => { + return StructAction::Quit + } + _ => {} + } + + match mode { + ViewMode::Watch => match k.code { + KeyCode::Char('q') | KeyCode::Esc => StructAction::Quit, + KeyCode::Up => StructAction::ScrollUp, + KeyCode::Down => StructAction::ScrollDown, + KeyCode::PageUp => StructAction::PageUp, + KeyCode::PageDown => StructAction::PageDown, + KeyCode::End => StructAction::Follow, + _ => StructAction::Ignore, + }, + // Interact forwards keystrokes to the PTY (drive the live menu), except + // the reserved cycle keys above. PgUp/PgDn still scroll the transcript. + ViewMode::Interact => match k.code { + KeyCode::PageUp => StructAction::PageUp, + KeyCode::PageDown => StructAction::PageDown, + _ => StructAction::ForwardKey, + }, + ViewMode::Raw => StructAction::Ignore, + } +} + +// ── Raw regime ────────────────────────────────────────────────────────────── + +async fn run_raw( + engine: &SessionEngine, + _mode: &Arc>, + in_tx: &mpsc::Sender, +) -> anyhow::Result { + let _raw = RawMode::enter()?; + + // Repaint the current screen so Raw doesn't start blank. + { + let mut out = std::io::stdout(); + let _ = out.write_all(b"\x1b[2J\x1b[H"); + let _ = out.write_all(&engine.screen_formatted()); + let _ = out.flush(); + } + eprint!("\r\n[RAW] Ctrl-] Tab → structured · Ctrl-] Ctrl-] → detach · Ctrl-D → EOF\r\n"); + + let mut chord = ChordParser::new(); + let mut stdin = tokio::io::stdin(); + let mut buf = [0u8; 4096]; + + loop { + let n = match stdin.read(&mut buf).await { + Ok(0) | Err(_) => return Ok(Flow::Quit), + Ok(n) => n, + }; + for action in chord.feed(&buf[..n]) { + match action { + RawAction::Forward(bytes) => { + if in_tx.send(Outbound::Bytes(bytes)).await.is_err() { + return Ok(Flow::Quit); + } + } + RawAction::ForwardThenStop(bytes) => { + let _ = in_tx.send(Outbound::BytesThenStop(bytes)).await; + return Ok(Flow::Quit); + } + RawAction::ExitToStructured => return Ok(Flow::SwitchTo(ViewMode::Watch)), + RawAction::Detach => return Ok(Flow::Quit), + } + } + } +} + +/// Map a crossterm key to the bytes a PTY expects. Returns `None` for keys with +/// no byte representation (the reserved cycle keys never reach here). +fn key_to_bytes(k: &KeyEvent) -> Option> { + let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); + Some(match k.code { + KeyCode::Char(c) if ctrl && c.is_ascii_alphabetic() => { + vec![(c.to_ascii_uppercase() as u8) & 0x1f] + } + KeyCode::Char(c) => c.to_string().into_bytes(), + KeyCode::Enter => vec![b'\r'], + KeyCode::Backspace => vec![0x7f], + KeyCode::Esc => vec![0x1b], + KeyCode::Up => b"\x1b[A".to_vec(), + KeyCode::Down => b"\x1b[B".to_vec(), + KeyCode::Right => b"\x1b[C".to_vec(), + KeyCode::Left => b"\x1b[D".to_vec(), + KeyCode::Home => b"\x1b[H".to_vec(), + KeyCode::End => b"\x1b[F".to_vec(), + KeyCode::Delete => b"\x1b[3~".to_vec(), + _ => return None, + }) +} + +// ── Rendering ───────────────────────────────────────────────────────────── + +fn draw( + f: &mut Frame, + mode: ViewMode, + blocks: &[SBlock], + menu: Option<&dd_client_session::block::Menu>, + scroll: &mut u16, + follow: bool, +) { + let area = f.area(); + let menu_h = menu.map(|m| m.options.len() as u16 + 2).unwrap_or(0); + let rows = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(menu_h), + Constraint::Length(1), + ]) + .split(area); + let body: Rect = rows[0]; + + let text = render_blocks(blocks); + let total = text.lines.len() as u16; + let inner_h = body.height.saturating_sub(2); + let max_scroll = total.saturating_sub(inner_h); + if follow || *scroll > max_scroll { + *scroll = max_scroll; + } + + let integrity = if mode.is_readonly() { + "clean" + } else { + "controlled" + }; + let title = format!(" {} ({integrity}) — {total} lines ", mode.label()); + let para = Paragraph::new(text) + .wrap(Wrap { trim: false }) + .scroll((*scroll, 0)) + .block(TuiBlock::default().borders(Borders::ALL).title(title)); + f.render_widget(para, body); + + if let Some(menu) = menu { + f.render_widget(render_menu(menu), rows[1]); + } + + let hints = match mode { + ViewMode::Watch => "q quit · ↑/↓ scroll · End follow · Tab→Interact", + ViewMode::Interact => "keys→session · PgUp/PgDn scroll · Tab→Raw · Shift-Tab→Watch", + ViewMode::Raw => "", + }; + let status = Paragraph::new(Line::styled( + format!(" {hints} "), + Style::default().fg(Color::DarkGray), + )); + f.render_widget(status, rows[2]); +} + +fn render_menu(menu: &dd_client_session::block::Menu) -> Paragraph<'static> { + let mut lines: Vec = Vec::new(); + for (i, opt) in menu.options.iter().enumerate() { + let marker = if menu.selected == Some(i) { + "❯ " + } else { + " " + }; + let key = opt + .hotkey + .map(|c| format!("{c}. ")) + .unwrap_or_else(|| " ".to_string()); + let style = if menu.selected == Some(i) { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::Cyan) + }; + lines.push(Line::styled(format!("{marker}{key}{}", opt.label), style)); + } + Paragraph::new(Text::from(lines)).block( + TuiBlock::default() + .borders(Borders::ALL) + .title(" menu (arrows/enter) ") + .border_style(Style::default().fg(Color::Yellow)), + ) +} + +fn render_blocks(blocks: &[SBlock]) -> Text<'static> { + let mut lines: Vec = Vec::new(); + for block in blocks { + match block { + SBlock::Markdown { text, .. } => { + for l in text.lines() { + lines.push(render_markdown_line(l)); + } + } + SBlock::Code { lang, text, .. } => { + let header = match lang { + Some(l) => format!("┌─ code ({l})"), + None => "┌─ code".to_string(), + }; + lines.push(Line::styled(header, Style::default().fg(Color::DarkGray))); + for l in text.lines() { + lines.push(Line::styled( + format!("│ {l}"), + Style::default().fg(Color::Cyan), + )); + } + } + SBlock::Diff { unified, .. } => { + for l in unified.lines() { + let style = match l.as_bytes().first() { + Some(b'+') => Style::default().fg(Color::Green), + Some(b'-') => Style::default().fg(Color::Red), + Some(b'@') => Style::default().fg(Color::Magenta), + _ => Style::default().fg(Color::Gray), + }; + lines.push(Line::styled(l.to_string(), style)); + } + } + SBlock::RawTerminal { screen } => { + for l in screen.lines() { + lines.push(Line::raw(l.to_string())); + } + } + SBlock::Menu(_) | SBlock::Input(_) => {} + } + lines.push(Line::raw(String::new())); + } + Text::from(lines) +} + +fn render_markdown_line(l: &str) -> Line<'static> { + let t = l.trim_start(); + if t.starts_with("# ") || t.starts_with("## ") || t.starts_with("### ") { + Line::styled( + l.to_string(), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + } else { + Line::raw(l.to_string()) + } +} + +struct RawMode { + #[cfg(unix)] + original: Option, +} + +impl RawMode { + fn enter() -> anyhow::Result { + #[cfg(unix)] + { + if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 { + return Ok(Self { original: None }); + } + let mut original = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(libc::STDIN_FILENO, original.as_mut_ptr()) } != 0 { + return Err(anyhow!("tcgetattr: {}", std::io::Error::last_os_error())); + } + let original = unsafe { original.assume_init() }; + let mut raw = original; + unsafe { libc::cfmakeraw(&mut raw) }; + if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 { + return Err(anyhow!( + "tcsetattr raw: {}", + std::io::Error::last_os_error() + )); + } + Ok(Self { + original: Some(original), + }) + } + #[cfg(not(unix))] + { + Ok(Self {}) + } + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(original) = &self.original { + let _ = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) }; + } + } +} diff --git a/crates/dd-client-core/Cargo.toml b/crates/dd-client-core/Cargo.toml index d2a7a8d..31455ef 100644 --- a/crates/dd-client-core/Cargo.toml +++ b/crates/dd-client-core/Cargo.toml @@ -12,13 +12,12 @@ chrono = { version = "0.4", features = ["serde"] } futures-util = "0.3" hex = "0.4" jsonwebtoken = "9" -libc = "0.2" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" snow = { version = "0.9", default-features = false, features = ["default-resolver"] } -tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] } +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } urlencoding = "2" x25519-dalek = { version = "2", features = ["static_secrets"] } diff --git a/crates/dd-client-core/src/ita.rs b/crates/dd-client-core/src/ita.rs index ef4bc2d..c456a71 100644 --- a/crates/dd-client-core/src/ita.rs +++ b/crates/dd-client-core/src/ita.rs @@ -55,40 +55,6 @@ impl Claims { } } -#[derive(Serialize)] -struct MintRequest<'a> { - quote: &'a str, -} - -#[derive(Deserialize)] -struct MintResponse { - token: String, -} - -pub async fn mint( - http: &Client, - base_url: &str, - api_key: &str, - quote_b64: &str, -) -> anyhow::Result { - let url = format!("{}/appraisal/v1/attest", base_url.trim_end_matches('/')); - let resp = http - .post(&url) - .header("x-api-key", api_key) - .header("Accept", "application/json") - .json(&MintRequest { quote: quote_b64 }) - .send() - .await - .with_context(|| format!("ITA mint {url}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("ITA mint {status}: {body}"); - } - let body: MintResponse = resp.json().await?; - Ok(body.token) -} - pub struct Verifier { jwks_url: String, issuer: String, diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index b23069d..289ae75 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -1,6 +1,7 @@ mod ita; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Context}; use base64::Engine as _; @@ -9,7 +10,6 @@ use rand::rngs::OsRng; use reqwest::Client as HttpClient; use serde_json::Value; use snow::{Builder, TransportState}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; @@ -17,9 +17,6 @@ use x25519_dalek::{PublicKey, StaticSecret}; const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s"; const MAX_NOISE_MSG: usize = 65535; -const ATTACH_CHUNK: usize = 4096; -const CTRL_D: u8 = 0x04; -const CTRL_RIGHT_BRACKET: u8 = 0x1d; type WsStream = WebSocketStream>; type WsSink = futures_util::stream::SplitSink; @@ -27,8 +24,8 @@ type WsRead = futures_util::stream::SplitStream; #[derive(Debug, Clone)] pub struct IntelTrustAuthority { - pub api_key: String, - pub base_url: String, + /// Intel's public JWKS endpoint. Verification only — no API key, no account: + /// the agent mints the token; the client just checks the signature. pub jwks_url: String, pub issuer: String, } @@ -77,6 +74,77 @@ impl NoiseConnection { out.truncate(n); Ok(serde_json::from_slice(&out)?) } + + /// Split into independently-ownable write/read halves so a caller can run a + /// duplex pump loop (e.g. the session engine forwarding keystrokes while + /// streaming PTY output). The Noise transport (encryption) stays inside core, + /// shared between the halves behind a brief, non-async lock — the lock is + /// never held across an `.await`. + pub fn split(self) -> (NoiseWriter, NoiseReader) { + let transport = Arc::new(Mutex::new(self.transport)); + ( + NoiseWriter { + transport: transport.clone(), + sink: self.sink, + }, + NoiseReader { + transport, + stream: self.stream, + }, + ) + } +} + +/// Write half of a split [`NoiseConnection`]. Encrypts plaintext into Noise +/// transport frames and sends them. +pub struct NoiseWriter { + transport: Arc>, + sink: WsSink, +} + +impl NoiseWriter { + /// Encrypt `plain` and send it as one Noise transport frame. + pub async fn send(&mut self, plain: &[u8]) -> anyhow::Result<()> { + let frame = { + let mut transport = self + .transport + .lock() + .map_err(|_| anyhow!("noise transport lock poisoned"))?; + let mut cipher = vec![0u8; plain.len() + 16]; + let n = transport.write_message(plain, &mut cipher)?; + cipher.truncate(n); + cipher + }; + self.sink.send(WsMessage::Binary(frame.into())).await?; + Ok(()) + } +} + +/// Read half of a split [`NoiseConnection`]. Receives Noise transport frames and +/// decrypts them. +pub struct NoiseReader { + transport: Arc>, + stream: WsRead, +} + +impl NoiseReader { + /// Receive and decrypt the next Noise transport frame. `Ok(None)` once the + /// socket closes. + pub async fn recv(&mut self) -> anyhow::Result>> { + let Some(cipher) = next_binary(&mut self.stream).await? else { + return Ok(None); + }; + let mut plain = vec![0u8; cipher.len()]; + let n = { + let mut transport = self + .transport + .lock() + .map_err(|_| anyhow!("noise transport lock poisoned"))?; + transport.read_message(&cipher, &mut plain)? + }; + plain.truncate(n); + Ok(Some(plain)) + } } pub async fn connect(opts: &ConnectionOptions) -> anyhow::Result { @@ -183,72 +251,6 @@ pub async fn exec(conn: &mut NoiseConnection, request: &ExecRequest) -> anyhow:: .await } -pub async fn attach_session(mut conn: NoiseConnection, id: &str) -> anyhow::Result<()> { - let ack = conn - .call(serde_json::json!({ - "method": "shell.attach_session", - "id": id, - "tail": true, - })) - .await?; - if ack.get("error").is_some() { - anyhow::bail!("attach failed: {}", serde_json::to_string(&ack)?); - } - - eprintln!("attached; Ctrl-] detaches, Ctrl-D sends EOF and disconnects"); - - let _raw = RawMode::enter()?; - let mut stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - let mut in_buf = [0u8; ATTACH_CHUNK]; - - loop { - tokio::select! { - n = stdin.read(&mut in_buf) => { - let n = n?; - if n == 0 { - break; - } - match attach_input_action(&in_buf[..n]) { - AttachInputAction::Forward => { - send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?; - } - AttachInputAction::ForwardThenDisconnect => { - send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?; - break; - } - AttachInputAction::Disconnect => break, - } - } - frame = next_binary(&mut conn.stream) => { - let Some(cipher) = frame? else { - break; - }; - let mut plain = vec![0u8; cipher.len()]; - let n = conn.transport.read_message(&cipher, &mut plain)?; - stdout.write_all(&plain[..n]).await?; - stdout.flush().await?; - } - } - } - Ok(()) -} - -#[derive(Debug, Eq, PartialEq)] -enum AttachInputAction { - Forward, - ForwardThenDisconnect, - Disconnect, -} - -fn attach_input_action(bytes: &[u8]) -> AttachInputAction { - match bytes { - [CTRL_D] => AttachInputAction::ForwardThenDisconnect, - [CTRL_RIGHT_BRACKET] => AttachInputAction::Disconnect, - _ => AttachInputAction::Forward, - } -} - pub fn session_id(value: &Value) -> anyhow::Result { if let Some(error) = value.get("error") { anyhow::bail!("create failed: {error}"); @@ -346,10 +348,10 @@ async fn fetch_and_verify_server_pubkey( .pointer("/noise/pubkey_hex") .and_then(Value::as_str) .ok_or_else(|| anyhow!("{url} did not include noise.pubkey_hex"))?; - let quote_b64 = body - .pointer("/noise/quote_b64") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("{url} did not include noise.quote_b64"))?; + // The agent mints an ITA appraisal of its Noise quote and serves it here; the + // client only verifies it (public JWKS — no account). Optional so the + // InsecureSkip path and older agents still work. + let ita_token = body.pointer("/noise/ita_token").and_then(Value::as_str); let bytes = hex::decode(pubkey_hex).context("decode noise.pubkey_hex")?; if bytes.len() != 32 { anyhow::bail!( @@ -359,13 +361,13 @@ async fn fetch_and_verify_server_pubkey( } let mut out = [0u8; 32]; out.copy_from_slice(&bytes); - verify_quote_binding(http, quote_b64, &out, &opts.quote_verification).await?; + verify_quote_binding(http, ita_token, &out, &opts.quote_verification).await?; Ok(out) } async fn verify_quote_binding( http: &HttpClient, - quote_b64: &str, + ita_token: Option<&str>, pubkey: &[u8; 32], verification: &QuoteVerification, ) -> anyhow::Result<()> { @@ -374,12 +376,15 @@ async fn verify_quote_binding( return Ok(()); }; - let token = ita::mint(http, &config.base_url, &config.api_key, quote_b64) - .await - .map_err(|e| anyhow!("ITA quote appraisal failed: {e}"))?; + let token = ita_token.ok_or_else(|| { + anyhow!( + "agent /health did not include noise.ita_token; update the agent or \ + pass --insecure-skip-quote-verify" + ) + })?; let verifier = ita::Verifier::new(http.clone(), config.jwks_url.clone(), config.issuer.clone()); let claims = verifier - .verify(&token) + .verify(token) .await .map_err(|e| anyhow!("ITA token verification failed: {e}"))?; let report_data = claims @@ -452,48 +457,6 @@ fn normalize_http_base(base_url: &str) -> String { } } -struct RawMode { - #[cfg(unix)] - original: Option, -} - -impl RawMode { - fn enter() -> anyhow::Result { - #[cfg(unix)] - { - if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 { - return Ok(Self { original: None }); - } - let mut original = std::mem::MaybeUninit::::uninit(); - if unsafe { libc::tcgetattr(libc::STDIN_FILENO, original.as_mut_ptr()) } != 0 { - return Err(std::io::Error::last_os_error()).context("tcgetattr"); - } - let original = unsafe { original.assume_init() }; - let mut raw = original; - unsafe { libc::cfmakeraw(&mut raw) }; - if unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) } != 0 { - return Err(std::io::Error::last_os_error()).context("tcsetattr raw"); - } - Ok(Self { - original: Some(original), - }) - } - #[cfg(not(unix))] - { - Ok(Self {}) - } - } -} - -impl Drop for RawMode { - fn drop(&mut self) { - #[cfg(unix)] - if let Some(original) = &self.original { - let _ = unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original) }; - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -554,25 +517,4 @@ mod tests { let encoded = base64::engine::general_purpose::STANDARD.encode(report); verify_report_data(&encoded, &pubkey).unwrap(); } - - #[test] - fn attach_input_detaches_on_ctrl_right_bracket() { - assert_eq!( - attach_input_action(&[CTRL_RIGHT_BRACKET]), - AttachInputAction::Disconnect - ); - } - - #[test] - fn attach_input_sends_eof_then_disconnects_on_ctrl_d() { - assert_eq!( - attach_input_action(&[CTRL_D]), - AttachInputAction::ForwardThenDisconnect - ); - } - - #[test] - fn attach_input_forwards_regular_bytes() { - assert_eq!(attach_input_action(b"exit\n"), AttachInputAction::Forward); - } } diff --git a/crates/dd-client-session/Cargo.toml b/crates/dd-client-session/Cargo.toml new file mode 100644 index 0000000..55d5549 --- /dev/null +++ b/crates/dd-client-session/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dd-client-session" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow = "1" +dd-client-core = { path = "../dd-client-core" } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt", "sync"] } +vt100 = "0.15" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/crates/dd-client-session/src/block.rs b/crates/dd-client-session/src/block.rs new file mode 100644 index 0000000..310785e --- /dev/null +++ b/crates/dd-client-session/src/block.rs @@ -0,0 +1,165 @@ +//! The structured "chat document" model. +//! +//! A session is rendered as an ordered list of typed [`Block`]s. The engine +//! holds the authoritative log (see [`crate::stream`]) and publishes +//! [`BlockEvent`] deltas; frontends either replay deltas onto their own copy or +//! re-read a snapshot. Phase 1 only populates `Markdown`/`Code`/`Diff` (and a +//! trailing `RawTerminal`); `Menu`/`Input` are defined here but produced later. + +/// Engine-assigned, monotonically increasing block identifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BlockId(pub u64); + +/// Bumps on every edit to a block, so a consumer can detect missed updates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Revision(pub u64); + +/// One rendered element of the chat document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Block { + Markdown { + text: String, + complete: bool, + }, + Code { + lang: Option, + text: String, + complete: bool, + }, + Diff { + unified: String, + complete: bool, + }, + Menu(Menu), + Input(InputPrompt), + /// The raw terminal screen — the always-available source of truth. Phase 1 + /// carries plain text; Phase 2 upgrades this to a styled grid snapshot. + RawTerminal { + screen: String, + }, +} + +/// The variant tag, used by [`BlockEvent::Append`] so a consumer can allocate an +/// empty block of the right kind before patches arrive. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockKind { + Markdown, + Code, + Diff, + Menu, + Input, + RawTerminal, +} + +impl Block { + pub fn kind(&self) -> BlockKind { + match self { + Block::Markdown { .. } => BlockKind::Markdown, + Block::Code { .. } => BlockKind::Code, + Block::Diff { .. } => BlockKind::Diff, + Block::Menu(_) => BlockKind::Menu, + Block::Input(_) => BlockKind::Input, + Block::RawTerminal { .. } => BlockKind::RawTerminal, + } + } + + /// An empty block of the given kind, as materialized on [`BlockEvent::Append`]. + pub fn empty(kind: BlockKind) -> Block { + match kind { + BlockKind::Markdown => Block::Markdown { + text: String::new(), + complete: false, + }, + BlockKind::Code => Block::Code { + lang: None, + text: String::new(), + complete: false, + }, + BlockKind::Diff => Block::Diff { + unified: String::new(), + complete: false, + }, + BlockKind::Menu => Block::Menu(Menu::default()), + BlockKind::Input => Block::Input(InputPrompt::default()), + BlockKind::RawTerminal => Block::RawTerminal { + screen: String::new(), + }, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Menu { + pub title: Option, + pub options: Vec, + /// Highlighted row as last observed on screen. + pub selected: Option, + pub state: MenuState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MenuOption { + pub label: String, + pub hotkey: Option, + /// Source screen row, used by keystroke synthesis in Phase 2. + pub raw_row: u16, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MenuState { + #[default] + Live, + Resolved { + chosen: usize, + }, + Stale, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InputPrompt { + pub prompt: String, + pub kind: InputKind, + pub state: InputState, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InputKind { + #[default] + FreeForm, + Text, + Password, + YesNo, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InputState { + #[default] + Awaiting, + Submitted, +} + +/// A delta published when the authoritative log changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockEvent { + /// A new block began. + Append { id: BlockId, kind: BlockKind }, + /// An existing block changed. + Update { + id: BlockId, + rev: Revision, + patch: BlockPatch, + }, + /// A block is complete; no more edits. + Finalize { id: BlockId, rev: Revision }, + /// Blocks from `from` onward were invalidated (e.g. an alt-screen clear). + Truncate { from: BlockId }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockPatch { + AppendText(String), + ReplaceText(String), + MenuSelect(usize), + MenuResolve(usize), + InputSubmit, +} diff --git a/crates/dd-client-session/src/derive/claude_code.rs b/crates/dd-client-session/src/derive/claude_code.rs new file mode 100644 index 0000000..a52c7f1 --- /dev/null +++ b/crates/dd-client-session/src/derive/claude_code.rs @@ -0,0 +1,291 @@ +//! Per-agent adapter for Claude Code's `--output-format stream-json`. +//! +//! When an agent is launched in a structured mode, we get lossless blocks +//! instead of scraping a TUI. This adapter consumes newline-delimited JSON +//! events and maps them to blocks: +//! * `assistant` message text → Markdown (one block per assistant turn) +//! * `assistant` `tool_use` → a Markdown header + a Code block of the input +//! * `user` `tool_result` → a Code block (the tool output) +//! * `result` → a final Markdown block +//! +//! It targets the default (non-partial) event stream; incremental +//! `content_block_delta` streaming is a future enrichment. A line that doesn't +//! parse is surfaced as Markdown rather than dropped, so nothing is lost. If +//! parsing mostly fails, [`confidence`](Adapter::confidence) drops and the +//! engine can fall back to the floor. + +use serde_json::Value; + +use crate::block::{BlockId, BlockKind, BlockPatch}; + +use super::{Adapter, BlockSink, Confidence}; + +#[derive(Default)] +pub struct ClaudeCodeAdapter { + buf: Vec, + /// Open Markdown block accumulating assistant text, if any. + text_block: Option, + events_ok: u32, + parse_errors: u32, +} + +impl ClaudeCodeAdapter { + pub fn new() -> Self { + Self::default() + } + + fn close_text(&mut self, sink: &mut dyn BlockSink) { + if let Some(id) = self.text_block.take() { + sink.finalize(id); + } + } + + fn push_markdown(&mut self, text: &str, sink: &mut dyn BlockSink) { + let id = match self.text_block { + Some(id) => id, + None => { + let id = sink.append(BlockKind::Markdown); + self.text_block = Some(id); + id + } + }; + sink.patch(id, BlockPatch::AppendText(text.to_string())); + } + + fn push_code(&mut self, lang: Option<&str>, text: &str, sink: &mut dyn BlockSink) { + self.close_text(sink); + let _ = lang; // lang carried for future styling; block records text now + let id = sink.append(BlockKind::Code); + sink.patch(id, BlockPatch::AppendText(text.to_string())); + sink.finalize(id); + } + + fn handle_event(&mut self, v: &Value, sink: &mut dyn BlockSink) { + match v.get("type").and_then(Value::as_str) { + Some("assistant") => self.handle_assistant(v, sink), + Some("user") => self.handle_tool_result(v, sink), + Some("result") => { + self.close_text(sink); + if let Some(text) = v.get("result").and_then(Value::as_str) { + let id = sink.append(BlockKind::Markdown); + sink.patch(id, BlockPatch::AppendText(format!("{text}\n"))); + sink.finalize(id); + } + } + // system/init and unknown types carry no user-facing content. + _ => {} + } + } + + fn handle_assistant(&mut self, v: &Value, sink: &mut dyn BlockSink) { + let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { + return; + }; + for block in content { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + self.push_markdown(&format!("{text}\n"), sink); + } + } + Some("tool_use") => { + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); + self.push_markdown(&format!("⚙ {name}\n"), sink); + let input = block + .get("input") + .map(|i| serde_json::to_string_pretty(i).unwrap_or_default()) + .unwrap_or_default(); + if !input.is_empty() { + self.push_code(Some("json"), &format!("{input}\n"), sink); + } + } + _ => {} + } + } + } + + fn handle_tool_result(&mut self, v: &Value, sink: &mut dyn BlockSink) { + let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { + return; + }; + for block in content { + if block.get("type").and_then(Value::as_str) == Some("tool_result") { + let text = match block.get("content") { + Some(Value::String(s)) => s.clone(), + Some(other) => serde_json::to_string_pretty(other).unwrap_or_default(), + None => continue, + }; + self.push_code(None, &format!("{text}\n"), sink); + } + } + } +} + +impl Adapter for ClaudeCodeAdapter { + fn name(&self) -> &str { + "claude-code" + } + + fn feed(&mut self, bytes: &[u8], sink: &mut dyn BlockSink) { + self.buf.extend_from_slice(bytes); + while let Some(pos) = self.buf.iter().position(|&b| b == b'\n') { + let line: Vec = self.buf.drain(..=pos).collect(); + let line = &line[..line.len() - 1]; + if line.iter().all(|b| b.is_ascii_whitespace()) { + continue; + } + match serde_json::from_slice::(line) { + Ok(v) => { + self.events_ok += 1; + self.handle_event(&v, sink); + } + Err(_) => { + // Don't drop anything we couldn't parse. + self.parse_errors += 1; + let text = String::from_utf8_lossy(line).into_owned(); + self.push_markdown(&format!("{text}\n"), sink); + } + } + } + } + + fn flush(&mut self, sink: &mut dyn BlockSink) { + if !self.buf.is_empty() { + let rest: Vec = std::mem::take(&mut self.buf); + if let Ok(v) = serde_json::from_slice::(&rest) { + self.events_ok += 1; + self.handle_event(&v, sink); + } + } + self.close_text(sink); + } + + fn confidence(&self) -> Confidence { + match (self.events_ok, self.parse_errors) { + (0, _) => Confidence::Low, + (_, 0) => Confidence::High, + _ => Confidence::Medium, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::block::Block; + use crate::stream::BlockLog; + + #[derive(Default)] + struct TestSink { + log: BlockLog, + } + impl BlockSink for TestSink { + fn append(&mut self, kind: BlockKind) -> BlockId { + self.log.append(kind).0 + } + fn patch(&mut self, id: BlockId, patch: BlockPatch) { + self.log.patch(id, patch); + } + fn finalize(&mut self, id: BlockId) { + self.log.finalize(id); + } + fn truncate(&mut self, from: BlockId) { + self.log.truncate(from); + } + } + + fn derive(input: &[u8]) -> Vec { + let mut a = ClaudeCodeAdapter::new(); + let mut sink = TestSink::default(); + a.feed(input, &mut sink); + a.flush(&mut sink); + sink.log.snapshot() + } + + #[test] + fn assistant_text_becomes_markdown() { + let line = + br#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello there"}]}}"#; + let mut input = line.to_vec(); + input.push(b'\n'); + assert_eq!( + derive(&input), + vec![Block::Markdown { + text: "Hello there\n".into(), + complete: true + }] + ); + } + + #[test] + fn tool_use_becomes_header_plus_code() { + let line = br#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}"#; + let mut input = line.to_vec(); + input.push(b'\n'); + let blocks = derive(&input); + assert_eq!( + blocks[0], + Block::Markdown { + text: "⚙ Bash\n".into(), + complete: true + } + ); + match &blocks[1] { + Block::Code { text, .. } => assert!(text.contains("\"command\": \"ls\"")), + other => panic!("expected code block, got {other:?}"), + } + } + + #[test] + fn result_event_is_final_markdown() { + let line = br#"{"type":"result","subtype":"success","result":"done"}"#; + let mut input = line.to_vec(); + input.push(b'\n'); + assert_eq!( + derive(&input), + vec![Block::Markdown { + text: "done\n".into(), + complete: true + }] + ); + } + + #[test] + fn unparseable_line_is_surfaced_not_dropped() { + let blocks = derive(b"not json at all\n"); + assert_eq!( + blocks, + vec![Block::Markdown { + text: "not json at all\n".into(), + complete: true + }] + ); + } + + #[test] + fn confidence_high_on_clean_parse_low_on_none() { + let mut a = ClaudeCodeAdapter::new(); + assert_eq!(a.confidence(), Confidence::Low); + let mut sink = TestSink::default(); + a.feed(b"{\"type\":\"system\"}\n", &mut sink); + assert_eq!(a.confidence(), Confidence::High); + } + + #[test] + fn json_split_across_feeds() { + let mut a = ClaudeCodeAdapter::new(); + let mut sink = TestSink::default(); + a.feed( + br#"{"type":"assistant","message":{"content":[{"type":"text",""#, + &mut sink, + ); + a.feed(b"text\":\"hi\"}]}}\n", &mut sink); + assert_eq!( + sink.log.snapshot(), + vec![Block::Markdown { + text: "hi\n".into(), + complete: false + }] + ); + } +} diff --git a/crates/dd-client-session/src/derive/floor.rs b/crates/dd-client-session/src/derive/floor.rs new file mode 100644 index 0000000..0610887 --- /dev/null +++ b/crates/dd-client-session/src/derive/floor.rs @@ -0,0 +1,379 @@ +//! The universal floor: a line-oriented interpreter that works for any TUI. +//! +//! It strips ANSI control sequences incrementally (state persists across feed +//! boundaries), splits the output into logical lines, and groups them into +//! `Markdown` / `Code` / `Diff` blocks. A bare carriage return rewrites the +//! current line (the common spinner/progress idiom), which keeps transient +//! redraws out of the block log. +//! +//! Honest limits (Phase 1): output is line-buffered, so a long token-streamed +//! line without newlines appears only once it completes; full-screen +//! (alt-screen) apps that paint absolutely are not modeled here — Phase 2's +//! `vt100` screen handles those and powers menu detection + Raw mode. + +use crate::block::{BlockId, BlockKind, BlockPatch}; + +use super::{Adapter, BlockSink, Confidence}; + +#[derive(Clone, Copy)] +enum Ansi { + Normal, + Esc, + Csi, + Osc, + OscEsc, +} + +pub struct FloorAdapter { + ansi: Ansi, + line: Vec, + /// A carriage return was seen but not yet acted on. `\r\n` is a clean line + /// ending; a bare `\r` followed by content rewrites the line (spinners). + pending_cr: bool, + in_code: bool, + in_diff: bool, + current: Option<(BlockId, BlockKind)>, +} + +impl Default for FloorAdapter { + fn default() -> Self { + Self { + ansi: Ansi::Normal, + line: Vec::new(), + pending_cr: false, + in_code: false, + in_diff: false, + current: None, + } + } +} + +impl FloorAdapter { + pub fn new() -> Self { + Self::default() + } + + /// Ensure the open block is of `kind`, finalizing a different open block + /// first. Returns the id to patch. + fn ensure(&mut self, kind: BlockKind, sink: &mut dyn BlockSink) -> BlockId { + if let Some((id, k)) = self.current { + if k == kind { + return id; + } + sink.finalize(id); + } + let id = sink.append(kind); + self.current = Some((id, kind)); + id + } + + /// If a bare carriage return is pending, the upcoming content rewrites the + /// line from the start, so clear what we had. + fn rewrite_if_pending_cr(&mut self) { + if self.pending_cr { + self.line.clear(); + self.pending_cr = false; + } + } + + fn append_line(&mut self, kind: BlockKind, mut text: String, sink: &mut dyn BlockSink) { + let id = self.ensure(kind, sink); + text.push('\n'); + sink.patch(id, BlockPatch::AppendText(text)); + } + + fn emit_line(&mut self, bytes: Vec, sink: &mut dyn BlockSink) { + let line = String::from_utf8_lossy(&bytes).into_owned(); + let trimmed = line.trim_start(); + + // Fenced code toggles. The fence line itself is not content. + if trimmed.starts_with("```") { + if self.in_code { + self.in_code = false; + if let Some((id, _)) = self.current.take() { + sink.finalize(id); + } + } else { + // Close any open block, then open a code block. + if let Some((id, _)) = self.current.take() { + sink.finalize(id); + } + self.in_code = true; + let id = sink.append(BlockKind::Code); + self.current = Some((id, BlockKind::Code)); + } + return; + } + + if self.in_code { + self.append_line(BlockKind::Code, line, sink); + return; + } + + if !self.in_diff && (trimmed.starts_with("@@ ") || line.starts_with("diff --git ")) { + self.in_diff = true; + } + if self.in_diff { + if is_diff_line(&line) { + self.append_line(BlockKind::Diff, line, sink); + return; + } + self.in_diff = false; + if let Some((id, BlockKind::Diff)) = self.current { + sink.finalize(id); + self.current = None; + } + } + + self.append_line(BlockKind::Markdown, line, sink); + } +} + +fn is_diff_line(line: &str) -> bool { + line.starts_with("diff --git") + || matches!( + line.as_bytes().first(), + Some(b'+' | b'-' | b' ' | b'@' | b'\\') + ) +} + +impl Adapter for FloorAdapter { + fn name(&self) -> &str { + "floor" + } + + fn feed(&mut self, bytes: &[u8], sink: &mut dyn BlockSink) { + for &b in bytes { + self.ansi = match self.ansi { + Ansi::Normal => match b { + 0x1b => Ansi::Esc, + b'\n' => { + self.pending_cr = false; + let line = std::mem::take(&mut self.line); + self.emit_line(line, sink); + Ansi::Normal + } + b'\r' => { + self.pending_cr = true; + Ansi::Normal + } + 0x08 => { + self.rewrite_if_pending_cr(); + self.line.pop(); + Ansi::Normal + } + b'\t' => { + self.rewrite_if_pending_cr(); + self.line.push(b' '); + Ansi::Normal + } + // Drop other C0 controls; keep printable bytes (incl. UTF-8). + 0x00..=0x1f => Ansi::Normal, + _ => { + self.rewrite_if_pending_cr(); + self.line.push(b); + Ansi::Normal + } + }, + Ansi::Esc => match b { + b'[' => Ansi::Csi, + b']' => Ansi::Osc, + _ => Ansi::Normal, + }, + // CSI ends on a final byte 0x40..=0x7e; params/intermediates continue. + Ansi::Csi => { + if (0x40..=0x7e).contains(&b) { + Ansi::Normal + } else { + Ansi::Csi + } + } + // OSC ends on BEL or ST (ESC \). + Ansi::Osc => match b { + 0x07 => Ansi::Normal, + 0x1b => Ansi::OscEsc, + _ => Ansi::Osc, + }, + Ansi::OscEsc => Ansi::Normal, + }; + } + } + + fn flush(&mut self, sink: &mut dyn BlockSink) { + if !self.line.is_empty() { + let line = std::mem::take(&mut self.line); + self.emit_line(line, sink); + } + if let Some((id, _)) = self.current.take() { + sink.finalize(id); + } + } + + fn confidence(&self) -> Confidence { + Confidence::Floor + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::block::{Block, BlockEvent}; + use crate::stream::{BlockLog, BlockView}; + + /// Test sink backed by a real `BlockLog`, also recording the event stream so + /// we can assert a `BlockView` reconstructs the same thing. + #[derive(Default)] + struct TestSink { + log: BlockLog, + events: Vec, + } + impl BlockSink for TestSink { + fn append(&mut self, kind: BlockKind) -> BlockId { + let (id, ev) = self.log.append(kind); + self.events.push(ev); + id + } + fn patch(&mut self, id: BlockId, patch: BlockPatch) { + if let Some(ev) = self.log.patch(id, patch) { + self.events.push(ev); + } + } + fn finalize(&mut self, id: BlockId) { + if let Some(ev) = self.log.finalize(id) { + self.events.push(ev); + } + } + fn truncate(&mut self, from: BlockId) { + if let Some(ev) = self.log.truncate(from) { + self.events.push(ev); + } + } + } + + fn derive(input: &[u8]) -> TestSink { + let mut floor = FloorAdapter::new(); + let mut sink = TestSink::default(); + floor.feed(input, &mut sink); + floor.flush(&mut sink); + // Sanity: replaying the event stream reconstructs the snapshot. + let mut view = BlockView::default(); + for ev in &sink.events { + view.apply(ev); + } + assert_eq!(view.blocks(), sink.log.snapshot()); + sink + } + + #[test] + fn plain_lines_become_one_markdown_block() { + let s = derive(b"hello\nworld\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "hello\nworld\n".into(), + complete: true + }] + ); + } + + #[test] + fn strips_ansi_color_and_cursor_codes() { + // Colored "ok" then a cursor-move that should vanish. + let s = derive(b"\x1b[32mok\x1b[0m\n\x1b[2Kdone\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "ok\ndone\n".into(), + complete: true + }] + ); + } + + #[test] + fn crlf_line_endings_are_clean() { + // Real PTY output uses CRLF; the CR must not wipe the line. + let s = derive(b"hello\r\nworld\r\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "hello\nworld\n".into(), + complete: true + }] + ); + } + + #[test] + fn carriage_return_rewrites_progress_line() { + // A spinner redrawing the same line; only the final state survives. + let s = derive(b"working 10%\rworking 80%\rdone\n"); + assert_eq!( + s.log.snapshot(), + vec![Block::Markdown { + text: "done\n".into(), + complete: true + }] + ); + } + + #[test] + fn fenced_code_becomes_code_block_between_markdown() { + let s = derive(b"intro\n```rust\nfn main() {}\n```\noutro\n"); + assert_eq!( + s.log.snapshot(), + vec![ + Block::Markdown { + text: "intro\n".into(), + complete: true + }, + Block::Code { + lang: None, + text: "fn main() {}\n".into(), + complete: true + }, + Block::Markdown { + text: "outro\n".into(), + complete: true + }, + ] + ); + } + + #[test] + fn unified_diff_hunk_becomes_diff_block() { + let input = b"edit:\n@@ -1,2 +1,2 @@\n-old\n+new\n done\nafter\n"; + let s = derive(input); + assert_eq!( + s.log.snapshot(), + vec![ + Block::Markdown { + text: "edit:\n".into(), + complete: true + }, + Block::Diff { + unified: "@@ -1,2 +1,2 @@\n-old\n+new\n done\n".into(), + complete: true + }, + Block::Markdown { + text: "after\n".into(), + complete: true + }, + ] + ); + } + + #[test] + fn ansi_sequence_split_across_feeds_is_still_stripped() { + let mut floor = FloorAdapter::new(); + let mut sink = TestSink::default(); + floor.feed(b"a\x1b[", &mut sink); // escape split mid-sequence + floor.feed(b"31mb\n", &mut sink); + floor.flush(&mut sink); + assert_eq!( + sink.log.snapshot(), + vec![Block::Markdown { + text: "ab\n".into(), + complete: true + }] + ); + } +} diff --git a/crates/dd-client-session/src/derive/menu.rs b/crates/dd-client-session/src/derive/menu.rs new file mode 100644 index 0000000..49d2385 --- /dev/null +++ b/crates/dd-client-session/src/derive/menu.rs @@ -0,0 +1,216 @@ +//! Menu / prompt detection over a screen snapshot. +//! +//! This is the deliberately-fragile floor heuristic the design calls out: from a +//! grid of text + per-row highlight flags, guess whether the app is presenting a +//! selectable list. It recognizes two shapes: +//! * a run of ≥2 numbered/bulleted option lines, with the selected row marked +//! by reverse-video or a leading `>`/`❯` arrow; +//! * a single yes/no prompt (`(y/n)`, `[Y/n]`). +//! +//! Callers gate this on output quiescence (the agent has gone quiet ⇒ likely +//! awaiting input) — see the engine. Per-agent adapters replace this with exact +//! structure for the agents people actually use. + +use crate::block::{Menu, MenuOption, MenuState}; + +use super::screen::ScreenSnapshot; + +const ARROW_MARKERS: [char; 5] = ['>', '❯', '➤', '●', '*']; + +/// Detect a menu on the current screen, or `None`. +pub fn detect_menu(screen: &ScreenSnapshot) -> Option { + if let Some(menu) = detect_option_list(screen) { + return Some(menu); + } + detect_yes_no(screen) +} + +fn detect_option_list(screen: &ScreenSnapshot) -> Option { + let rows = &screen.rows; + // Find the last (closest-to-bottom) run of ≥2 consecutive option lines. + let mut best: Option<(usize, usize)> = None; + let mut i = 0; + while i < rows.len() { + if option_label(&rows[i].text).is_some() { + let start = i; + while i < rows.len() && option_label(&rows[i].text).is_some() { + i += 1; + } + if i - start >= 2 { + best = Some((start, i)); + } + } else { + i += 1; + } + } + let (start, end) = best?; + + let mut options = Vec::new(); + let mut selected = None; + for (idx, row) in rows[start..end].iter().enumerate() { + let (label, hotkey) = option_label(&row.text)?; + if row.inverse || starts_with_marker(&row.text) { + selected = Some(idx); + } + options.push(MenuOption { + label, + hotkey, + raw_row: (start + idx) as u16, + }); + } + Some(Menu { + title: None, + options, + selected, + state: MenuState::Live, + }) +} + +fn detect_yes_no(screen: &ScreenSnapshot) -> Option { + let row = screen + .rows + .iter() + .rev() + .find(|r| !r.text.trim().is_empty())?; + let lower = row.text.to_lowercase(); + if !(lower.contains("(y/n)") || lower.contains("[y/n]") || lower.contains("(yes/no)")) { + return None; + } + Some(Menu { + title: Some(row.text.trim().to_string()), + options: vec![ + MenuOption { + label: "Yes".into(), + hotkey: Some('y'), + raw_row: 0, + }, + MenuOption { + label: "No".into(), + hotkey: Some('n'), + raw_row: 0, + }, + ], + selected: None, + state: MenuState::Live, + }) +} + +fn starts_with_marker(line: &str) -> bool { + line.trim_start() + .starts_with(|c: char| ARROW_MARKERS.contains(&c)) +} + +/// Parse an option line, returning `(label, hotkey)` if it looks like one. +/// Accepts an optional leading arrow/bullet marker, then either a number +/// (`1.`/`1)`) or a bracketed/lettered hotkey, then the label text. +fn option_label(line: &str) -> Option<(String, Option)> { + let mut s = line.trim_start(); + // Strip a leading selection marker. + if let Some(rest) = s.strip_prefix(|c: char| ARROW_MARKERS.contains(&c)) { + s = rest.trim_start(); + } + if s.is_empty() { + return None; + } + + // Numbered: "1. label" or "1) label". + let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !digits.is_empty() { + let rest = &s[digits.len()..]; + if let Some(label) = rest.strip_prefix(['.', ')']) { + let label = label.trim(); + if !label.is_empty() { + let hotkey = digits.chars().next().filter(|_| digits.len() == 1); + return Some((label.to_string(), hotkey)); + } + } + } + + // Bracketed letter: "[a] label" or "(a) label". + let bytes = s.as_bytes(); + if bytes.len() >= 3 { + let (open, close) = (bytes[0], bytes[2]); + let mid = bytes[1] as char; + if (open == b'[' && close == b']' || open == b'(' && close == b')') + && mid.is_ascii_alphanumeric() + { + let label = s[3..].trim(); + if !label.is_empty() { + return Some((label.to_string(), Some(mid.to_ascii_lowercase()))); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive::screen::RowSnapshot; + + fn screen(rows: &[(&str, bool)]) -> ScreenSnapshot { + ScreenSnapshot { + rows: rows + .iter() + .map(|(t, inv)| RowSnapshot { + text: (*t).to_string(), + inverse: *inv, + }) + .collect(), + cursor: (0, 0), + alternate: false, + } + } + + #[test] + fn numbered_list_with_inverse_selection() { + let s = screen(&[ + ("Choose an option:", false), + ("1. Apply", false), + ("2. Skip", true), // highlighted + ("3. Quit", false), + ]); + let menu = detect_menu(&s).expect("menu"); + assert_eq!(menu.options.len(), 3); + assert_eq!(menu.options[0].label, "Apply"); + assert_eq!(menu.options[0].hotkey, Some('1')); + assert_eq!(menu.selected, Some(1)); + } + + #[test] + fn arrow_marker_marks_selection() { + let s = screen(&[("> 1) yes", false), (" 2) no", false)]); + let menu = detect_menu(&s).expect("menu"); + assert_eq!(menu.selected, Some(0)); + assert_eq!(menu.options[1].label, "no"); + } + + #[test] + fn yes_no_prompt() { + let s = screen(&[("Proceed with deploy? (y/n)", false)]); + let menu = detect_menu(&s).expect("menu"); + assert_eq!(menu.options.len(), 2); + assert_eq!(menu.options[0].hotkey, Some('y')); + assert_eq!(menu.options[1].hotkey, Some('n')); + } + + #[test] + fn plain_prose_is_not_a_menu() { + let s = screen(&[ + ("Here is a paragraph of normal output.", false), + ("It continues on a second line.", false), + ]); + assert!(detect_menu(&s).is_none()); + } + + #[test] + fn single_option_is_not_enough() { + let s = screen(&[ + ("intro", false), + ("1. only one", false), + ("trailing", false), + ]); + assert!(detect_menu(&s).is_none()); + } +} diff --git a/crates/dd-client-session/src/derive/mod.rs b/crates/dd-client-session/src/derive/mod.rs new file mode 100644 index 0000000..6f518c9 --- /dev/null +++ b/crates/dd-client-session/src/derive/mod.rs @@ -0,0 +1,46 @@ +//! Derivation: turning the PTY byte stream into structured blocks. +//! +//! The [`FloorAdapter`] is always present and works for any TUI by interpreting +//! its output. Later phases add per-agent [`Adapter`]s that consume a structured +//! mode (e.g. Claude Code stream-json) for lossless blocks. Both write through a +//! [`BlockSink`], so the engine doesn't care which produced a block. + +pub mod claude_code; +pub mod floor; +pub mod menu; +pub mod screen; + +use crate::block::{BlockId, BlockKind, BlockPatch}; + +pub use claude_code::ClaudeCodeAdapter; +pub use floor::FloorAdapter; +pub use screen::{Screen, ScreenSnapshot}; + +/// Where an adapter pushes block mutations. The engine implements this over its +/// authoritative log + event broadcast. +pub trait BlockSink { + fn append(&mut self, kind: BlockKind) -> BlockId; + fn patch(&mut self, id: BlockId, patch: BlockPatch); + fn finalize(&mut self, id: BlockId); + fn truncate(&mut self, from: BlockId); +} + +/// How faithful an adapter believes its block model is. The engine prefers the +/// highest-confidence adapter; the floor is always [`Confidence::Floor`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Confidence { + Floor, + Low, + Medium, + High, +} + +pub trait Adapter: Send { + fn name(&self) -> &str; + /// Feed raw PTY bytes; push any resulting block mutations to `sink`. + fn feed(&mut self, bytes: &[u8], sink: &mut dyn BlockSink); + /// Flush any buffered partial line and finalize the open block (called once + /// the session output ends). + fn flush(&mut self, sink: &mut dyn BlockSink); + fn confidence(&self) -> Confidence; +} diff --git a/crates/dd-client-session/src/derive/screen.rs b/crates/dd-client-session/src/derive/screen.rs new file mode 100644 index 0000000..8d21914 --- /dev/null +++ b/crates/dd-client-session/src/derive/screen.rs @@ -0,0 +1,135 @@ +//! Headless terminal screen, wrapping `vt100`. +//! +//! Two jobs: (1) produce a faithful text snapshot of the current screen for the +//! `RawTerminal` block / Raw-mode rendering, and (2) expose per-row highlight +//! info so menu detection can spot the selected option. The `vt100` crate is +//! kept behind this wrapper so the heuristics never depend on it directly. + +use vt100::Parser; + +const DEFAULT_ROWS: u16 = 24; +const DEFAULT_COLS: u16 = 80; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RowSnapshot { + pub text: String, + /// True if any cell in the row is reverse-video — the strongest signal a TUI + /// uses to mark a highlighted/selected menu row. + pub inverse: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScreenSnapshot { + pub rows: Vec, + /// (row, col) of the cursor. + pub cursor: (u16, u16), + /// Whether the app is on the alternate screen (full-screen TUI). + pub alternate: bool, +} + +impl ScreenSnapshot { + /// The screen as plain text, trailing blank rows trimmed. + pub fn plain(&self) -> String { + let last = self + .rows + .iter() + .rposition(|r| !r.text.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(0); + self.rows[..last] + .iter() + .map(|r| r.text.as_str()) + .collect::>() + .join("\n") + } +} + +pub struct Screen { + parser: Parser, +} + +impl Default for Screen { + fn default() -> Self { + Self::new(DEFAULT_ROWS, DEFAULT_COLS) + } +} + +impl Screen { + pub fn new(rows: u16, cols: u16) -> Self { + Self { + parser: Parser::new(rows, cols, 0), + } + } + + pub fn process(&mut self, bytes: &[u8]) { + self.parser.process(bytes); + } + + pub fn resize(&mut self, rows: u16, cols: u16) { + self.parser.set_size(rows, cols); + } + + /// Escape-sequence bytes that repaint the current screen from scratch — + /// used to restore the display when entering Raw mode. + pub fn formatted(&self) -> Vec { + self.parser.screen().contents_formatted() + } + + pub fn snapshot(&self) -> ScreenSnapshot { + let screen = self.parser.screen(); + let (rows, cols) = screen.size(); + let mut out = Vec::with_capacity(rows as usize); + for r in 0..rows { + let mut text = String::new(); + let mut inverse = false; + for c in 0..cols { + if let Some(cell) = screen.cell(r, c) { + let contents = cell.contents(); + if contents.is_empty() { + text.push(' '); + } else { + text.push_str(&contents); + } + if cell.inverse() { + inverse = true; + } + } + } + out.push(RowSnapshot { + text: text.trim_end().to_string(), + inverse, + }); + } + ScreenSnapshot { + rows: out, + cursor: screen.cursor_position(), + alternate: screen.alternate_screen(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_plain_text() { + let mut s = Screen::new(4, 20); + s.process(b"hello\r\nworld\r\n"); + let snap = s.snapshot(); + assert_eq!(snap.rows[0].text, "hello"); + assert_eq!(snap.rows[1].text, "world"); + assert_eq!(snap.plain(), "hello\nworld"); + } + + #[test] + fn detects_inverse_row() { + let mut s = Screen::new(4, 20); + // Normal line, then a reverse-video line. + s.process(b"normal\r\n\x1b[7mselected\x1b[0m\r\n"); + let snap = s.snapshot(); + assert!(!snap.rows[0].inverse); + assert!(snap.rows[1].inverse); + assert_eq!(snap.rows[1].text, "selected"); + } +} diff --git a/crates/dd-client-session/src/engine.rs b/crates/dd-client-session/src/engine.rs new file mode 100644 index 0000000..a173593 --- /dev/null +++ b/crates/dd-client-session/src/engine.rs @@ -0,0 +1,186 @@ +//! The session engine: owns the authoritative [`BlockLog`], the floor deriver, +//! and the vt100 [`Screen`], and publishes a [`BlockEvent`] broadcast. +//! +//! The frontend feeds every output frame to [`SessionEngine::feed_output`]; the +//! engine drives both the floor (→ structured blocks) and the screen (→ Raw-mode +//! rendering + menu detection). It is cheaply cloneable (shared state) so the +//! pump task and the renderer can each hold one. + +use std::sync::{Arc, Mutex}; + +use tokio::sync::broadcast; + +use crate::block::{Block, BlockEvent, BlockId, BlockKind, BlockPatch}; +use crate::derive::menu::detect_menu; +use crate::derive::screen::ScreenSnapshot; +use crate::derive::{Adapter, BlockSink, FloorAdapter, Screen}; +use crate::stream::BlockLog; + +const EVENT_CAPACITY: usize = 4096; + +#[derive(Clone)] +pub struct SessionEngine { + log: Arc>, + tx: broadcast::Sender, + adapter: Arc>>, + screen: Arc>, +} + +impl Default for SessionEngine { + fn default() -> Self { + Self::new() + } +} + +impl SessionEngine { + /// Engine with the universal floor deriver. + pub fn new() -> Self { + Self::with_adapter(Box::new(FloorAdapter::new())) + } + + /// Engine with a specific structured deriver (e.g. a per-agent adapter). The + /// vt100 screen always runs alongside for Raw-mode rendering + menu detection. + pub fn with_adapter(adapter: Box) -> Self { + let (tx, _) = broadcast::channel(EVENT_CAPACITY); + Self { + log: Arc::new(Mutex::new(BlockLog::new())), + tx, + adapter: Arc::new(Mutex::new(adapter)), + screen: Arc::new(Mutex::new(Screen::default())), + } + } + + /// Feed one decrypted output frame: drives the structured adapter and the + /// screen (grid). Called from the transport pump. + pub fn feed_output(&self, bytes: &[u8]) { + { + let mut adapter = self.adapter.lock().expect("adapter poisoned"); + let mut sink = self.sink(); + adapter.feed(bytes, &mut sink); + } + self.screen.lock().expect("screen poisoned").process(bytes); + } + + /// Flush the adapter's buffered state and finalize the open block (once the + /// session output ends). + pub fn finish(&self) { + let mut adapter = self.adapter.lock().expect("adapter poisoned"); + let mut sink = self.sink(); + adapter.flush(&mut sink); + } + + pub fn resize(&self, rows: u16, cols: u16) { + self.screen + .lock() + .expect("screen poisoned") + .resize(rows, cols); + } + + /// Current screen snapshot — for Raw-mode rendering and menu detection. + pub fn screen_snapshot(&self) -> ScreenSnapshot { + self.screen.lock().expect("screen poisoned").snapshot() + } + + /// Repaint bytes for the current screen — written to the tty on Raw entry. + pub fn screen_formatted(&self) -> Vec { + self.screen.lock().expect("screen poisoned").formatted() + } + + /// Detect a menu on the current screen, if any (Interact mode). + pub fn detect_menu(&self) -> Option { + detect_menu(&self.screen_snapshot()) + } + + /// A consistent snapshot plus a receiver for subsequent deltas. The lock is + /// held across `subscribe()` + `snapshot()` so no event slips between them. + pub fn subscribe(&self) -> (Vec, broadcast::Receiver) { + let log = self.log.lock().expect("block log poisoned"); + let rx = self.tx.subscribe(); + let snap = log.snapshot(); + (snap, rx) + } + + /// Current blocks — used by a renderer to resync after a broadcast lag. + pub fn snapshot(&self) -> Vec { + self.log.lock().expect("block log poisoned").snapshot() + } + + fn sink(&self) -> EngineSink { + EngineSink { + log: self.log.clone(), + tx: self.tx.clone(), + } + } +} + +/// A [`BlockSink`] that mutates the engine's log and broadcasts each delta. The +/// log lock is held across the broadcast so [`SessionEngine::subscribe`] stays +/// race-free. +struct EngineSink { + log: Arc>, + tx: broadcast::Sender, +} + +impl BlockSink for EngineSink { + fn append(&mut self, kind: BlockKind) -> BlockId { + let mut log = self.log.lock().expect("block log poisoned"); + let (id, ev) = log.append(kind); + let _ = self.tx.send(ev); + id + } + + fn patch(&mut self, id: BlockId, patch: BlockPatch) { + let mut log = self.log.lock().expect("block log poisoned"); + if let Some(ev) = log.patch(id, patch) { + let _ = self.tx.send(ev); + } + } + + fn finalize(&mut self, id: BlockId) { + let mut log = self.log.lock().expect("block log poisoned"); + if let Some(ev) = log.finalize(id) { + let _ = self.tx.send(ev); + } + } + + fn truncate(&mut self, from: BlockId) { + let mut log = self.log.lock().expect("block log poisoned"); + if let Some(ev) = log.truncate(from) { + let _ = self.tx.send(ev); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn feed_output_lands_in_log_screen_and_broadcasts() { + let engine = SessionEngine::new(); + let (snap0, mut rx) = engine.subscribe(); + assert!(snap0.is_empty()); + + engine.feed_output(b"hello\r\n"); + engine.finish(); + + assert!(matches!(rx.try_recv(), Ok(BlockEvent::Append { .. }))); + assert_eq!( + engine.snapshot(), + vec![Block::Markdown { + text: "hello\n".into(), + complete: true + }] + ); + assert_eq!(engine.screen_snapshot().rows[0].text, "hello"); + } + + #[tokio::test] + async fn menu_detected_from_screen() { + let engine = SessionEngine::new(); + engine.feed_output(b"Pick:\r\n1. apply\r\n\x1b[7m2. skip\x1b[0m\r\n"); + let menu = engine.detect_menu().expect("menu"); + assert_eq!(menu.options.len(), 2); + assert_eq!(menu.selected, Some(1)); + } +} diff --git a/crates/dd-client-session/src/input.rs b/crates/dd-client-session/src/input.rs new file mode 100644 index 0000000..00e0b07 --- /dev/null +++ b/crates/dd-client-session/src/input.rs @@ -0,0 +1,196 @@ +//! Raw-mode escape chord parsing and menu keystroke synthesis. +//! +//! In Raw mode the app owns `Tab`, so we can't use it to leave. Instead `Ctrl-]` +//! is a prefix: `Ctrl-] Tab` pops back to the structured view, `Ctrl-] Ctrl-]` +//! or `Ctrl-] d` detaches (keeps the remote session alive). `Ctrl-D` still sends +//! EOF and disconnects. Everything else is forwarded verbatim. +//! +//! For menu selection there is no structured channel to a TUI — it awaits a +//! keypress — so a pick is replayed as keystrokes: the option's hotkey when +//! known, otherwise arrow-stepping from the last-observed selection plus Enter. + +use crate::block::MenuOption; + +pub const CTRL_RIGHT_BRACKET: u8 = 0x1d; +pub const CTRL_D: u8 = 0x04; +const TAB: u8 = 0x09; + +/// What the Raw-mode chord parser decided for a span of input. +#[derive(Debug, PartialEq, Eq)] +pub enum RawAction { + /// Forward these bytes to the PTY. + Forward(Vec), + /// Forward these bytes, then disconnect (EOF). + ForwardThenStop(Vec), + /// Leave Raw, return to the structured view (session stays attached). + ExitToStructured, + /// Detach: stop the client but keep the remote session alive. + Detach, +} + +/// Stateful chord parser: `Ctrl-]` may arrive at the end of one read and its +/// continuation at the start of the next, so the "armed" flag persists. +#[derive(Default)] +pub struct ChordParser { + armed: bool, +} + +impl ChordParser { + pub fn new() -> Self { + Self::default() + } + + pub fn feed(&mut self, bytes: &[u8]) -> Vec { + let mut actions = Vec::new(); + let mut pending: Vec = Vec::new(); + + for &b in bytes { + if self.armed { + self.armed = false; + match b { + TAB => { + flush(&mut pending, &mut actions); + actions.push(RawAction::ExitToStructured); + } + CTRL_RIGHT_BRACKET | b'd' => { + flush(&mut pending, &mut actions); + actions.push(RawAction::Detach); + } + // Not a chord: the swallowed Ctrl-] was literal input. + _ => { + pending.push(CTRL_RIGHT_BRACKET); + self.consume_plain(b, &mut pending, &mut actions); + } + } + } else { + self.consume_plain(b, &mut pending, &mut actions); + } + } + flush(&mut pending, &mut actions); + actions + } + + fn consume_plain(&mut self, b: u8, pending: &mut Vec, actions: &mut Vec) { + match b { + CTRL_RIGHT_BRACKET => self.armed = true, + CTRL_D => { + flush(pending, actions); + actions.push(RawAction::ForwardThenStop(vec![CTRL_D])); + } + _ => pending.push(b), + } + } +} + +fn flush(pending: &mut Vec, actions: &mut Vec) { + if !pending.is_empty() { + actions.push(RawAction::Forward(std::mem::take(pending))); + } +} + +/// Synthesize the keystrokes that select `target` in a menu, given the +/// last-observed highlighted index. Prefers the option's hotkey (absolute, +/// avoids stale-cursor drift); otherwise arrow-steps and confirms with Enter. +pub fn menu_select_keys(current: Option, target: usize, option: &MenuOption) -> Vec { + if let Some(c) = option.hotkey { + // Hotkey selection (numbered/lettered/yes-no) acts on the keypress. + return vec![c as u8]; + } + let cur = current.unwrap_or(0); + let (seq, count): (&[u8], usize) = if target >= cur { + (b"\x1b[B", target - cur) // Down + } else { + (b"\x1b[A", cur - target) // Up + }; + let mut keys = Vec::with_capacity(seq.len() * count + 1); + for _ in 0..count { + keys.extend_from_slice(seq); + } + keys.push(b'\r'); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opt(hotkey: Option) -> MenuOption { + MenuOption { + label: "x".into(), + hotkey, + raw_row: 0, + } + } + + #[test] + fn forwards_plain_bytes() { + let mut p = ChordParser::new(); + assert_eq!(p.feed(b"ls\n"), vec![RawAction::Forward(b"ls\n".to_vec())]); + } + + #[test] + fn ctrl_rbracket_then_tab_exits() { + let mut p = ChordParser::new(); + assert_eq!( + p.feed(&[CTRL_RIGHT_BRACKET, TAB]), + vec![RawAction::ExitToStructured] + ); + } + + #[test] + fn double_ctrl_rbracket_detaches() { + let mut p = ChordParser::new(); + assert_eq!( + p.feed(&[CTRL_RIGHT_BRACKET, CTRL_RIGHT_BRACKET]), + vec![RawAction::Detach] + ); + } + + #[test] + fn ctrl_d_forwards_then_stops() { + let mut p = ChordParser::new(); + assert_eq!( + p.feed(&[CTRL_D]), + vec![RawAction::ForwardThenStop(vec![CTRL_D])] + ); + } + + #[test] + fn chord_split_across_feeds() { + let mut p = ChordParser::new(); + assert_eq!(p.feed(&[CTRL_RIGHT_BRACKET]), vec![]); // armed, nothing yet + assert_eq!(p.feed(&[TAB]), vec![RawAction::ExitToStructured]); + } + + #[test] + fn ctrl_rbracket_then_other_is_literal() { + let mut p = ChordParser::new(); + // Ctrl-] then 'x' was not a chord: forward both. + assert_eq!( + p.feed(&[CTRL_RIGHT_BRACKET, b'x']), + vec![RawAction::Forward(vec![CTRL_RIGHT_BRACKET, b'x'])] + ); + } + + #[test] + fn hotkey_selection_is_absolute() { + assert_eq!(menu_select_keys(Some(0), 2, &opt(Some('3'))), vec![b'3']); + } + + #[test] + fn arrow_selection_steps_down_then_enter() { + // From row 0 to row 2: Down, Down, Enter. + assert_eq!( + menu_select_keys(Some(0), 2, &opt(None)), + b"\x1b[B\x1b[B\r".to_vec() + ); + } + + #[test] + fn arrow_selection_steps_up() { + assert_eq!( + menu_select_keys(Some(3), 1, &opt(None)), + b"\x1b[A\x1b[A\r".to_vec() + ); + } +} diff --git a/crates/dd-client-session/src/lib.rs b/crates/dd-client-session/src/lib.rs new file mode 100644 index 0000000..247b57b --- /dev/null +++ b/crates/dd-client-session/src/lib.rs @@ -0,0 +1,22 @@ +//! The DevOps Defender client session engine. +//! +//! This crate sits between [`dd_client_core`] (Noise transport, quote +//! verification, RPCs, keys) and the platform frontends (CLI, iOS). It owns the +//! interpretation layer: it consumes the PTY byte stream of an attached session +//! and — in later phases — derives a structured "chat document" (markdown +//! blocks + menus), tracks view modes, and routes input. +//! +//! Phase 0 establishes only the seam: [`transport`] drives the Noise duplex +//! (forward input frames, surface decrypted output frames) with no terminal +//! coupling, so the same loop serves every frontend. + +pub mod block; +pub mod derive; +pub mod engine; +pub mod input; +pub mod mode; +pub mod stream; +pub mod transport; + +pub use engine::SessionEngine; +pub use mode::ViewMode; diff --git a/crates/dd-client-session/src/mode.rs b/crates/dd-client-session/src/mode.rs new file mode 100644 index 0000000..cc940a8 --- /dev/null +++ b/crates/dd-client-session/src/mode.rs @@ -0,0 +1,97 @@ +//! View-mode state machine and its mapping onto the server integrity model. +//! +//! The three modes are projections of one live session. Watch is read-only +//! (transmits nothing → `Clean`); Interact and Raw can send input (`Controlled`). +//! Tab cycles forward, Shift-Tab back; the indicator and the taint badge are the +//! same thing. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ViewMode { + /// Read-only structured render. Transmits nothing. + Watch, + /// Structured render with live menus/input. + Interact, + /// Full PTY passthrough. + Raw, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Integrity { + Clean, + Controlled, +} + +impl ViewMode { + pub fn next(self) -> Self { + match self { + ViewMode::Watch => ViewMode::Interact, + ViewMode::Interact => ViewMode::Raw, + ViewMode::Raw => ViewMode::Watch, + } + } + + pub fn prev(self) -> Self { + match self { + ViewMode::Watch => ViewMode::Raw, + ViewMode::Interact => ViewMode::Watch, + ViewMode::Raw => ViewMode::Interact, + } + } + + /// Whether this mode is allowed to transmit input/signals. + pub fn is_readonly(self) -> bool { + matches!(self, ViewMode::Watch) + } + + /// Whether this mode renders structured blocks (vs. raw passthrough). + pub fn is_structured(self) -> bool { + !matches!(self, ViewMode::Raw) + } + + pub fn integrity(self) -> Integrity { + if self.is_readonly() { + Integrity::Clean + } else { + Integrity::Controlled + } + } + + pub fn label(self) -> &'static str { + match self { + ViewMode::Watch => "WATCH", + ViewMode::Interact => "INTERACT", + ViewMode::Raw => "RAW", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cycles_forward_and_back() { + assert_eq!(ViewMode::Watch.next(), ViewMode::Interact); + assert_eq!(ViewMode::Interact.next(), ViewMode::Raw); + assert_eq!(ViewMode::Raw.next(), ViewMode::Watch); + assert_eq!(ViewMode::Watch.prev(), ViewMode::Raw); + assert_eq!(ViewMode::Raw.prev(), ViewMode::Interact); + } + + #[test] + fn only_watch_is_clean_and_readonly() { + assert!(ViewMode::Watch.is_readonly()); + assert_eq!(ViewMode::Watch.integrity(), Integrity::Clean); + for m in [ViewMode::Interact, ViewMode::Raw] { + assert!(!m.is_readonly()); + assert_eq!(m.integrity(), Integrity::Controlled); + } + } + + #[test] + fn raw_is_the_only_unstructured_mode() { + assert!(ViewMode::Watch.is_structured()); + assert!(ViewMode::Interact.is_structured()); + assert!(!ViewMode::Raw.is_structured()); + } +} diff --git a/crates/dd-client-session/src/stream.rs b/crates/dd-client-session/src/stream.rs new file mode 100644 index 0000000..2b441d6 --- /dev/null +++ b/crates/dd-client-session/src/stream.rs @@ -0,0 +1,228 @@ +//! Authoritative block log + delta replay. +//! +//! [`BlockLog`] is the engine's source of truth. Each mutation returns the +//! [`BlockEvent`] it produced so the caller can broadcast it. Consumers keep a +//! [`BlockView`] seeded from a snapshot and replay events onto it — the same +//! logic the FFI/iOS layer will use, so it's defined and tested once here. + +use std::collections::HashMap; + +use crate::block::{Block, BlockEvent, BlockId, BlockKind, BlockPatch, Revision}; + +/// The engine-owned, authoritative ordered log of blocks. +#[derive(Default)] +pub struct BlockLog { + blocks: Vec<(BlockId, Revision, Block)>, + index: HashMap, + next_id: u64, +} + +impl BlockLog { + pub fn new() -> Self { + Self::default() + } + + /// Append a fresh, empty block of `kind`. Returns the new id and the event. + pub fn append(&mut self, kind: BlockKind) -> (BlockId, BlockEvent) { + let id = BlockId(self.next_id); + self.next_id += 1; + self.index.insert(id, self.blocks.len()); + self.blocks.push((id, Revision(0), Block::empty(kind))); + (id, BlockEvent::Append { id, kind }) + } + + /// Apply a patch to an existing block, bumping its revision. Returns the + /// event, or `None` if the id is unknown. + pub fn patch(&mut self, id: BlockId, patch: BlockPatch) -> Option { + let idx = *self.index.get(&id)?; + let (_, rev, block) = &mut self.blocks[idx]; + apply_patch(block, &patch); + rev.0 += 1; + let rev = *rev; + Some(BlockEvent::Update { id, rev, patch }) + } + + /// Mark a block complete. Returns the event, or `None` if unknown. + pub fn finalize(&mut self, id: BlockId) -> Option { + let idx = *self.index.get(&id)?; + let (_, rev, block) = &mut self.blocks[idx]; + set_complete(block, true); + rev.0 += 1; + Some(BlockEvent::Finalize { id, rev: *rev }) + } + + /// Drop `from` and everything after it. Returns the event, or `None` if unknown. + pub fn truncate(&mut self, from: BlockId) -> Option { + let idx = *self.index.get(&from)?; + for (id, _, _) in self.blocks.drain(idx..) { + self.index.remove(&id); + } + Some(BlockEvent::Truncate { from }) + } + + /// A point-in-time copy of the rendered blocks, in order. + pub fn snapshot(&self) -> Vec { + self.blocks.iter().map(|(_, _, b)| b.clone()).collect() + } + + /// The id of the last block, if any (used by the floor to extend it). + pub fn last_id(&self) -> Option { + self.blocks.last().map(|(id, _, _)| *id) + } + + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +/// A consumer's local mirror, kept current by replaying [`BlockEvent`]s. +#[derive(Default)] +pub struct BlockView { + blocks: Vec, + index: HashMap, + order: Vec, +} + +impl BlockView { + /// Seed from a snapshot. Subsequent ids must be replayed via [`Self::apply`]; + /// the snapshot path can't know ids, so a fresh view is normally built purely + /// from the event stream when ids matter. For render-only use, [`Self::blocks`] + /// is what you draw. + pub fn from_snapshot(blocks: Vec) -> Self { + Self { + blocks, + index: HashMap::new(), + order: Vec::new(), + } + } + + pub fn blocks(&self) -> &[Block] { + &self.blocks + } + + /// Replay one delta. Unknown ids on update/finalize are ignored (the view may + /// have been seeded from a snapshot that predates them). + pub fn apply(&mut self, event: &BlockEvent) { + match event { + BlockEvent::Append { id, kind } => { + self.index.insert(*id, self.blocks.len()); + self.order.push(*id); + self.blocks.push(Block::empty(*kind)); + } + BlockEvent::Update { id, patch, .. } => { + if let Some(&idx) = self.index.get(id) { + apply_patch(&mut self.blocks[idx], patch); + } + } + BlockEvent::Finalize { id, .. } => { + if let Some(&idx) = self.index.get(id) { + set_complete(&mut self.blocks[idx], true); + } + } + BlockEvent::Truncate { from } => { + if let Some(&idx) = self.index.get(from) { + for id in self.order.drain(idx..) { + self.index.remove(&id); + } + self.blocks.truncate(idx); + } + } + } + } +} + +fn apply_patch(block: &mut Block, patch: &BlockPatch) { + match (block, patch) { + (Block::Markdown { text, .. }, BlockPatch::AppendText(s)) => text.push_str(s), + (Block::Code { text, .. }, BlockPatch::AppendText(s)) => text.push_str(s), + (Block::Diff { unified, .. }, BlockPatch::AppendText(s)) => unified.push_str(s), + (Block::RawTerminal { screen }, BlockPatch::AppendText(s)) => screen.push_str(s), + (Block::Markdown { text, .. }, BlockPatch::ReplaceText(s)) => *text = s.clone(), + (Block::Code { text, .. }, BlockPatch::ReplaceText(s)) => *text = s.clone(), + (Block::Diff { unified, .. }, BlockPatch::ReplaceText(s)) => *unified = s.clone(), + (Block::RawTerminal { screen }, BlockPatch::ReplaceText(s)) => *screen = s.clone(), + (Block::Menu(menu), BlockPatch::MenuSelect(i)) => menu.selected = Some(*i), + (Block::Menu(menu), BlockPatch::MenuResolve(i)) => { + menu.state = crate::block::MenuState::Resolved { chosen: *i } + } + (Block::Input(input), BlockPatch::InputSubmit) => { + input.state = crate::block::InputState::Submitted + } + // Mismatched patch/block kinds are ignored — the producer never emits them. + _ => {} + } +} + +fn set_complete(block: &mut Block, value: bool) { + match block { + Block::Markdown { complete, .. } + | Block::Code { complete, .. } + | Block::Diff { complete, .. } => *complete = value, + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn append_patch_finalize_roundtrips_through_view() { + let mut log = BlockLog::new(); + let (id, e_append) = log.append(BlockKind::Markdown); + let e_patch = log + .patch(id, BlockPatch::AppendText("hello ".into())) + .unwrap(); + let e_patch2 = log + .patch(id, BlockPatch::AppendText("world".into())) + .unwrap(); + let e_final = log.finalize(id).unwrap(); + + let mut view = BlockView::default(); + for ev in [&e_append, &e_patch, &e_patch2, &e_final] { + view.apply(ev); + } + assert_eq!( + view.blocks(), + &[Block::Markdown { + text: "hello world".into(), + complete: true + }] + ); + // The authoritative snapshot agrees. + assert_eq!(log.snapshot(), view.blocks()); + } + + #[test] + fn truncate_drops_tail_in_both() { + let mut log = BlockLog::new(); + let (a, ea) = log.append(BlockKind::Markdown); + let (_b, eb) = log.append(BlockKind::Markdown); + let (_c, ec) = log.append(BlockKind::Markdown); + let et = log.truncate(a).unwrap(); + + let mut view = BlockView::default(); + for ev in [&ea, &eb, &ec, &et] { + view.apply(ev); + } + assert!(view.blocks().is_empty()); + assert!(log.is_empty()); + } + + #[test] + fn revision_bumps_on_each_patch() { + let mut log = BlockLog::new(); + let (id, _) = log.append(BlockKind::Markdown); + let BlockEvent::Update { rev: r1, .. } = + log.patch(id, BlockPatch::AppendText("a".into())).unwrap() + else { + panic!("expected update"); + }; + let BlockEvent::Update { rev: r2, .. } = + log.patch(id, BlockPatch::AppendText("b".into())).unwrap() + else { + panic!("expected update"); + }; + assert!(r2 > r1); + } +} diff --git a/crates/dd-client-session/src/transport.rs b/crates/dd-client-session/src/transport.rs new file mode 100644 index 0000000..e51691f --- /dev/null +++ b/crates/dd-client-session/src/transport.rs @@ -0,0 +1,61 @@ +//! Drives the Noise duplex for an attached session. +//! +//! Forwards plaintext input frames toward the peer and hands each decrypted +//! output frame to a sink callback, until the input side closes, a [`Outbound::Stop`] +//! is received, or the socket ends. This is deliberately platform-agnostic — no +//! termios, no stdin/stdout. The frontend owns terminal I/O and feeds this loop +//! over an [`mpsc`] channel; the engine (later phases) will sit on top, deriving +//! blocks from the output frames. + +use dd_client_core::NoiseConnection; +use tokio::sync::mpsc; + +/// A message the frontend sends toward the attached session. +#[derive(Debug)] +pub enum Outbound { + /// Plaintext bytes to encrypt and forward to the peer (e.g. keystrokes). + Bytes(Vec), + /// Forward these bytes, then stop the loop (e.g. EOF + disconnect). + BytesThenStop(Vec), + /// Stop the loop without sending anything (e.g. detach). + Stop, +} + +/// Run the duplex pump. `on_output` is invoked with each decrypted inbound +/// frame, in order. Returns when input closes, a [`Outbound::Stop`] arrives, or +/// the socket closes. +/// +/// `on_output` is synchronous by design: a raw-terminal frontend writes the +/// bytes straight to its tty inside the callback. Higher layers pass a callback +/// that feeds the derivation engine. +pub async fn run( + conn: NoiseConnection, + mut input: mpsc::Receiver, + mut on_output: F, +) -> anyhow::Result<()> +where + F: FnMut(&[u8]) + Send, +{ + let (mut writer, mut reader) = conn.split(); + loop { + tokio::select! { + msg = input.recv() => { + match msg { + None | Some(Outbound::Stop) => break, + Some(Outbound::Bytes(b)) => writer.send(&b).await?, + Some(Outbound::BytesThenStop(b)) => { + writer.send(&b).await?; + break; + } + } + } + frame = reader.recv() => { + match frame? { + None => break, + Some(plain) => on_output(&plain), + } + } + } + } + Ok(()) +} From 4b625dd2b228547a720d07357e5c64c6b0aef9f4 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 29 May 2026 19:07:11 +0000 Subject: [PATCH 2/7] Add client-side E2E history decryptor (Phase 4) dd-sessiond seals each transcript record to paired device pubkeys; replay returns ciphertext the enclave can't read. Add dd-client-session::history with open_record (recover the content key from our recipient stanza via X25519 + HKDF-SHA256, decrypt the record) and decrypt_replay (reconstruct the terminal byte stream, also handling the legacy plaintext bytes_b64 shape during rollout). Wire `dd-client replay` to decrypt with the device key and write the transcript to stdout. Tests seal exactly as dd/src/sessiond.rs::seal_record does, so they verify cross-repo wire compatibility (recipient opens, non-recipient gets None, multi-recipient, pty-stream reconstruction, legacy fallback). Pairs with devopsdefender/dd#268. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 26 +++ crates/dd-client-cli/src/main.rs | 12 +- crates/dd-client-session/Cargo.toml | 8 + crates/dd-client-session/src/history.rs | 242 ++++++++++++++++++++++++ crates/dd-client-session/src/lib.rs | 1 + 5 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 crates/dd-client-session/src/history.rs diff --git a/Cargo.lock b/Cargo.lock index 3dcadfd..f19d6c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -490,10 +490,18 @@ name = "dd-client-session" version = "0.1.0" dependencies = [ "anyhow", + "base64", + "chacha20poly1305", "dd-client-core", + "hex", + "hkdf", + "rand 0.8.6", + "serde", "serde_json", + "sha2", "tokio", "vt100", + "x25519-dalek", ] [[package]] @@ -704,6 +712,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index ad1181d..8917778 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -4,8 +4,8 @@ use anyhow::anyhow; use clap::{Args, Parser, Subcommand}; use dd_client_core::{ close_session, connect, create_session, enrollment_url, exec, list_recipes, list_sessions, - public_key_hex, replay_session, resize_session, session_id, ConnectionOptions, - CreateSessionRequest, ExecRequest, IntelTrustAuthority, QuoteVerification, + load_or_create_key, public_key_hex, replay_session, resize_session, session_id, + ConnectionOptions, CreateSessionRequest, ExecRequest, IntelTrustAuthority, QuoteVerification, }; use dd_client_session::ViewMode; @@ -144,8 +144,14 @@ async fn main() -> anyhow::Result<()> { print_json(create_session(&mut conn, &create_request(&args)).await?)?; } Command::Replay(args) => { + // Decrypt history client-side with the device key: the enclave seals + // each record to paired device pubkeys and cannot read it back. + let secret = load_or_create_key(&args.connect.key).await?; let mut conn = connect(&connection_options(args.connect)?).await?; - print_json(replay_session(&mut conn, &args.id).await?)?; + let response = replay_session(&mut conn, &args.id).await?; + let bytes = dd_client_session::history::decrypt_replay(&secret, &response)?; + use std::io::Write; + std::io::stdout().write_all(&bytes)?; } Command::Resize(args) => { let mut conn = connect(&connection_options(args.connect)?).await?; diff --git a/crates/dd-client-session/Cargo.toml b/crates/dd-client-session/Cargo.toml index 55d5549..a07913b 100644 --- a/crates/dd-client-session/Cargo.toml +++ b/crates/dd-client-session/Cargo.toml @@ -7,10 +7,18 @@ repository.workspace = true [dependencies] anyhow = "1" +base64 = "0.22" +chacha20poly1305 = "0.10" dd-client-core = { path = "../dd-client-core" } +hex = "0.4" +hkdf = "0.12" +serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tokio = { version = "1", features = ["macros", "rt", "sync"] } vt100 = "0.15" +x25519-dalek = { version = "2", features = ["static_secrets"] } [dev-dependencies] +rand = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/crates/dd-client-session/src/history.rs b/crates/dd-client-session/src/history.rs new file mode 100644 index 0000000..69dccbd --- /dev/null +++ b/crates/dd-client-session/src/history.rs @@ -0,0 +1,242 @@ +//! Client-side decryption of end-to-end-encrypted session history. +//! +//! `dd-sessiond` seals each transcript record to the paired device pubkeys and +//! `replay` returns the sealed lines — the enclave cannot read them back. This +//! module is the matching opener: with the device's X25519 secret, recover the +//! content key from whichever recipient stanza is ours, decrypt the record, and +//! reconstruct the terminal byte stream. +//! +//! Wire format (must match `dd/src/sessiond.rs::seal_record`): one compact-JSON +//! line per record — +//! ```json +//! {"v":2,"rcpts":[{"epk":"","n":"","wk":""}],"bn":"","body":""} +//! ``` +//! `body` = ChaCha20Poly1305(CEK, bn, serde(TranscriptRecord)); each stanza wraps +//! CEK to one device via ephemeral X25519 + HKDF-SHA256(info = +//! "dd-sessiond-e2e-v2" ‖ epk ‖ recipient_pubkey). + +use base64::Engine as _; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use hkdf::Hkdf; +use serde::Deserialize; +use serde_json::Value; +use sha2::Sha256; +use x25519_dalek::{PublicKey, StaticSecret}; + +const KDF_INFO_PREFIX: &[u8] = b"dd-sessiond-e2e-v2"; +const B64: base64::engine::general_purpose::GeneralPurpose = + base64::engine::general_purpose::STANDARD; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct TranscriptRecord { + pub ts: i64, + pub kind: String, + pub data_b64: String, +} + +#[derive(Deserialize)] +struct SealedLine { + v: u32, + rcpts: Vec, + bn: String, + body: String, +} + +#[derive(Deserialize)] +struct RecipientStanza { + epk: String, + n: String, + wk: String, +} + +/// Derive the per-recipient key-wrapping key — must match the server's +/// `derive_wrap_key`. +fn derive_wrap_key(shared: &[u8; 32], epk: &[u8; 32], rpk: &[u8; 32]) -> [u8; 32] { + let hk = Hkdf::::new(None, shared); + let mut info = Vec::with_capacity(KDF_INFO_PREFIX.len() + 64); + info.extend_from_slice(KDF_INFO_PREFIX); + info.extend_from_slice(epk); + info.extend_from_slice(rpk); + let mut out = [0u8; 32]; + hk.expand(&info, &mut out) + .expect("hkdf expand of 32 bytes never fails"); + out +} + +fn hex32(s: &str) -> anyhow::Result<[u8; 32]> { + let v = hex::decode(s)?; + let arr: [u8; 32] = v + .try_into() + .map_err(|_| anyhow::anyhow!("expected 32 bytes"))?; + Ok(arr) +} + +fn hex12(s: &str) -> anyhow::Result<[u8; 12]> { + let v = hex::decode(s)?; + let arr: [u8; 12] = v + .try_into() + .map_err(|_| anyhow::anyhow!("expected 12 bytes"))?; + Ok(arr) +} + +/// Open one sealed line with the device secret. Returns `Ok(None)` if this device +/// is not a recipient (no stanza decrypts), `Err` only on malformed input. +pub fn open_record( + device_secret: &StaticSecret, + line: &str, +) -> anyhow::Result> { + let sealed: SealedLine = serde_json::from_str(line)?; + if sealed.v != 2 { + anyhow::bail!("unsupported sealed-record version {}", sealed.v); + } + let device_pk = *PublicKey::from(device_secret).as_bytes(); + let bn = hex12(&sealed.bn)?; + let body = B64.decode(&sealed.body)?; + + for st in &sealed.rcpts { + let epk = hex32(&st.epk)?; + let n = hex12(&st.n)?; + let wk = B64.decode(&st.wk)?; + let shared = device_secret.diffie_hellman(&PublicKey::from(epk)); + let wrap_key = derive_wrap_key(shared.as_bytes(), &epk, &device_pk); + // Wrong recipient → AEAD tag mismatch → try the next stanza. + let Ok(cek) = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)) + .decrypt(Nonce::from_slice(&n), wk.as_ref()) + else { + continue; + }; + let cek: [u8; 32] = cek + .try_into() + .map_err(|_| anyhow::anyhow!("content key wrong length"))?; + let plain = ChaCha20Poly1305::new(Key::from_slice(&cek)) + .decrypt(Nonce::from_slice(&bn), body.as_ref()) + .map_err(|e| anyhow::anyhow!("decrypt record body: {e}"))?; + let record: TranscriptRecord = serde_json::from_slice(&plain)?; + return Ok(Some(record)); + } + Ok(None) +} + +/// Reconstruct the terminal byte stream from a `replay` response. Handles both +/// the v2 sealed `records` array (decrypted with `device_secret`) and the legacy +/// plaintext `bytes_b64` shape (older agents), so the client works across the +/// server rollout. +pub fn decrypt_replay(device_secret: &StaticSecret, response: &Value) -> anyhow::Result> { + if let Some(records) = response.get("records").and_then(Value::as_array) { + let mut out = Vec::new(); + for line in records.iter().filter_map(Value::as_str) { + let Some(record) = open_record(device_secret, line)? else { + continue; // not our recipient — skip + }; + if matches!(record.kind.as_str(), "pty" | "stdout" | "stderr") { + out.extend_from_slice(&B64.decode(&record.data_b64)?); + } + } + return Ok(out); + } + if let Some(b64) = response.get("bytes_b64").and_then(Value::as_str) { + return Ok(B64.decode(b64)?); // legacy plaintext replay + } + anyhow::bail!("replay response had neither `records` nor `bytes_b64`") +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::OsRng; + + /// Seal exactly as `dd/src/sessiond.rs::seal_record` does, so this is a + /// cross-repo wire-compatibility test, not just a self-consistency one. + fn seal_record(recipients: &[[u8; 32]], plain: &[u8]) -> String { + use rand::RngCore; + let mut rng = OsRng; + let mut cek = [0u8; 32]; + rng.fill_bytes(&mut cek); + let mut bn = [0u8; 12]; + rng.fill_bytes(&mut bn); + let body = ChaCha20Poly1305::new(Key::from_slice(&cek)) + .encrypt(Nonce::from_slice(&bn), plain) + .unwrap(); + + let mut rcpts = Vec::new(); + for r in recipients { + let mut e_bytes = [0u8; 32]; + rng.fill_bytes(&mut e_bytes); + let e_sk = StaticSecret::from(e_bytes); + let e_pk = PublicKey::from(&e_sk); + let shared = e_sk.diffie_hellman(&PublicKey::from(*r)); + let wrap_key = derive_wrap_key(shared.as_bytes(), e_pk.as_bytes(), r); + let mut n = [0u8; 12]; + rng.fill_bytes(&mut n); + let wk = ChaCha20Poly1305::new(Key::from_slice(&wrap_key)) + .encrypt(Nonce::from_slice(&n), cek.as_ref()) + .unwrap(); + rcpts.push(serde_json::json!({ + "epk": hex::encode(e_pk.as_bytes()), + "n": hex::encode(n), + "wk": B64.encode(wk), + })); + } + serde_json::json!({ + "v": 2, "rcpts": rcpts, "bn": hex::encode(bn), "body": B64.encode(body), + }) + .to_string() + } + + fn record_line(recipients: &[[u8; 32]], kind: &str, data: &[u8]) -> String { + let rec = serde_json::json!({ "ts": 1, "kind": kind, "data_b64": B64.encode(data) }); + seal_record(recipients, serde_json::to_string(&rec).unwrap().as_bytes()) + } + + fn device() -> (StaticSecret, [u8; 32]) { + let sk = StaticSecret::random_from_rng(OsRng); + let pk = *PublicKey::from(&sk).as_bytes(); + (sk, pk) + } + + #[test] + fn opens_record_for_recipient() { + let (sk, pk) = device(); + let line = record_line(&[pk], "pty", b"hello"); + let rec = open_record(&sk, &line).unwrap().expect("recipient"); + assert_eq!(rec.kind, "pty"); + assert_eq!(B64.decode(rec.data_b64).unwrap(), b"hello"); + } + + #[test] + fn non_recipient_gets_none() { + let (_sk_a, pk_a) = device(); + let (sk_b, _pk_b) = device(); // B is not a recipient + let line = record_line(&[pk_a], "pty", b"secret"); + assert!(open_record(&sk_b, &line).unwrap().is_none()); + } + + #[test] + fn multi_recipient_each_opens() { + let (sk_a, pk_a) = device(); + let (sk_b, pk_b) = device(); + let line = record_line(&[pk_a, pk_b], "pty", b"shared"); + assert!(open_record(&sk_a, &line).unwrap().is_some()); + assert!(open_record(&sk_b, &line).unwrap().is_some()); + } + + #[test] + fn decrypt_replay_reconstructs_pty_stream_and_skips_meta() { + let (sk, pk) = device(); + let records = vec![ + Value::String(record_line(&[pk], "meta", b"{...}")), + Value::String(record_line(&[pk], "pty", b"foo")), + Value::String(record_line(&[pk], "stdout", b"bar")), + ]; + let resp = serde_json::json!({ "id": "s", "version": 2, "records": records }); + assert_eq!(decrypt_replay(&sk, &resp).unwrap(), b"foobar"); + } + + #[test] + fn decrypt_replay_handles_legacy_plaintext() { + let (sk, _pk) = device(); + let resp = serde_json::json!({ "id": "s", "bytes_b64": B64.encode(b"legacy") }); + assert_eq!(decrypt_replay(&sk, &resp).unwrap(), b"legacy"); + } +} diff --git a/crates/dd-client-session/src/lib.rs b/crates/dd-client-session/src/lib.rs index 247b57b..6d6cd0c 100644 --- a/crates/dd-client-session/src/lib.rs +++ b/crates/dd-client-session/src/lib.rs @@ -13,6 +13,7 @@ pub mod block; pub mod derive; pub mod engine; +pub mod history; pub mod input; pub mod mode; pub mod stream; From b3050f12402c479cde7cb5608c1fa4d01321b15c Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 29 May 2026 20:32:29 +0000 Subject: [PATCH 3/7] UniFFI bindings + SwiftUI companion (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled C FFI with UniFFI: one Rust surface generates Swift (and Kotlin) bindings. Exposes keygen, a SessionHandle object (attach over Noise, blocks() snapshot, subscribe(BlockObserver), send_text, set_mode, close) and the block/mode DTOs. All interpretation stays in dd-client-session; the foreign side only renders blocks and reacts to a change callback. Everything is sync across the FFI — a shared multi-thread tokio runtime does the async work — so Swift never bridges Rust futures. Adds a uniffi-bindgen bin for generation. iOS app (apps/ios): SessionModel mirrors the block snapshot into SwiftUI on change; ContentView renders the structured document — markdown as markdown, menus as tappable buttons, code/diff monospaced — with a Watch/Interact/Raw picker and an input bar. README documents the bindgen + xcframework steps. The Rust FFI crate is compile/clippy/test-verified on Linux; binding generation and the Xcode build require the Apple toolchain (not run here). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 376 +++++++++++++++- apps/ios/DevOpsDefender/ContentView.swift | 187 +++++++- apps/ios/DevOpsDefender/SessionModel.swift | 106 +++++ apps/ios/README.md | 53 ++- crates/dd-client-ffi/Cargo.toml | 11 +- .../dd-client-ffi/src/bin/uniffi-bindgen.rs | 4 + crates/dd-client-ffi/src/lib.rs | 425 ++++++++++++++---- 7 files changed, 1037 insertions(+), 125 deletions(-) create mode 100644 apps/ios/DevOpsDefender/SessionModel.swift create mode 100644 crates/dd-client-ffi/src/bin/uniffi-bindgen.rs diff --git a/Cargo.lock b/Cargo.lock index f19d6c4..dbba543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,47 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -132,6 +173,24 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -168,6 +227,38 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -479,10 +570,14 @@ dependencies = [ name = "dd-client-ffi" version = "0.1.0" dependencies = [ + "anyhow", "dd-client-core", + "dd-client-session", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", + "uniffi", ] [[package]] @@ -590,6 +685,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -689,6 +793,23 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1106,6 +1227,28 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1118,6 +1261,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1221,6 +1374,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "poly1305" version = "0.8.0" @@ -1291,7 +1450,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1312,7 +1471,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1582,11 +1741,35 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -1710,10 +1893,16 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.12" @@ -1726,6 +1915,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "snow" version = "0.9.6" @@ -1842,13 +2037,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1970,6 +2194,15 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tower" version = "0.5.3" @@ -2055,7 +2288,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -2065,6 +2298,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2100,6 +2339,124 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -2308,6 +2665,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/apps/ios/DevOpsDefender/ContentView.swift b/apps/ios/DevOpsDefender/ContentView.swift index aa47ed6..188e707 100644 --- a/apps/ios/DevOpsDefender/ContentView.swift +++ b/apps/ios/DevOpsDefender/ContentView.swift @@ -1,24 +1,193 @@ import SwiftUI struct ContentView: View { + @StateObject private var model = SessionModel() + var body: some View { NavigationStack { - List { - Section("Pairing") { - LabeledContent("Device key", value: "Not generated") - Button("Generate enrollment URL") {} + if model.connected { + SessionView(model: model) + .navigationTitle("Session") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Disconnect") { model.disconnect() } + } + } + } else { + ConnectForm(model: model) + .navigationTitle("DevOps Defender") + } + } + } +} + +/// Connect form. `keyPath` defaults into the app's container; the device key is +/// created on first use and enrolled out-of-band via the CP `/admin/enroll` URL +/// (`keygen` returns it). +struct ConnectForm: View { + @ObservedObject var model: SessionModel + @State private var agentUrl = "https://" + @State private var sessionId = "" + @State private var insecure = false + + private var keyPath: String { + let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return dir.appendingPathComponent("noise.key").path + } + + var body: some View { + Form { + Section("Agent") { + TextField("Agent URL", text: $agentUrl) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Session ID", text: $sessionId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Toggle("Skip quote verification", isOn: $insecure) + } + Section { + Button(model.connecting ? "Connecting…" : "Connect") { + model.connect( + agentUrl: agentUrl, keyPath: keyPath, + sessionId: sessionId, insecure: insecure) } + .disabled(model.connecting || agentUrl.isEmpty || sessionId.isEmpty) + } + Section { Text(model.status).foregroundStyle(.secondary) } + } + } +} + +/// The session: a mode picker, the streamed block document, and (in Interact) +/// an input bar. +struct SessionView: View { + @ObservedObject var model: SessionModel + @State private var draft = "" - Section("Agents") { - ContentUnavailableView("No agents", systemImage: "server.rack") + var body: some View { + VStack(spacing: 0) { + Picker("Mode", selection: Binding(get: { model.mode }, set: model.setMode)) { + Text("Watch").tag(FfiMode.watch) + Text("Interact").tag(FfiMode.interact) + Text("Raw").tag(FfiMode.raw) + } + .pickerStyle(.segmented) + .padding(8) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(Array(model.blocks.enumerated()), id: \.offset) { idx, block in + BlockView(block: block, interactive: model.mode == .interact) { opt, i in + model.pick(option: opt, index: i) + } + .id(idx) + } + } + .padding(.horizontal) + } + .onChange(of: model.blocks.count) { _, count in + if count > 0 { proxy.scrollTo(count - 1, anchor: .bottom) } } } - .navigationTitle("DevOps Defender") + + if model.mode == .interact { + HStack { + TextField("Send to session…", text: $draft) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Send") { + model.send(draft + "\n") + draft = "" + } + .disabled(draft.isEmpty) + } + .padding(8) + } } } } -#Preview { - ContentView() +/// Renders one block. The whole point of the design: markdown reads as markdown, +/// menus are tappable, code/diffs are monospaced — not a terminal. +struct BlockView: View { + let block: FfiBlock + let interactive: Bool + let onPick: (FfiMenuOption, Int) -> Void + + var body: some View { + switch block { + case let .markdown(text, _): + Text((try? AttributedString(markdown: text)) ?? AttributedString(text)) + .frame(maxWidth: .infinity, alignment: .leading) + + case let .code(lang, text, _): + VStack(alignment: .leading, spacing: 2) { + if let lang { Text(lang).font(.caption2).foregroundStyle(.secondary) } + Text(text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + + case let .diff(unified, _): + DiffView(unified: unified) + + case let .menu(title, options, selected, resolved): + VStack(alignment: .leading, spacing: 6) { + if let title { Text(title).font(.headline) } + ForEach(Array(options.enumerated()), id: \.offset) { i, opt in + Button { onPick(opt, i) } label: { + HStack { + Image(systemName: selected == UInt32(i) + ? "largecircle.fill.circle" : "circle") + Text(opt.label) + Spacer() + } + } + .buttonStyle(.bordered) + .disabled(!interactive || resolved) + } + } + .padding(8) + .background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + + case let .input(prompt): + Text(prompt).italic().foregroundStyle(.secondary) + + case let .rawTerminal(screen): + Text(screen) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } } +struct DiffView: View { + let unified: String + var body: some View { + VStack(alignment: .leading, spacing: 0) { + let lines = unified.split(separator: "\n", omittingEmptySubsequences: false) + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(String(line)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(color(for: line.first)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(6) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 6)) + } + + private func color(for first: Character?) -> Color { + switch first { + case "+": return .green + case "-": return .red + case "@": return .purple + default: return .primary + } + } +} diff --git a/apps/ios/DevOpsDefender/SessionModel.swift b/apps/ios/DevOpsDefender/SessionModel.swift new file mode 100644 index 0000000..7542edb --- /dev/null +++ b/apps/ios/DevOpsDefender/SessionModel.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftUI + +// Types referenced here — `SessionHandle`, `BlockObserver`, `FfiBlock`, +// `FfiMode`, `FfiMenuOption`, `keygen`, `KeygenResult` — come from the +// UniFFI-generated `dd-client-ffi` bindings. Generate them with: +// +// cargo build -p dd-client-ffi --release # produces the staticlib +// cargo run -p uniffi-bindgen -- generate \ +// --library target/release/libdd_client_ffi.a --language swift --out-dir apps/ios/Generated +// +// then add Generated/*.swift + the static lib (as an xcframework) to the target. + +/// Observable wrapper around a live `SessionHandle`. All interpretation lives in +/// Rust; this just mirrors the block snapshot into SwiftUI on change. +@MainActor +final class SessionModel: ObservableObject { + @Published var blocks: [FfiBlock] = [] + @Published var mode: FfiMode = .watch + @Published var status: String = "Not connected" + @Published var connected = false + @Published var connecting = false + + private var handle: SessionHandle? + private var observer: ChangeObserver? + + func connect(agentUrl: String, keyPath: String, sessionId: String, insecure: Bool) { + connecting = true + status = "Connecting…" + Task.detached { [weak self] in + do { + // attach() blocks on the Noise handshake — off the main actor. + let handle = try SessionHandle.attach( + agentUrl: agentUrl, + keyPath: keyPath, + sessionId: sessionId, + insecureSkipQuoteVerify: insecure, + jwksUrl: "https://portal.trustauthority.intel.com/certs", + issuer: "https://portal.trustauthority.intel.com" + ) + await self?.onAttached(handle) + } catch { + await self?.onFailed(error) + } + } + } + + private func onAttached(_ handle: SessionHandle) { + self.handle = handle + let observer = ChangeObserver(model: self) + self.observer = observer + handle.subscribe(observer: observer) + self.mode = handle.mode() + self.connecting = false + self.connected = true + self.status = "Connected" + reload() + } + + private func onFailed(_ error: Error) { + self.connecting = false + self.status = "Failed: \(error)" + } + + func reload() { + blocks = handle?.blocks() ?? [] + } + + func setMode(_ newMode: FfiMode) { + handle?.setMode(mode: newMode) + mode = newMode + } + + /// Send text to the session (ignored by the engine in Watch — read-only). + func send(_ text: String) { + handle?.sendText(text: text) + } + + /// Pick a menu option: send its hotkey when known, else its 1-based number. + func pick(option: FfiMenuOption, index: Int) { + if let hotkey = option.hotkey { + send(hotkey) + } else { + send(String(index + 1)) + } + } + + func disconnect() { + handle?.close() + handle = nil + observer = nil + connected = false + status = "Disconnected" + blocks = [] + } +} + +/// Bridges the Rust `BlockObserver` callback onto the main actor. +final class ChangeObserver: BlockObserver { + weak var model: SessionModel? + init(model: SessionModel) { self.model = model } + + func onChanged() { + Task { @MainActor [weak model] in model?.reload() } + } +} diff --git a/apps/ios/README.md b/apps/ios/README.md index 8e12b9e..7330934 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -2,25 +2,52 @@ The iOS client should be a native SwiftUI app backed by the Rust client core. -Initial split: +Split: - SwiftUI owns screens, navigation, notifications, Keychain access, and iOS lifecycle. -- `dd-client-core` owns protocol behavior: pairing keys, quote verification, - direct agent Noise transport, session RPCs, and PTY bytes. -- `dd-client-ffi` exposes a C-compatible bridge that can be linked into an - Xcode target as a static library. +- `dd-client-session` owns interpretation: blocks, the floor/agent derivers, + view modes, and history decryption — shared verbatim with the CLI. +- `dd-client-core` owns protocol behavior: pairing keys, quote verification + (verify-only, no Intel account), Noise transport, session RPCs, PTY bytes. +- `dd-client-ffi` exposes all of the above over **UniFFI** (Swift + Kotlin + generated from one Rust surface) — no hand-written C. -First screen to build: +The app is a renderer for the structured chat document the engine produces: +`SessionHandle.blocks()` returns typed `FfiBlock`s, a `BlockObserver` fires on +change, and `setMode`/`sendText` drive Watch ⇄ Interact ⇄ Raw. No protocol, +crypto, or terminal-interpretation logic lives in Swift. See `SessionModel.swift` +and `ContentView.swift`. -1. Generate or load a device key from Keychain-backed storage. -2. Display the public key and CP enrollment URL. -3. Open the enrollment URL in an authenticated browser session. -4. After enrollment, list routed agents and connect directly to the selected - agent over Noise. +## Generating the UniFFI bindings -The iOS app should not embed a browser shell or PWA. It should be a native -client using the same core as the CLI. +The Swift in this folder references types (`SessionHandle`, `FfiBlock`, +`FfiMode`, `keygen`, …) emitted by UniFFI. Generate them before building: + +```bash +# from the repo root +cargo build -p dd-client-ffi --release +cargo run -p dd-client-ffi --bin uniffi-bindgen -- generate \ + --library target/release/libdd_client_ffi.dylib \ + --language swift --out-dir apps/ios/Generated +``` + +Then add `apps/ios/Generated/*.swift` to the target and link the Rust static +library as an `xcframework` (build `aarch64-apple-ios` + the simulator/macABI +triples and `xcodebuild -create-xcframework`). The generated `*.modulemap` +header path is wired via the xcframework. + +> The Rust FFI crate is compile/clippy/test-verified on Linux. The binding +> generation and the iOS build require the Apple toolchain (Xcode), which isn't +> available in CI here — run the steps above on macOS. + +First-run flow: + +1. Generate or load the device key (`keygen`), Keychain-backed. +2. Show the pubkey + CP enrollment URL; enroll in an authenticated browser. +3. After enrollment, attach to a session and render its block document. + +The iOS app does not embed a browser shell or PWA. macOS testing target: diff --git a/crates/dd-client-ffi/Cargo.toml b/crates/dd-client-ffi/Cargo.toml index 4fc6283..1d5abab 100644 --- a/crates/dd-client-ffi/Cargo.toml +++ b/crates/dd-client-ffi/Cargo.toml @@ -9,10 +9,17 @@ repository.workspace = true crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] +anyhow = "1" dd-client-core = { path = "../dd-client-core" } +dd-client-session = { path = "../dd-client-session" } serde_json = "1" -tokio = { version = "1", features = ["fs", "rt"] } +thiserror = "2" +tokio = { version = "1", features = ["fs", "rt-multi-thread", "sync", "macros"] } +uniffi = { version = "0.28", features = ["cli"] } + +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" [dev-dependencies] tempfile = "3" - diff --git a/crates/dd-client-ffi/src/bin/uniffi-bindgen.rs b/crates/dd-client-ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 0000000..8f8c1e0 --- /dev/null +++ b/crates/dd-client-ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,4 @@ +//! Binding generator entry point: `cargo run -p dd-client-ffi --bin uniffi-bindgen`. +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 9f2748d..1026796 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -1,96 +1,313 @@ -use std::ffi::{CStr, CString}; -use std::os::raw::c_char; +//! UniFFI bindings: one Rust surface, Swift + Kotlin generated from it. +//! +//! The companion app is a thin renderer over `dd-client-session`. All +//! interpretation (blocks, modes, attestation, history) stays here in Rust; the +//! foreign side gets a snapshot of typed blocks and a change callback, and sends +//! input / switches mode. No protocol or crypto logic crosses into Swift. +//! +//! Everything is synchronous across the FFI (a shared multi-thread tokio runtime +//! does the async work internally), so Swift never has to bridge Rust futures. +//! +//! NOTE: this crate is compile-verified on Linux. The generated Swift bindings +//! and the iOS app are built with the Apple toolchain (`uniffi-bindgen generate +//! --library --language swift`), which isn't available in this +//! environment — see apps/ios. + use std::path::Path; +use std::sync::{Mutex, OnceLock}; -#[no_mangle] -pub extern "C" fn dd_client_keygen( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> *mut c_char { - let result = keygen_response(key_path, cp_url, label); - into_c_string(result) -} - -#[no_mangle] -/// # Safety -/// -/// `value` must be a pointer returned by this library, and it must not have -/// already been freed. -pub unsafe extern "C" fn dd_client_string_free(value: *mut c_char) { - if value.is_null() { - return; - } - let _ = unsafe { CString::from_raw(value) }; -} - -fn keygen_response( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> serde_json::Value { - match keygen(key_path, cp_url, label) { - Ok(value) => value, - Err(error) => serde_json::json!({ - "ok": false, - "error": error, - }), - } +use dd_client_core::{connect, ConnectionOptions, IntelTrustAuthority, QuoteVerification}; +use dd_client_session::block::{Block, MenuState as SMenuState}; +use dd_client_session::transport::{self, Outbound}; +use dd_client_session::{SessionEngine, ViewMode}; +use tokio::runtime::Runtime; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +uniffi::setup_scaffolding!(); + +/// Shared multi-thread runtime: connect handshakes block on it, the pump and the +/// observer-drain run on it. +fn rt() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("build tokio runtime") + }) +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum FfiError { + #[error("{0}")] + Message(String), } -fn keygen( - key_path: *const c_char, - cp_url: *const c_char, - label: *const c_char, -) -> Result { - let key_path = required_c_string(key_path, "key_path")?; - let cp_url = optional_c_string(cp_url)?; - let label = optional_c_string(label)?; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| e.to_string())?; - let pubkey_hex = runtime +fn err(e: impl std::fmt::Display) -> FfiError { + FfiError::Message(e.to_string()) +} + +#[derive(uniffi::Record)] +pub struct KeygenResult { + pub pubkey_hex: String, + pub enrollment_url: Option, +} + +/// Generate (or load) the device key and return its pubkey, plus the CP +/// enrollment URL when `cp_url` + `label` are given. +#[uniffi::export] +pub fn keygen( + key_path: String, + cp_url: Option, + label: Option, +) -> Result { + let pubkey_hex = rt() .block_on(dd_client_core::public_key_hex(Path::new(&key_path))) - .map_err(|e| e.to_string())?; + .map_err(err)?; let enrollment_url = match (cp_url.as_deref(), label.as_deref()) { - (Some(cp_url), Some(label)) => { - Some(dd_client_core::enrollment_url(cp_url, &pubkey_hex, label)) - } + (Some(cp), Some(l)) => Some(dd_client_core::enrollment_url(cp, &pubkey_hex, l)), _ => None, }; + Ok(KeygenResult { + pubkey_hex, + enrollment_url, + }) +} - Ok(serde_json::json!({ - "ok": true, - "pubkey_hex": pubkey_hex, - "enrollment_url": enrollment_url, - })) +#[derive(Clone, Copy, uniffi::Enum)] +pub enum FfiMode { + Watch, + Interact, + Raw, } -fn required_c_string(ptr: *const c_char, name: &str) -> Result { - optional_c_string(ptr)?.ok_or_else(|| format!("{name} is required")) +impl From for ViewMode { + fn from(m: FfiMode) -> Self { + match m { + FfiMode::Watch => ViewMode::Watch, + FfiMode::Interact => ViewMode::Interact, + FfiMode::Raw => ViewMode::Raw, + } + } +} +impl From for FfiMode { + fn from(m: ViewMode) -> Self { + match m { + ViewMode::Watch => FfiMode::Watch, + ViewMode::Interact => FfiMode::Interact, + ViewMode::Raw => FfiMode::Raw, + } + } +} + +#[derive(uniffi::Record)] +pub struct FfiMenuOption { + pub label: String, + pub hotkey: Option, +} + +#[derive(uniffi::Enum)] +pub enum FfiBlock { + Markdown { + text: String, + complete: bool, + }, + Code { + lang: Option, + text: String, + complete: bool, + }, + Diff { + unified: String, + complete: bool, + }, + Menu { + title: Option, + options: Vec, + selected: Option, + resolved: bool, + }, + Input { + prompt: String, + }, + RawTerminal { + screen: String, + }, } -fn optional_c_string(ptr: *const c_char) -> Result, String> { - if ptr.is_null() { - return Ok(None); +impl From<&Block> for FfiBlock { + fn from(b: &Block) -> Self { + match b { + Block::Markdown { text, complete } => FfiBlock::Markdown { + text: text.clone(), + complete: *complete, + }, + Block::Code { + lang, + text, + complete, + } => FfiBlock::Code { + lang: lang.clone(), + text: text.clone(), + complete: *complete, + }, + Block::Diff { unified, complete } => FfiBlock::Diff { + unified: unified.clone(), + complete: *complete, + }, + Block::Menu(menu) => FfiBlock::Menu { + title: menu.title.clone(), + options: menu + .options + .iter() + .map(|o| FfiMenuOption { + label: o.label.clone(), + hotkey: o.hotkey.map(|c| c.to_string()), + }) + .collect(), + selected: menu.selected.map(|s| s as u32), + resolved: matches!(menu.state, SMenuState::Resolved { .. }), + }, + Block::Input(input) => FfiBlock::Input { + prompt: input.prompt.clone(), + }, + Block::RawTerminal { screen } => FfiBlock::RawTerminal { + screen: screen.clone(), + }, + } } - let s = unsafe { CStr::from_ptr(ptr) } - .to_str() - .map_err(|e| e.to_string())? - .to_owned(); - Ok(Some(s)) -} - -fn into_c_string(value: serde_json::Value) -> *mut c_char { - let text = serde_json::to_string(&value) - .unwrap_or_else(|e| format!(r#"{{"ok":false,"error":"serialize response: {e}"}}"#)); - CString::new(text) - .unwrap_or_else(|_| { - CString::new(r#"{"ok":false,"error":"response contained nul"}"#).unwrap() +} + +/// Foreign-implemented: called whenever the block document changes. The app +/// re-reads [`SessionHandle::blocks`] and re-renders. +#[uniffi::export(callback_interface)] +pub trait BlockObserver: Send + Sync { + fn on_changed(&self); +} + +/// A live attached session. Holds the engine + the pump driving it; the foreign +/// side renders [`Self::blocks`] and reacts to a [`BlockObserver`]. +#[derive(uniffi::Object)] +pub struct SessionHandle { + engine: SessionEngine, + input: mpsc::Sender, + mode: Mutex, + pump: Mutex>>, + drain: Mutex>>, +} + +#[uniffi::export] +impl SessionHandle { + /// Connect to `agent_url`, attach to `session_id` (read-only posture), and + /// start deriving blocks. Verifies the agent's served ITA token against the + /// public JWKS unless `insecure_skip_quote_verify`. + #[uniffi::constructor] + pub fn attach( + agent_url: String, + key_path: String, + session_id: String, + insecure_skip_quote_verify: bool, + jwks_url: String, + issuer: String, + ) -> Result, FfiError> { + let quote_verification = if insecure_skip_quote_verify { + QuoteVerification::InsecureSkip + } else { + QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { jwks_url, issuer }) + }; + let opts = ConnectionOptions { + agent_url, + key_path: key_path.into(), + quote_verification, + }; + rt().block_on(async move { + let mut conn = connect(&opts).await.map_err(err)?; + let ack = conn + .call(serde_json::json!({ + "method": "shell.attach_session", + "id": session_id, + "tail": true, + "readonly": true, + })) + .await + .map_err(err)?; + if ack.get("error").is_some() { + return Err(FfiError::Message(format!("attach failed: {ack}"))); + } + let engine = SessionEngine::new(); + let (input, in_rx) = mpsc::channel::(256); + let pump_engine = engine.clone(); + let pump = rt().spawn(async move { + let _ = transport::run(conn, in_rx, |bytes| pump_engine.feed_output(bytes)).await; + pump_engine.finish(); + }); + Ok(std::sync::Arc::new(SessionHandle { + engine, + input, + mode: Mutex::new(ViewMode::Watch), + pump: Mutex::new(Some(pump)), + drain: Mutex::new(None), + })) }) - .into_raw() + } + + /// Current block document. + pub fn blocks(&self) -> Vec { + self.engine.snapshot().iter().map(FfiBlock::from).collect() + } + + /// Register a change observer. Replaces any previous one. + pub fn subscribe(&self, observer: Box) { + let (_snapshot, mut rx) = self.engine.subscribe(); + let handle = rt().spawn(async move { + // Exits when the broadcast closes (Err(Closed) doesn't match). + while let Ok(_) | Err(RecvError::Lagged(_)) = rx.recv().await { + observer.on_changed(); + } + }); + if let Some(old) = self.drain.lock().expect("drain lock").replace(handle) { + old.abort(); + } + } + + /// Send text to the session (dropped in Watch — read-only). + pub fn send_text(&self, text: String) { + if *self.mode.lock().expect("mode lock") == ViewMode::Watch { + return; + } + let _ = self.input.try_send(Outbound::Bytes(text.into_bytes())); + } + + pub fn mode(&self) -> FfiMode { + (*self.mode.lock().expect("mode lock")).into() + } + + pub fn set_mode(&self, mode: FfiMode) { + *self.mode.lock().expect("mode lock") = mode.into(); + } + + /// Stop the session (the remote session stays alive — this just detaches). + pub fn close(&self) { + if let Some(h) = self.drain.lock().expect("drain lock").take() { + h.abort(); + } + if let Some(h) = self.pump.lock().expect("pump lock").take() { + h.abort(); + } + } +} + +impl Drop for SessionHandle { + fn drop(&mut self) { + if let Some(h) = self.drain.get_mut().expect("drain lock").take() { + h.abort(); + } + if let Some(h) = self.pump.get_mut().expect("pump lock").take() { + h.abort(); + } + } } #[cfg(test)] @@ -98,30 +315,46 @@ mod tests { use super::*; #[test] - fn keygen_returns_enrollment_url() { + fn keygen_returns_pubkey_and_enrollment_url() { let dir = tempfile::tempdir().unwrap(); - let key_path = - CString::new(dir.path().join("noise.key").to_string_lossy().as_ref()).unwrap(); - let cp_url = CString::new("https://cp.example.com").unwrap(); - let label = CString::new("ios phone").unwrap(); - - let value = keygen_response(key_path.as_ptr(), cp_url.as_ptr(), label.as_ptr()); - - assert_eq!(value["ok"], true); - assert!(value["pubkey_hex"].as_str().unwrap().len() == 64); + let key = dir.path().join("noise.key").to_string_lossy().into_owned(); + let out = keygen( + key, + Some("https://cp.example.com".into()), + Some("ios phone".into()), + ) + .unwrap(); + assert_eq!(out.pubkey_hex.len(), 64); assert_eq!( - value["enrollment_url"], - "https://cp.example.com/admin/enroll?pubkey=".to_string() - + value["pubkey_hex"].as_str().unwrap() - + "&label=ios%20phone" + out.enrollment_url.unwrap(), + format!( + "https://cp.example.com/admin/enroll?pubkey={}&label=ios%20phone", + out.pubkey_hex + ) ); } #[test] - fn keygen_rejects_missing_key_path() { - let value = keygen_response(std::ptr::null(), std::ptr::null(), std::ptr::null()); + fn keygen_without_cp_has_no_url() { + let dir = tempfile::tempdir().unwrap(); + let key = dir.path().join("k.key").to_string_lossy().into_owned(); + let out = keygen(key, None, None).unwrap(); + assert_eq!(out.pubkey_hex.len(), 64); + assert!(out.enrollment_url.is_none()); + } - assert_eq!(value["ok"], false); - assert!(value["error"].as_str().unwrap().contains("key_path")); + #[test] + fn ffi_block_maps_from_session_block() { + let b = Block::Markdown { + text: "hi".into(), + complete: true, + }; + match FfiBlock::from(&b) { + FfiBlock::Markdown { text, complete } => { + assert_eq!(text, "hi"); + assert!(complete); + } + _ => panic!("wrong variant"), + } } } From 2580d04f3aa9c6146d942fec5574eb732a5a85ae Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 29 May 2026 23:42:09 +0000 Subject: [PATCH 4/7] Client: pin agent measurement (MRTD/TCB) in verify path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend QuoteVerification::IntelTrustAuthority with expected_mrtds + expected_tcb. After verifying the ITA token + report_data binding, verify_measurement checks the MRTD is in the allowlist (and TCB matches) — so the client confirms not just "a genuine TDX enclave" but "running the code we pinned". Unpinned = warn (don't fail). CLI: --expected-mrtd (repeatable/comma-sep, DD_EXPECTED_MRTD) + --expected-tcb. The pin must come from a source independent of the agent. Pairs with devopsdefender/dd#. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/dd-client-cli/src/main.rs | 18 +++++++++ crates/dd-client-core/src/lib.rs | 65 +++++++++++++++++++++++++++++++- crates/dd-client-ffi/src/lib.rs | 9 ++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/dd-client-cli/src/main.rs b/crates/dd-client-cli/src/main.rs index 8917778..6d7d95d 100644 --- a/crates/dd-client-cli/src/main.rs +++ b/crates/dd-client-cli/src/main.rs @@ -66,6 +66,17 @@ struct ConnectArgs { ita_jwks_url: String, #[arg(long, env = "DD_ITA_ISSUER", default_value = DEFAULT_ITA_ISSUER)] ita_issuer: String, + /// Pin the agent measurement: accepted MRTD(s), hex (repeatable / comma-sep). + /// Unset = unpinned (warns). Source this from a trusted pin, not the agent. + #[arg( + long = "expected-mrtd", + env = "DD_EXPECTED_MRTD", + value_delimiter = ',' + )] + expected_mrtd: Vec, + /// Required TCB status when an MRTD is pinned (e.g. "UpToDate"). + #[arg(long, env = "DD_EXPECTED_TCB")] + expected_tcb: Option, /// Structured deriver: "floor" (any TUI) or "claude" (Claude Code stream-json). #[arg(long, default_value = "floor")] adapter: String, @@ -210,6 +221,13 @@ fn connection_options(args: ConnectArgs) -> anyhow::Result { QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { jwks_url: args.ita_jwks_url, issuer: args.ita_issuer, + expected_mrtds: args + .expected_mrtd + .iter() + .map(|m| m.trim().to_lowercase()) + .filter(|m| !m.is_empty()) + .collect(), + expected_tcb: args.expected_tcb, }) }; Ok(ConnectionOptions { diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index 289ae75..a94c70c 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -28,6 +28,12 @@ pub struct IntelTrustAuthority { /// the agent mints the token; the client just checks the signature. pub jwks_url: String, pub issuer: String, + /// Expected MRTDs (lowercase hex), any-of. Empty = measurement unpinned + /// (verifies genuineness but not *which code* — warns). Pin to a value from a + /// source independent of the agent (committed pin / signed release manifest). + pub expected_mrtds: Vec, + /// Required TCB status when pinned (e.g. "UpToDate"). + pub expected_tcb: Option, } #[derive(Debug, Clone)] @@ -391,7 +397,34 @@ async fn verify_quote_binding( .report_data .as_deref() .ok_or_else(|| anyhow!("ITA token missing attester_held_data/report_data"))?; - verify_report_data(report_data, pubkey) + verify_report_data(report_data, pubkey)?; + verify_measurement(&claims, config) +} + +/// Pin the enclave measurement: attestation proves a genuine TDX VM, but only +/// matching the MRTD proves it's running *our* code. Unpinned ⇒ warn (don't fail). +fn verify_measurement(claims: &ita::Claims, config: &IntelTrustAuthority) -> anyhow::Result<()> { + if config.expected_mrtds.is_empty() { + eprintln!( + "warning: agent measurement is unpinned (no --expected-mrtd); attestation proves a \ + genuine TDX enclave but not which code it runs" + ); + return Ok(()); + } + let mrtd = claims.mrtd.as_deref().unwrap_or("").to_lowercase(); + if !config.expected_mrtds.contains(&mrtd) { + anyhow::bail!( + "agent MRTD {} not in expected allowlist", + if mrtd.is_empty() { "" } else { &mrtd } + ); + } + if let Some(want) = &config.expected_tcb { + let got = claims.tcb_status.as_deref().unwrap_or(""); + if got != want { + anyhow::bail!("agent TCB status {got:?} != expected {want:?}"); + } + } + Ok(()) } fn verify_report_data(report_data: &str, pubkey: &[u8; 32]) -> anyhow::Result<()> { @@ -517,4 +550,34 @@ mod tests { let encoded = base64::engine::general_purpose::STANDARD.encode(report); verify_report_data(&encoded, &pubkey).unwrap(); } + + fn ita_config(mrtds: &[&str], tcb: Option<&str>) -> IntelTrustAuthority { + IntelTrustAuthority { + jwks_url: String::new(), + issuer: String::new(), + expected_mrtds: mrtds.iter().map(|s| s.to_string()).collect(), + expected_tcb: tcb.map(String::from), + } + } + + fn claims(mrtd: &str, tcb: &str) -> ita::Claims { + ita::Claims { + mrtd: Some(mrtd.into()), + tcb_status: Some(tcb.into()), + ..Default::default() + } + } + + #[test] + fn measurement_unpinned_warns_but_passes() { + assert!(verify_measurement(&claims("aa", "OutOfDate"), &ita_config(&[], None)).is_ok()); + } + + #[test] + fn measurement_pinned_accepts_match_rejects_others() { + let cfg = ita_config(&["aa", "bb"], Some("UpToDate")); + assert!(verify_measurement(&claims("bb", "UpToDate"), &cfg).is_ok()); + assert!(verify_measurement(&claims("cc", "UpToDate"), &cfg).is_err()); // wrong mrtd + assert!(verify_measurement(&claims("aa", "OutOfDate"), &cfg).is_err()); // bad tcb + } } diff --git a/crates/dd-client-ffi/src/lib.rs b/crates/dd-client-ffi/src/lib.rs index 1026796..4d01a6b 100644 --- a/crates/dd-client-ffi/src/lib.rs +++ b/crates/dd-client-ffi/src/lib.rs @@ -215,7 +215,14 @@ impl SessionHandle { let quote_verification = if insecure_skip_quote_verify { QuoteVerification::InsecureSkip } else { - QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { jwks_url, issuer }) + QuoteVerification::IntelTrustAuthority(IntelTrustAuthority { + jwks_url, + issuer, + // Measurement pinning on mobile is a follow-up (needs a trusted + // pin source); unpinned for now (warns, still verifies genuineness). + expected_mrtds: Vec::new(), + expected_tcb: None, + }) }; let opts = ConnectionOptions { agent_url, From 334f7b641ef5b344f2110330d47df388d84fbf65 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sat, 30 May 2026 11:46:38 +0000 Subject: [PATCH 5/7] Surface server error detail on session create The Noise gateway wraps upstream failures as {error, detail}; the client printed only "shell_failed" and dropped the detail that says why (e.g. "unknown recipe: codex"). Include the detail in the bailed error message. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/dd-client-core/src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/dd-client-core/src/lib.rs b/crates/dd-client-core/src/lib.rs index a94c70c..d2d6ef9 100644 --- a/crates/dd-client-core/src/lib.rs +++ b/crates/dd-client-core/src/lib.rs @@ -259,7 +259,16 @@ pub async fn exec(conn: &mut NoiseConnection, request: &ExecRequest) -> anyhow:: pub fn session_id(value: &Value) -> anyhow::Result { if let Some(error) = value.get("error") { - anyhow::bail!("create failed: {error}"); + // The Noise gateway wraps upstream failures as {error, detail}; the detail + // carries the real cause (e.g. "unknown recipe: codex"). Surface both. + let msg = error + .as_str() + .map(String::from) + .unwrap_or_else(|| error.to_string()); + match value.get("detail").and_then(Value::as_str) { + Some(detail) => anyhow::bail!("create failed: {msg}: {detail}"), + None => anyhow::bail!("create failed: {msg}"), + } } value .get("id") From 4466e99413114ddc2ed35164bb43df708a43ccf3 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sat, 30 May 2026 11:52:43 +0000 Subject: [PATCH 6/7] Render the vt100 grid for alt-screen apps in Watch/Interact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-screen TUIs (Codex, vim, …) paint a grid with absolute cursor moves; the line-oriented floor mangles them (words run together, spinner litter). The engine already keeps a faithful vt100 ScreenSnapshot with an .alternate flag — render that grid (preserving column spacing, reverse-video) in the structured views when the app is on the alternate screen, instead of the floor blocks. Plain scrolling output still uses the structured block view. Title shows screen vs blocks. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/dd-client-cli/src/session_ui.rs | 61 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/crates/dd-client-cli/src/session_ui.rs b/crates/dd-client-cli/src/session_ui.rs index 1b90ebf..9d665c4 100644 --- a/crates/dd-client-cli/src/session_ui.rs +++ b/crates/dd-client-cli/src/session_ui.rs @@ -18,7 +18,7 @@ use std::time::Duration; use anyhow::anyhow; use dd_client_core::NoiseConnection; use dd_client_session::block::Block as SBlock; -use dd_client_session::derive::ClaudeCodeAdapter; +use dd_client_session::derive::{ClaudeCodeAdapter, ScreenSnapshot}; use dd_client_session::input::{ChordParser, RawAction}; use dd_client_session::transport::{self, Outbound}; use dd_client_session::{SessionEngine, ViewMode}; @@ -141,9 +141,18 @@ async fn run_structured( None }; - if let Err(e) = - terminal.draw(|f| draw(f, current, &blocks, menu.as_ref(), &mut scroll, follow)) - { + let screen = engine.screen_snapshot(); + if let Err(e) = terminal.draw(|f| { + draw( + f, + current, + &blocks, + &screen, + menu.as_ref(), + &mut scroll, + follow, + ) + }) { break Err(e.into()); } @@ -317,10 +326,12 @@ fn key_to_bytes(k: &KeyEvent) -> Option> { // ── Rendering ───────────────────────────────────────────────────────────── +#[allow(clippy::too_many_arguments)] fn draw( f: &mut Frame, mode: ViewMode, blocks: &[SBlock], + screen: &ScreenSnapshot, menu: Option<&dd_client_session::block::Menu>, scroll: &mut u16, follow: bool, @@ -335,11 +346,21 @@ fn draw( .split(area); let body: Rect = rows[0]; - let text = render_blocks(blocks); + // Full-screen (alt-screen) apps like Codex paint a grid with absolute cursor + // moves; the line-oriented floor mangles them. Render the faithful vt100 grid + // instead. Plain scrolling output keeps the structured block view. + let on_screen = screen.alternate; + let text = if on_screen { + render_screen(screen) + } else { + render_blocks(blocks) + }; let total = text.lines.len() as u16; let inner_h = body.height.saturating_sub(2); let max_scroll = total.saturating_sub(inner_h); - if follow || *scroll > max_scroll { + if on_screen { + *scroll = 0; // the grid is the viewport — no scrollback + } else if follow || *scroll > max_scroll { *scroll = max_scroll; } @@ -348,7 +369,8 @@ fn draw( } else { "controlled" }; - let title = format!(" {} ({integrity}) — {total} lines ", mode.label()); + let kind = if on_screen { "screen" } else { "blocks" }; + let title = format!(" {} ({integrity}) — {kind}, {total} lines ", mode.label()); let para = Paragraph::new(text) .wrap(Wrap { trim: false }) .scroll((*scroll, 0)) @@ -398,6 +420,31 @@ fn render_menu(menu: &dd_client_session::block::Menu) -> Paragraph<'static> { ) } +/// Render the vt100 grid faithfully — preserves the column spacing alt-screen +/// TUIs depend on. Reverse-video cells (selected menu rows) keep their highlight. +fn render_screen(screen: &ScreenSnapshot) -> Text<'static> { + let last = screen + .rows + .iter() + .rposition(|r| !r.text.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(0); + let lines = screen.rows[..last] + .iter() + .map(|r| { + if r.inverse { + Line::styled( + r.text.clone(), + Style::default().add_modifier(Modifier::REVERSED), + ) + } else { + Line::raw(r.text.clone()) + } + }) + .collect::>(); + Text::from(lines) +} + fn render_blocks(blocks: &[SBlock]) -> Text<'static> { let mut lines: Vec = Vec::new(); for block in blocks { From 31c9db2e0239e8e5bd124d1e9eff9b9c4d52edc9 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Sat, 30 May 2026 12:09:29 +0000 Subject: [PATCH 7/7] Read report_data from tdx_report_data claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intel TDX attestation tokens carry the quote's report_data in the tdx_report_data claim; attester_held_data is only present when held-data is submitted at mint time (which the agent does not do). The client's report_data binding check was reading only attester_held_data, so keyless verification failed with "ITA token missing report_data" — caught the first time the binding check ran against a real token (prior runs used --insecure-skip). Fall back to tdx_report_data. Verified end-to-end: keyless verify against the prod agent (no Intel account). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/dd-client-core/src/ita.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/dd-client-core/src/ita.rs b/crates/dd-client-core/src/ita.rs index c456a71..ea4858d 100644 --- a/crates/dd-client-core/src/ita.rs +++ b/crates/dd-client-core/src/ita.rs @@ -49,7 +49,9 @@ impl Claims { attester_type: get("attester_type"), mrtd: get("tdx_mrtd"), mrsigner: get("tdx_mrsigner"), - report_data: get("attester_held_data"), + // Intel TDX tokens carry the quote's report_data as `tdx_report_data`; + // `attester_held_data` only appears if held-data was submitted at mint. + report_data: get("attester_held_data").or_else(|| get("tdx_report_data")), extra: v, } }