From dd86330ecafc6c4666732069edfc22ef54be3eab Mon Sep 17 00:00:00 2001
From: Alex Newman <806363+posix4e@users.noreply.github.com>
Date: Sun, 10 May 2026 11:02:38 +0000
Subject: [PATCH 01/13] Add mobile prompt inbox for shell
---
src/shell.rs | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 149 insertions(+), 2 deletions(-)
diff --git a/src/shell.rs b/src/shell.rs
index 87d413c..f1548ef 100644
--- a/src/shell.rs
+++ b/src/shell.rs
@@ -1462,6 +1462,14 @@ main { max-width:none; padding:0; height:100dvh; display:grid; grid-template-col
.notify-item .notify-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:700; }
.notify-item .notify-time { color:#8791a5; font-size:10px; font-weight:400; white-space:nowrap; }
.notify-item .notify-body { margin-top:3px; color:#8791a5; font-size:11px; line-height:1.4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.prompt-feed { display:flex; flex-direction:column; gap:8px; margin:0 0 12px; }
+.prompt-card { background:#171c29; border:1px solid #7aa2f7; border-radius:7px; padding:10px; min-width:0; box-shadow:0 0 0 1px #1d3358 inset; }
+.prompt-card .prompt-head { display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:800; }
+.prompt-card .prompt-meta { color:#8791a5; font-size:10px; white-space:nowrap; }
+.prompt-card .prompt-body { margin-top:6px; color:#d7deea; font-size:12px; line-height:1.45; overflow-wrap:anywhere; }
+.prompt-actions { display:flex; flex-wrap:wrap; gap:6px; margin-top:9px; }
+.prompt-actions button { flex:1 1 64px; min-width:0; padding:8px 9px; font-size:12px; }
+.prompt-actions button.dismiss { flex:0 0 auto; color:#8791a5; background:#171c29; border:1px solid #2b3242; }
.notify-badge { display:none; min-width:18px; height:18px; border-radius:999px; background:#7aa2f7; color:#05070a; font-size:11px; font-weight:800; align-items:center; justify-content:center; padding:0 5px; }
.notify-badge.active { display:inline-flex; }
.toast-stack { position:fixed; top:60px; right:14px; z-index:50; display:flex; flex-direction:column; gap:8px; width:min(340px, calc(100vw - 28px)); pointer-events:none; }
@@ -1511,6 +1519,8 @@ button.secondary { background:#252a36; color:#d7deea; }
System
+
Prompt inbox
+
Notifications
@@ -1592,6 +1602,9 @@ let cachedWorkloads = [];
let activePanel = "system";
let notifications = loadNotificationHistory();
let unreadNotifications = 0;
+let promptRequests = loadPromptHistory();
+let promptScanBuffer = "";
+let lastPromptKey = "";
const decoder = new TextDecoder();
let serviceWorkerReady = null;
installPwaMetadata();
@@ -1612,6 +1625,7 @@ async function refresh() {
api("/api/workloads").catch(() => [])
]);
renderSystem(system);
+ renderPromptFeed();
renderNotificationFeed();
cachedRecipes = recipes;
renderRecipes();
@@ -1803,11 +1817,11 @@ async function attach(id) {
};
ws.onmessage = ev => {
if (typeof ev.data === "string") {
- scanNotifications(ev.data);
+ scanTerminalOutput(ev.data);
term.write(ev.data);
} else {
const bytes = new Uint8Array(ev.data);
- scanNotifications(bytes);
+ scanTerminalOutput(bytes);
term.write(bytes);
}
};
@@ -2001,6 +2015,10 @@ async function sendResize() {
body: JSON.stringify({cols: term.cols, rows: term.rows})
}).catch(() => {});
}
+function scanTerminalOutput(data) {
+ const text = scanNotifications(data);
+ scanPromptRequests(text);
+}
function scanNotifications(data) {
const text = typeof data === "string" ? data : decoder.decode(data, {stream:true});
oscBuffer += text;
@@ -2014,6 +2032,124 @@ function scanNotifications(data) {
}
if (consumed > 0) oscBuffer = oscBuffer.slice(consumed);
if (oscBuffer.length > 8192) oscBuffer = oscBuffer.slice(-1024);
+ return text;
+}
+function scanPromptRequests(text) {
+ if (!text) return;
+ promptScanBuffer = stripAnsi(promptScanBuffer + text).replace(/\r/g, "\n");
+ if (promptScanBuffer.length > 12000) promptScanBuffer = promptScanBuffer.slice(-8000);
+ const lines = promptScanBuffer.split("\n").map(l => l.trim()).filter(Boolean);
+ const recent = lines.slice(-18);
+ const prompt = detectPrompt(recent);
+ if (!prompt) return;
+ const key = `${current || "none"}:${prompt.kind}:${prompt.body}:${prompt.actions.map(a => a.input).join("|")}`;
+ if (key === lastPromptKey || promptRequests.some(p => p.key === key)) return;
+ lastPromptKey = key;
+ const item = {
+ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ key,
+ sessionId: current,
+ title: prompt.title,
+ body: prompt.body,
+ actions: prompt.actions,
+ ts: Date.now()
+ };
+ promptRequests.unshift(item);
+ promptRequests = promptRequests.slice(0, 20);
+ savePromptHistory();
+ renderPromptFeed();
+ notify("Codex needs input", prompt.body);
+}
+function detectPrompt(lines) {
+ const windowText = lines.join("\n");
+ if (!/\?|select|choose|enter|approve|allow|continue|proceed|permission|confirmation/i.test(windowText)) return null;
+ const numbered = [];
+ for (const line of lines.slice(-12)) {
+ const m = line.match(/^(?:\[?([1-9])\]?[\).:]?|([1-9])\s+[-:])\s+(.{1,90})$/);
+ if (!m) continue;
+ const n = m[1] || m[2];
+ const label = `${n}: ${(m[3] || "").trim()}`.slice(0, 44);
+ if (!numbered.some(a => a.input === `${n}\n`)) numbered.push({label, input:`${n}\n`});
+ }
+ if (numbered.length >= 2 && numbered.length <= 6) {
+ return {
+ kind: "numbered",
+ title: "Input requested",
+ body: summarizePrompt(lines),
+ actions: numbered
+ };
+ }
+ const yn = windowText.match(/\((?:y\/n|Y\/n|y\/N)\)|\[(?:y\/n|Y\/n|y\/N)\]/);
+ if (yn) {
+ return {
+ kind: "yesno",
+ title: "Confirmation requested",
+ body: summarizePrompt(lines),
+ actions: [{label:"Yes", input:"y\n"}, {label:"No", input:"n\n"}]
+ };
+ }
+ if (/press enter|enter for no changes|hit enter/i.test(windowText)) {
+ return {
+ kind: "enter",
+ title: "Continue requested",
+ body: summarizePrompt(lines),
+ actions: [{label:"Enter", input:"\n"}]
+ };
+ }
+ return null;
+}
+function summarizePrompt(lines) {
+ const useful = lines.slice(-8).filter(l => !/^[\s\-\|]+$/.test(l));
+ return useful.join(" ").replace(/\s+/g, " ").slice(0, 220);
+}
+function renderPromptFeed() {
+ const root = document.getElementById("prompt-feed");
+ if (!root) return;
+ const pending = promptRequests.filter(p => !p.dismissed).slice(0, 8);
+ if (!pending.length) {
+ root.innerHTML = `No pending prompts
`;
+ return;
+ }
+ root.innerHTML = pending.map(p => `
+
+
${escapeHtml(p.title || "Input requested")}${escapeHtml(formatClock(p.ts))}
+
${escapeHtml(p.body || "")}
+
+ ${(p.actions || []).map((a, i) => ``).join("")}
+
+
+
+ `).join("");
+ root.querySelectorAll("button[data-action]").forEach(btn => {
+ btn.onclick = () => sendPromptInput(btn.dataset.prompt, Number(btn.dataset.action));
+ });
+ root.querySelectorAll("button[data-dismiss-prompt]").forEach(btn => {
+ btn.onclick = () => dismissPrompt(btn.dataset.dismissPrompt);
+ });
+}
+function sendPromptInput(id, actionIndex) {
+ if (currentKind !== "session" || !ws || ws.readyState !== WebSocket.OPEN) {
+ document.getElementById("status").textContent = "Attach session before sending prompt input";
+ return;
+ }
+ const prompt = promptRequests.find(p => p.id === id);
+ const action = prompt && Array.isArray(prompt.actions) ? prompt.actions[actionIndex] : null;
+ if (!action) return;
+ const input = action.input || "";
+ ws.send(input || "");
+ dismissPrompt(id);
+ document.getElementById("status").textContent = "Prompt input sent";
+}
+function dismissPrompt(id) {
+ const prompt = promptRequests.find(p => p.id === id);
+ if (prompt) prompt.dismissed = true;
+ savePromptHistory();
+ renderPromptFeed();
+}
+function stripAnsi(value) {
+ return value
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
}
function notify(title, body) {
const item = {
@@ -2073,6 +2209,17 @@ function loadNotificationHistory() {
function saveNotificationHistory() {
localStorage.setItem("dd-shell-notifications", JSON.stringify(notifications.slice(0, 50)));
}
+function loadPromptHistory() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem("dd-shell-prompts") || "[]");
+ return Array.isArray(parsed) ? parsed.slice(0, 20) : [];
+ } catch (_) {
+ return [];
+ }
+}
+function savePromptHistory() {
+ localStorage.setItem("dd-shell-prompts", JSON.stringify(promptRequests.slice(0, 20)));
+}
function showToast(item) {
const stack = document.getElementById("toast-stack");
if (!stack) return;
From 96a8d5a401b188a9cb66db0e600c8f92ba748fe0 Mon Sep 17 00:00:00 2001
From: Alex Newman
Date: Sun, 10 May 2026 11:13:22 +0000
Subject: [PATCH 02/13] Route shell sessions over Noise
---
.github/workflows/deploy-cp.yml | 1 -
Cargo.lock | 20 +-
Cargo.toml | 3 +-
README.md | 35 +-
apps/README.md | 29 +-
apps/_infra/local-cp.sh | 3 +
docs/sessiond-central-ui-plan.md | 84 ++--
src/agent.rs | 13 +-
src/cp.rs | 1 +
src/lib.rs | 1 +
src/main.rs | 6 +-
src/noise_client.rs | 721 ++++++++++++++++++++++++++++++
src/noise_gateway/allowlist.rs | 27 ++
src/noise_gateway/mod.rs | 1 +
src/noise_gateway/noise.rs | 65 ++-
src/noise_gateway/upstream.rs | 137 +++++-
src/shell.rs | 726 +------------------------------
17 files changed, 1078 insertions(+), 795 deletions(-)
create mode 100644 src/noise_client.rs
diff --git a/.github/workflows/deploy-cp.yml b/.github/workflows/deploy-cp.yml
index 3ebe892..dcc86d5 100644
--- a/.github/workflows/deploy-cp.yml
+++ b/.github/workflows/deploy-cp.yml
@@ -269,7 +269,6 @@ jobs:
DD_AUTH_COOKIE_DOMAIN="$DD_AUTH_COOKIE_DOMAIN" \
DD_AUTH_COOKIE_SECRET="$DD_AUTH_COOKIE_SECRET" \
bake apps/dd-shell/workload.json.tmpl
- bake apps/ee-proxy/workload.json.tmpl
} | jq -cs '.')
# EE_CAPTURE_SOCKET tells EE (post-capture-socket patch) to tee
diff --git a/Cargo.lock b/Cargo.lock
index 865fe94..55875bf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -386,6 +386,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
+ "tokio-tungstenite",
"urlencoding",
"uuid",
"x25519-dalek",
@@ -755,7 +756,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots",
+ "webpki-roots 1.0.6",
]
[[package]]
@@ -1448,7 +1449,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "webpki-roots",
+ "webpki-roots 1.0.6",
]
[[package]]
@@ -1908,8 +1909,12 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
+ "rustls",
+ "rustls-pki-types",
"tokio",
+ "tokio-rustls",
"tungstenite",
+ "webpki-roots 0.26.11",
]
[[package]]
@@ -1996,6 +2001,8 @@ dependencies = [
"httparse",
"log",
"rand 0.9.2",
+ "rustls",
+ "rustls-pki-types",
"sha1",
"thiserror",
"utf-8",
@@ -2228,6 +2235,15 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.6",
+]
+
[[package]]
name = "webpki-roots"
version = "1.0.6"
diff --git a/Cargo.toml b/Cargo.toml
index cd7cf19..ccb2218 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,7 +25,8 @@ sha2 = "0.10"
snow = { version = "0.9", default-features = false, features = ["default-resolver"] }
sysinfo = { version = "0.33", default-features = false, features = ["system", "disk", "network"] }
thiserror = "2"
-tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "net", "io-util", "sync"] }
+tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "net", "io-util", "io-std", "sync"] }
+tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
diff --git a/README.md b/README.md
index 60df909..7770c04 100644
--- a/README.md
+++ b/README.md
@@ -111,12 +111,37 @@ The agent verifies the OIDC token against GitHub's JWKS, checks `repository_owne
## Terminal access
-Each VM runs `dd-shell` as a workload on a `-shell` labelled subdomain (for example `app-shell.devopsdefender.com` or `-shell.devopsdefender.com`). DD gates it behind the same GitHub App broker session as the dashboards. The shell UI separates observed read-only workload logs from controlled read-write PTY sessions. Read-only viewing does not change integrity state because it cannot send input or signals; read-write PTYs are controlled as soon as they exist and keep encrypted transcript history inside the enclave.
+Each VM runs `dd-sessiond` as the local session supervisor. `dd-sessiond` owns
+PTYs, child process groups, resize/close control, and encrypted transcript
+history inside the enclave.
+
+Native clients use a paired device key against the agent's `/noise/ws` endpoint.
+The bootstrap flow fetches `/health`, appraises `noise.quote_b64` with Intel
+Trust Authority, checks that the quote binds `noise.pubkey_hex` into TDX
+`report_data`, and then runs Noise_IK over WebSocket. The exposed session RPC surface is
+`shell.list_recipes`, `shell.list_sessions`, `shell.create_session`,
+`shell.replay_session`, `shell.resize_session`, `shell.close_session`, and the
+streaming `shell.attach_session` method. Session control and PTY bytes flow
+inside the Noise transport to the agent and then to local `dd-sessiond`; the CP
+is used for enrollment and route discovery, not for shell/log/session bytes.
+
+The bundled native CLI exercises that path directly:
-The shell is installable as a small PWA. Browser notifications are always
-delivered by default when permission is granted; on mobile, install the shell
-from the browser so the service worker can present notifications through the
-platform notification surface while the shell is active.
+```bash
+devopsdefender noise keygen --cp-url https://app.devopsdefender.com --label laptop
+devopsdefender noise recipes --url https://
+devopsdefender noise shell --url https:// --recipe shell
+```
+
+The CLI uses `DD_ITA_API_KEY` for quote appraisal. `DD_ITA_BASE_URL`,
+`DD_ITA_JWKS_URL`, and `DD_ITA_ISSUER` default to Intel Trust Authority's
+production endpoints and can be overridden when needed. Local preview/dev runs
+without ITA credentials must pass `--insecure-skip-quote-verify` explicitly.
+
+The web shell should become another client implementation of the same protocol:
+it keeps its own paired device identity, asks CP for current routes, and opens
+Noise directly to the agent. The existing cookie-auth browser shell remains a
+compatibility surface while that client-side Noise implementation lands.
## STONITH
diff --git a/apps/README.md b/apps/README.md
index d8d70a9..dd3a30a 100644
--- a/apps/README.md
+++ b/apps/README.md
@@ -175,7 +175,8 @@ inline in two places so both lifecycle points behave identically:
| `busybox` | | ✅ | ✅ |
| `cloudflared` | ✅ | ✅ | ✅ |
| `dd-agent` | | ✅ | ✅ |
-| `dd-shell` | | ✅ | ✅ |
+| `dd-sessiond` | ✅ | ✅ | ✅ |
+| `dd-shell` | ✅ | ✅ | ✅ |
| `human-readonly` | | ✅ | |
| `dd-management` | ✅ | | |
| `podman-static` | | ✅ | ✅ |
@@ -190,24 +191,16 @@ Additional examples:
is a shell workload recipe, not a `devopsdefender` binary subcommand.
- `apps/oracle-readonly`: standalone oracle example with the same scraper and
vanity-address metadata; copy this shape into real oracle app repos.
-- `apps/confidential-shell`: runs dd-shell with
- `DD_SHELL_DIR=/var/lib/easyenclave/data/dd-shell` so read-write PTY
- transcript history survives on the workload disk.
+- `apps/confidential-shell`: legacy standalone shell workload for deployments
+ that still run the browser shell and PTY supervisor in one process.
- `apps/codex-podman-shell`: alternative read-write shell workload. It exposes
- the normal `-shell` label, stores encrypted dd-shell history under
- `/var/lib/easyenclave/data/dd-shell`, and advertises a Codex recipe in the
- shell UI. The normal shell recipe remains available. Launching Codex starts a
- Podman-backed Node container with per-session home, workspace, cache, and tmp
- directories supplied by dd-shell. The container installs `@openai/codex` on
- first use, so `codex login` can be completed interactively from the browser
- terminal. Session scratch is intentionally ephemeral in this first slice; the
- encrypted transcript remains under `DD_SHELL_DIR`. Use this instead of
- `dd-shell`, not alongside it, unless you give one of them a different
- `hostname_label`.
-
-CP stays slim: just `cloudflared` + `dd-management`. Preview agent VMs run a
-small read-only oracle plus agent + podman for CI to prove registration,
-scraping, vanity ingress, and dashboards end-to-end. Prod agent VMs use the
+ the normal `-shell` label and carries an older self-contained Codex recipe
+ path. New deployments should prefer `dd-sessiond` + `dd-shell`.
+
+CP stays slim: `cloudflared` + `dd-management` + `dd-sessiond` + `dd-shell`.
+Preview agent VMs run a small read-only oracle plus agent + podman for CI to
+prove registration, scraping, vanity ingress, and dashboards end-to-end. Prod
+agent VMs use the
same CPU-only boot shape without demo workloads for now. `dd-local-dogfood`
uses that same prod boot chain but is manually managed, sized larger by
default, and not relaunched by CI.
diff --git a/apps/_infra/local-cp.sh b/apps/_infra/local-cp.sh
index 2a89126..57f9264 100755
--- a/apps/_infra/local-cp.sh
+++ b/apps/_infra/local-cp.sh
@@ -153,6 +153,9 @@ build_config_iso() {
DD_OWNER_ID="$EE_OWNER_ID" \
DD_OWNER_KIND="$EE_OWNER_KIND" \
bake "$REPO_ROOT/apps/dd-management/workload.json.tmpl"
+ DD_SESSIOND_DIR=/tmp/dd-shell \
+ DD_SESSIOND_SCRATCH_DIR=/tmp/dd-shell/sessions \
+ bake "$REPO_ROOT/apps/dd-sessiond/workload.json.tmpl"
DD_DOMAIN="$DD_DOMAIN" \
DD_HOSTNAME="$HOSTNAME" \
DD_ENV="$ENV_LABEL" \
diff --git a/docs/sessiond-central-ui-plan.md b/docs/sessiond-central-ui-plan.md
index ca152f6..1b9f80a 100644
--- a/docs/sessiond-central-ui-plan.md
+++ b/docs/sessiond-central-ui-plan.md
@@ -3,16 +3,15 @@
## Goal
Accept one disruptive dogfood redeploy to install a durable session backend.
-After that, iterate on desktop/mobile/assistant UI from the centralized fleet
-control plane without restarting the dogfood VM or killing active Codex/Claude
-sessions.
+After that, iterate on desktop/mobile/assistant clients without restarting the
+dogfood VM or killing active Codex/Claude sessions.
## Target Shape
```text
-browser
- -> app.devopsdefender.com fleet UI
- -> selected agent session gateway
+native/web/mobile client
+ -> CP route discovery + device enrollment
+ -> selected agent /noise/ws
-> dd-sessiond Unix socket on that VM
-> PTY + child process group
```
@@ -28,10 +27,15 @@ Agent VM responsibilities:
Control-plane responsibilities:
-- Serve the desktop shell UI, mobile UI, and Claude-style assistant view.
-- Let users select a fleet agent and attach to sessions through that agent's
- session API.
-- Ship UI updates via normal CP/web deploys, without touching agent VMs.
+- Own device enrollment, revocation, policy, and route discovery.
+- Optionally serve static web/PWA assets.
+- Never carry shell, log, transcript, or PTY bytes.
+
+Client responsibilities:
+
+- Hold a paired device identity.
+- Ask CP for current routes and capabilities.
+- Connect directly to the selected agent over Noise for runtime data.
## Non-Goals
@@ -39,7 +43,9 @@ Control-plane responsibilities:
- No hot-swappable UI directory as the primary mechanism.
- No fallback in-process PTY mode once `dd-sessiond` is introduced.
- No CRIU/checkpoint work in the first implementation.
-- No full browser-side Noise handshake in the first implementation.
+- No CP relay or mailbox relay for shell/log/session bytes.
+- No full browser-side Noise handshake in the first implementation; native CLI
+ is the first protocol exerciser.
## Phase 1: One Disruptive Dogfood Upgrade
@@ -52,8 +58,8 @@ Deliverables:
- Add a `dd-sessiond` boot workload for dogfood agents. Implemented in this branch.
- Change interactive sessions so `dd-sessiond` owns PTYs. Implemented for `dd-shell`
by proxying session create/list/attach/resize/close/replay to sessiond.
-- Change `dd-agent` to proxy session APIs to `dd-sessiond`. Implemented with
- browser-auth-gated routes for the first cut.
+- Change `dd-agent` to proxy session APIs to `dd-sessiond`. Implemented over
+ paired-device Noise for the first durable client path.
- Keep Codex launch working through the session manager.
- Preserve persistent disk state, including Codex/npm/login state.
@@ -91,57 +97,74 @@ Implementation notes:
- Transcript and events are canonical session state, not UI state.
- Mobile clients should not resize the canonical PTY by default.
-## Phase 3: Centralized Fleet UI
+## Phase 3: Client-Side Fleet UI
-Move active shell UI development to the CP/fleet dashboard.
+Move active shell UI development to clients that use CP only for routes and
+agent/device policy.
Deliverables:
-- Add CP route for agent sessions.
+- Add CP route discovery for agent sessions.
- UI lists sessions for the selected agent.
-- UI can create, attach, input, resize, close, and replay sessions.
+- UI can create, attach, input, resize, close, and replay sessions by opening
+ Noise directly to the selected agent.
- Desktop terminal view remains raw PTY-first.
- Mobile view can add touch controls, smart sizing, and assistant-style
rendering without changing agent-side session ownership.
+- The web/PWA interface becomes a client implementation of the same protocol,
+ not a server-side shell proxy.
Acceptance:
-- Updating CP UI via PR/release updates the shell/mobile experience.
+- Updating static web/PWA assets updates the browser shell/mobile experience.
- No dogfood agent restart is needed for UI-only changes.
-- Active dogfood Codex sessions survive CP UI updates.
+- Active dogfood Codex sessions survive web/client updates.
Status:
-- Not implemented in the first branch slice. The agent-side session gateway is
- present so the CP UI can target it next.
+- Not implemented in the first branch slice. The native CLI is present so the
+ client-side protocol can be tested before replacing the browser shell proxy.
## Phase 4: Auth, Attestation, And Noise
Start simple:
-- CP issues short-lived scoped session tokens.
-- Tokens bind user, agent id, session/action scope, and expiry.
-- `dd-agent` validates tokens before proxying to `dd-sessiond`.
+- CP enrolls paired device public keys and exposes current routes.
+- Agents poll CP for the trusted device set and route/policy freshness.
+- Native CLI/desktop/mobile clients use direct `/noise/ws` channels for
+ session RPCs.
Then strengthen:
-- Bind session authorization to the attested agent Noise public key.
-- CP tracks agent attestation and `noise_pubkey`.
-- Desktop/CLI clients can eventually use a direct Noise channel.
-- Browser UI can stay token-over-HTTPS until custom browser crypto is worth it.
+- Clients verify the agent quote and pin the attested Noise public key.
+- Browser/PWA uses the same direct agent Noise path once the browser crypto or
+ WASM client is in place.
+
+Implemented first slice:
+
+- `/health` exposes the quote and Noise public key that clients verify and pin.
+- A paired device can send `shell.list_recipes`, `shell.list_sessions`,
+ `shell.create_session`, `shell.replay_session`, `shell.resize_session`, and
+ `shell.close_session` JSON requests over Noise.
+- `shell.attach_session` returns one JSON ack, then switches the same Noise
+ session into encrypted raw PTY byte streaming to local `dd-sessiond`.
+- CP Noise endpoints reject shell methods because they have no local sessiond
+ adapter; agent Noise endpoints wire the adapter in.
Design rule:
```text
remote clients never trust naked tunnel DNS
-remote clients trust CP-issued scoped authorization and, later, attested keys
+remote clients trust CP-enrolled device identity plus attested agent keys
+CP is route/key authority only, never a session data plane
dd-sessiond stays local-only
dd-agent is the policy/encryption gateway
```
## Risks And Open Questions
-- Whether to keep a minimal agent-local shell UI for emergency access.
+- Whether to keep a minimal cookie-auth shell UI only as temporary emergency
+ compatibility.
- How to model recipes so CP UI and `dd-sessiond` agree on available launchers.
- How to represent "input requested" events for Codex/Claude without making
terminal parsing authoritative.
@@ -149,3 +172,4 @@ dd-agent is the policy/encryption gateway
passing or exec handoff.
- How much of the existing transcript encryption format should move unchanged
into `dd-sessiond`.
+- Browser Noise implementation choice: pure JS library versus small WASM client.
diff --git a/src/agent.rs b/src/agent.rs
index a341857..8b9724d 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -196,17 +196,22 @@ pub async fn run() -> Result<()> {
std::path::PathBuf::from(noise_gateway::upstream::DEFAULT_EE_AGENT_SOCK),
ee_token,
));
+ let sessiond_http_url =
+ std::env::var("DD_SESSIOND_HTTP_URL").unwrap_or_else(|_| "http://127.0.0.1:7683".into());
+ let sessiond_attach_addr =
+ std::env::var("DD_SESSIOND_ATTACH_ADDR").unwrap_or_else(|_| "127.0.0.1:7684".into());
+ let shell = Arc::new(noise_gateway::upstream::Sessiond::new(
+ sessiond_http_url.clone(),
+ sessiond_attach_addr.clone(),
+ ));
let ng_state = noise_gateway::State {
attest: attestor.clone(),
trust,
upstream,
+ shell: Some(shell),
};
let gh = gh_oidc::Verifier::new(cfg.common.owner.clone(), "dd-agent".into());
- let sessiond_http_url =
- std::env::var("DD_SESSIOND_HTTP_URL").unwrap_or_else(|_| "http://127.0.0.1:7683".into());
- let sessiond_attach_addr =
- std::env::var("DD_SESSIOND_ATTACH_ADDR").unwrap_or_else(|_| "127.0.0.1:7684".into());
// Seed taint set. Boot-time facts go in now; runtime events
// (CustomerOwnerEnabled, CustomerWorkloadDeployed) are appended
diff --git a/src/cp.rs b/src/cp.rs
index c346aec..f8213e8 100644
--- a/src/cp.rs
+++ b/src/cp.rs
@@ -353,6 +353,7 @@ pub async fn run() -> Result<()> {
attest: attestor.clone(),
trust: trust.clone(),
upstream,
+ shell: None,
};
let state = St {
diff --git a/src/lib.rs b/src/lib.rs
index 7568ffc..c4c8b01 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -11,6 +11,7 @@ pub mod gh_oidc;
pub mod html;
pub mod ita;
pub mod metrics;
+pub mod noise_client;
pub mod noise_gateway;
pub mod oracle;
pub mod sessiond;
diff --git a/src/main.rs b/src/main.rs
index 9482ab1..f131a99 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,10 +4,11 @@
//! DD_MODE=agent devopsdefender # in-VM agent
//! DD_MODE=shell devopsdefender # shell web service
//! DD_MODE=sessiond devopsdefender # local session supervisor
+//! devopsdefender noise ... # native Noise client
//!
//! (Also accepts `devopsdefender cp` / `devopsdefender agent` for local dev.)
-use devopsdefender::{agent, cp, sessiond, shell};
+use devopsdefender::{agent, cp, noise_client, sessiond, shell};
#[tokio::main]
async fn main() {
@@ -20,8 +21,9 @@ async fn main() {
Some("agent") => agent::run().await.map_err(Into::into),
Some("shell") => shell::run().await.map_err(Into::into),
Some("sessiond") => sessiond::run().await.map_err(Into::into),
+ Some("noise") | Some("cli") => noise_client::run_cli().await,
_ => {
- eprintln!("usage: devopsdefender ");
+ eprintln!("usage: devopsdefender ");
eprintln!(" or: DD_MODE= devopsdefender");
std::process::exit(2);
}
diff --git a/src/noise_client.rs b/src/noise_client.rs
new file mode 100644
index 0000000..d8d16ae
--- /dev/null
+++ b/src/noise_client.rs
@@ -0,0 +1,721 @@
+//! Native Noise client for operator shell sessions.
+//!
+//! This is intentionally small and dependency-light: it speaks the same
+//! Noise_IK-over-WebSocket framing as `noise_gateway`, then sends the
+//! shell/session RPCs that the gateway forwards to local `dd-sessiond`.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::{anyhow, Context};
+use base64::Engine as _;
+use futures_util::{SinkExt, StreamExt};
+use rand::rngs::OsRng;
+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};
+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;
+
+type WsStream = WebSocketStream>;
+type WsSink = futures_util::stream::SplitSink;
+type WsRead = futures_util::stream::SplitStream;
+
+pub async fn run_cli() -> anyhow::Result<()> {
+ let mut args: Vec = std::env::args().skip(2).collect();
+ if args.is_empty() || args[0] == "-h" || args[0] == "--help" {
+ print_usage();
+ return Ok(());
+ }
+
+ let command = args.remove(0);
+ match command.as_str() {
+ "keygen" => {
+ let opts = parse_opts(args)?;
+ let key_path = opts.key_path();
+ let secret = load_or_create_key(&key_path).await?;
+ let pubkey = public_hex(&secret);
+ println!("{pubkey}");
+ if let Some(cp_url) = opts.cp_url.as_deref() {
+ let label = opts.label.unwrap_or_else(default_label);
+ println!("{}", enrollment_url(cp_url, &pubkey, &label));
+ }
+ }
+ "pubkey" => {
+ let opts = parse_opts(args)?;
+ let secret = load_or_create_key(&opts.key_path()).await?;
+ println!("{}", public_hex(&secret));
+ }
+ "recipes" => {
+ let opts = parse_opts(args)?;
+ let mut conn = connect(&opts).await?;
+ print_json(
+ conn.call(serde_json::json!({"method": "shell.list_recipes"}))
+ .await?,
+ )?;
+ }
+ "sessions" => {
+ let opts = parse_opts(args)?;
+ let mut conn = connect(&opts).await?;
+ print_json(
+ conn.call(serde_json::json!({"method": "shell.list_sessions"}))
+ .await?,
+ )?;
+ }
+ "create" => {
+ let opts = parse_opts(args)?;
+ let mut conn = connect(&opts).await?;
+ let session = create_session(&mut conn, &opts).await?;
+ print_json(session)?;
+ }
+ "replay" => {
+ let opts = parse_opts(args)?;
+ let id = opts
+ .id
+ .as_deref()
+ .ok_or_else(|| anyhow!("replay requires --id"))?;
+ let mut conn = connect(&opts).await?;
+ print_json(
+ conn.call(serde_json::json!({
+ "method": "shell.replay_session",
+ "id": id,
+ }))
+ .await?,
+ )?;
+ }
+ "resize" => {
+ let opts = parse_opts(args)?;
+ let id = opts
+ .id
+ .as_deref()
+ .ok_or_else(|| anyhow!("resize requires --id"))?;
+ let cols = opts.cols.ok_or_else(|| anyhow!("resize requires --cols"))?;
+ let rows = opts.rows.ok_or_else(|| anyhow!("resize requires --rows"))?;
+ let mut conn = connect(&opts).await?;
+ print_json(
+ conn.call(serde_json::json!({
+ "method": "shell.resize_session",
+ "id": id,
+ "cols": cols,
+ "rows": rows,
+ }))
+ .await?,
+ )?;
+ }
+ "close" => {
+ let opts = parse_opts(args)?;
+ let id = opts
+ .id
+ .as_deref()
+ .ok_or_else(|| anyhow!("close requires --id"))?;
+ let mut conn = connect(&opts).await?;
+ print_json(
+ conn.call(serde_json::json!({
+ "method": "shell.close_session",
+ "id": id,
+ }))
+ .await?,
+ )?;
+ }
+ "attach" => {
+ let opts = parse_opts(args)?;
+ let id = opts
+ .id
+ .as_deref()
+ .ok_or_else(|| anyhow!("attach requires --id"))?;
+ let conn = connect(&opts).await?;
+ attach_session(conn, id).await?;
+ }
+ "shell" => {
+ let opts = parse_opts(args)?;
+ let mut conn = connect(&opts).await?;
+ let session = create_session(&mut conn, &opts).await?;
+ let id = session_id(&session)?;
+ attach_session(conn, &id).await?;
+ }
+ "exec" => {
+ let (opts, cmd) = parse_exec_opts(args)?;
+ let mut conn = connect(&opts).await?;
+ print_json(
+ conn.call(serde_json::json!({
+ "method": "exec",
+ "cmd": cmd,
+ "timeout_secs": opts.timeout_secs.unwrap_or(60),
+ }))
+ .await?,
+ )?;
+ }
+ other => {
+ anyhow::bail!("unknown noise command `{other}`");
+ }
+ }
+ Ok(())
+}
+
+fn print_usage() {
+ eprintln!(
+ "usage:
+ devopsdefender noise keygen [--key PATH] [--cp-url URL] [--label LABEL]
+ devopsdefender noise pubkey [--key PATH]
+ devopsdefender noise recipes --url AGENT_URL [--key PATH]
+ devopsdefender noise sessions --url AGENT_URL [--key PATH]
+ devopsdefender noise create --url AGENT_URL [--key PATH] [--recipe ID] [--name NAME] [--command PATH]
+ devopsdefender noise replay --url AGENT_URL [--key PATH] --id SESSION_ID
+ devopsdefender noise resize --url AGENT_URL [--key PATH] --id SESSION_ID --cols N --rows N
+ devopsdefender noise close --url AGENT_URL [--key PATH] --id SESSION_ID
+ devopsdefender noise attach --url AGENT_URL [--key PATH] --id SESSION_ID
+ devopsdefender noise shell --url AGENT_URL [--key PATH] [--recipe ID] [--name NAME] [--command PATH]
+ devopsdefender noise exec --url AGENT_URL [--key PATH] [--timeout SECS] -- CMD [ARG...]
+
+Quote verification is enabled by default and uses DD_ITA_API_KEY plus optional
+DD_ITA_BASE_URL, DD_ITA_JWKS_URL, and DD_ITA_ISSUER. Local dev can pass
+--insecure-skip-quote-verify explicitly."
+ );
+}
+
+#[derive(Default)]
+struct Opts {
+ url: Option,
+ key: Option,
+ cp_url: Option,
+ label: Option,
+ recipe: Option,
+ name: Option,
+ command: Option,
+ id: Option,
+ cols: Option,
+ rows: Option,
+ timeout_secs: Option,
+ insecure_skip_quote_verify: bool,
+ ita_api_key: Option,
+ ita_base_url: Option,
+ ita_jwks_url: Option,
+ ita_issuer: Option,
+}
+
+impl Opts {
+ fn key_path(&self) -> PathBuf {
+ self.key
+ .clone()
+ .or_else(|| std::env::var_os("DD_NOISE_CLIENT_KEY").map(PathBuf::from))
+ .unwrap_or_else(default_key_path)
+ }
+
+ fn agent_url(&self) -> anyhow::Result<&str> {
+ self.url
+ .as_deref()
+ .ok_or_else(|| anyhow!("missing --url AGENT_URL"))
+ }
+}
+
+fn parse_opts(args: Vec) -> anyhow::Result {
+ let mut opts = Opts::default();
+ let mut i = 0;
+ while i < args.len() {
+ let key = args[i].as_str();
+ i += 1;
+ let mut take = |name: &str| -> anyhow::Result {
+ let value = args
+ .get(i)
+ .cloned()
+ .ok_or_else(|| anyhow!("{name} requires a value"))?;
+ i += 1;
+ Ok(value)
+ };
+ match key {
+ "--url" => opts.url = Some(take("--url")?),
+ "--key" => opts.key = Some(PathBuf::from(take("--key")?)),
+ "--cp-url" => opts.cp_url = Some(take("--cp-url")?),
+ "--label" => opts.label = Some(take("--label")?),
+ "--recipe" => opts.recipe = Some(take("--recipe")?),
+ "--name" => opts.name = Some(take("--name")?),
+ "--command" => opts.command = Some(take("--command")?),
+ "--id" => opts.id = Some(take("--id")?),
+ "--cols" => opts.cols = Some(parse_u64("--cols", &take("--cols")?)?),
+ "--rows" => opts.rows = Some(parse_u64("--rows", &take("--rows")?)?),
+ "--timeout" => opts.timeout_secs = Some(parse_u64("--timeout", &take("--timeout")?)?),
+ "--ita-api-key" => opts.ita_api_key = Some(take("--ita-api-key")?),
+ "--ita-base-url" => opts.ita_base_url = Some(take("--ita-base-url")?),
+ "--ita-jwks-url" => opts.ita_jwks_url = Some(take("--ita-jwks-url")?),
+ "--ita-issuer" => opts.ita_issuer = Some(take("--ita-issuer")?),
+ "--insecure-skip-quote-verify" => opts.insecure_skip_quote_verify = true,
+ "-h" | "--help" => {
+ print_usage();
+ std::process::exit(0);
+ }
+ other => anyhow::bail!("unknown option `{other}`"),
+ }
+ }
+ Ok(opts)
+}
+
+fn parse_exec_opts(args: Vec) -> anyhow::Result<(Opts, Vec)> {
+ let split = args
+ .iter()
+ .position(|arg| arg == "--")
+ .ok_or_else(|| anyhow!("exec requires `-- CMD [ARG...]`"))?;
+ let opts = parse_opts(args[..split].to_vec())?;
+ let cmd = args[split + 1..].to_vec();
+ if cmd.is_empty() {
+ anyhow::bail!("exec requires a command after `--`");
+ }
+ Ok((opts, cmd))
+}
+
+fn parse_u64(name: &str, value: &str) -> anyhow::Result {
+ value
+ .parse()
+ .with_context(|| format!("{name} must be an unsigned integer"))
+}
+
+struct NoiseConnection {
+ transport: TransportState,
+ sink: WsSink,
+ stream: WsRead,
+}
+
+impl NoiseConnection {
+ async fn call(&mut self, request: Value) -> anyhow::Result {
+ let plain = serde_json::to_vec(&request)?;
+ send_encrypted(&mut self.transport, &mut self.sink, &plain).await?;
+ let cipher = next_binary(&mut self.stream)
+ .await?
+ .ok_or_else(|| anyhow!("Noise websocket closed before response"))?;
+ let mut out = vec![0u8; cipher.len()];
+ let n = self.transport.read_message(&cipher, &mut out)?;
+ out.truncate(n);
+ Ok(serde_json::from_slice(&out)?)
+ }
+}
+
+async fn connect(opts: &Opts) -> anyhow::Result {
+ let base_url = opts.agent_url()?;
+ let secret = load_or_create_key(&opts.key_path()).await?;
+ let server_pubkey = fetch_and_verify_server_pubkey(base_url, opts).await?;
+ let ws_url = noise_ws_url(base_url);
+
+ let (ws, _response) = connect_async(&ws_url)
+ .await
+ .with_context(|| format!("connect {ws_url}"))?;
+ let (mut sink, mut stream) = ws.split();
+
+ let mut hs = Builder::new(NOISE_PATTERN.parse()?)
+ .local_private_key(secret.as_bytes())
+ .remote_public_key(&server_pubkey)
+ .build_initiator()?;
+
+ let mut first = [0u8; MAX_NOISE_MSG];
+ let n = hs.write_message(&[], &mut first)?;
+ sink.send(WsMessage::Binary(first[..n].to_vec().into()))
+ .await?;
+
+ let second = next_binary(&mut stream)
+ .await?
+ .ok_or_else(|| anyhow!("Noise websocket closed during handshake"))?;
+ let mut payload = [0u8; MAX_NOISE_MSG];
+ hs.read_message(&second, &mut payload)?;
+
+ Ok(NoiseConnection {
+ transport: hs.into_transport_mode()?,
+ sink,
+ stream,
+ })
+}
+
+async fn fetch_and_verify_server_pubkey(base_url: &str, opts: &Opts) -> anyhow::Result<[u8; 32]> {
+ let url = health_url(base_url);
+ let body: Value = reqwest::get(&url)
+ .await
+ .with_context(|| format!("GET {url}"))?
+ .error_for_status()
+ .with_context(|| format!("GET {url}"))?
+ .json()
+ .await
+ .with_context(|| format!("parse {url}"))?;
+ let pubkey_hex = body
+ .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"))?;
+ let bytes = hex::decode(pubkey_hex).context("decode noise.pubkey_hex")?;
+ if bytes.len() != 32 {
+ anyhow::bail!(
+ "noise.pubkey_hex decoded to {} bytes, expected 32",
+ bytes.len()
+ );
+ }
+ let mut out = [0u8; 32];
+ out.copy_from_slice(&bytes);
+ verify_quote_binding(quote_b64, &out, opts).await?;
+ Ok(out)
+}
+
+async fn verify_quote_binding(
+ quote_b64: &str,
+ pubkey: &[u8; 32],
+ opts: &Opts,
+) -> anyhow::Result<()> {
+ if opts.insecure_skip_quote_verify {
+ eprintln!("warning: skipping agent TDX quote verification by explicit request");
+ return Ok(());
+ }
+
+ let api_key = opt_or_env(opts.ita_api_key.as_deref(), "DD_ITA_API_KEY")
+ .ok_or_else(|| anyhow!("DD_ITA_API_KEY required for quote verification"))?;
+ let base_url = opt_or_env(opts.ita_base_url.as_deref(), "DD_ITA_BASE_URL")
+ .unwrap_or_else(|| "https://api.trustauthority.intel.com".into());
+ let jwks_url = opt_or_env(opts.ita_jwks_url.as_deref(), "DD_ITA_JWKS_URL")
+ .unwrap_or_else(|| "https://portal.trustauthority.intel.com/certs".into());
+ let issuer = opt_or_env(opts.ita_issuer.as_deref(), "DD_ITA_ISSUER")
+ .unwrap_or_else(|| "https://portal.trustauthority.intel.com".into());
+
+ let token = crate::ita::mint(&base_url, &api_key, quote_b64)
+ .await
+ .map_err(|e| anyhow!("ITA quote appraisal failed: {e}"))?;
+ let verifier = crate::ita::Verifier::new(jwks_url, issuer);
+ let claims = verifier
+ .verify(&token)
+ .await
+ .map_err(|e| anyhow!("ITA token verification failed: {e}"))?;
+ let report_data = claims
+ .report_data
+ .as_deref()
+ .ok_or_else(|| anyhow!("ITA token missing attester_held_data/report_data"))?;
+ verify_report_data(report_data, pubkey)
+}
+
+fn opt_or_env(opt: Option<&str>, env: &str) -> Option {
+ opt.map(str::to_owned).or_else(|| {
+ std::env::var(env)
+ .ok()
+ .map(|v| v.trim().to_string())
+ .filter(|v| !v.is_empty())
+ })
+}
+
+fn verify_report_data(report_data: &str, pubkey: &[u8; 32]) -> anyhow::Result<()> {
+ let bytes = decode_report_data(report_data)?;
+ match bytes.len() {
+ 32 if bytes.as_slice() == pubkey => Ok(()),
+ 64 if bytes[..32] == pubkey[..] && bytes[32..].iter().all(|b| *b == 0) => Ok(()),
+ 32 | 64 => anyhow::bail!("TDX report_data does not bind expected Noise public key"),
+ n => anyhow::bail!("TDX report_data decoded to {n} bytes, expected 32 or 64"),
+ }
+}
+
+fn decode_report_data(report_data: &str) -> anyhow::Result> {
+ let s = report_data.trim();
+ let hexish = s.strip_prefix("0x").unwrap_or(s);
+ if hexish.len().is_multiple_of(2) && hexish.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return hex::decode(hexish).context("decode ITA report_data hex");
+ }
+ for engine in [
+ &base64::engine::general_purpose::STANDARD,
+ &base64::engine::general_purpose::STANDARD_NO_PAD,
+ &base64::engine::general_purpose::URL_SAFE,
+ &base64::engine::general_purpose::URL_SAFE_NO_PAD,
+ ] {
+ if let Ok(bytes) = engine.decode(s) {
+ return Ok(bytes);
+ }
+ }
+ anyhow::bail!("ITA report_data is neither hex nor base64")
+}
+
+async fn create_session(conn: &mut NoiseConnection, opts: &Opts) -> anyhow::Result {
+ let mut request = serde_json::Map::from_iter([(
+ "method".to_string(),
+ Value::String("shell.create_session".into()),
+ )]);
+ if let Some(recipe) = opts.recipe.as_deref() {
+ request.insert("recipe_id".into(), Value::String(recipe.into()));
+ }
+ if let Some(name) = opts.name.as_deref() {
+ request.insert("name".into(), Value::String(name.into()));
+ }
+ if let Some(command) = opts.command.as_deref() {
+ request.insert("command".into(), Value::String(command.into()));
+ }
+ conn.call(Value::Object(request)).await
+}
+
+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)?);
+ }
+
+ 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;
+ }
+ send_encrypted(&mut conn.transport, &mut conn.sink, &in_buf[..n]).await?;
+ }
+ 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(())
+}
+
+async fn send_encrypted(
+ transport: &mut TransportState,
+ sink: &mut WsSink,
+ plain: &[u8],
+) -> anyhow::Result<()> {
+ let mut cipher = vec![0u8; plain.len() + 16];
+ let n = transport.write_message(plain, &mut cipher)?;
+ cipher.truncate(n);
+ sink.send(WsMessage::Binary(cipher.into())).await?;
+ Ok(())
+}
+
+async fn next_binary(stream: &mut WsRead) -> anyhow::Result