From 134a3576b2c595c9a721aeb630a5376c1a63b366 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 14 Sep 2026 21:54:00 +0800 Subject: [PATCH 01/11] feat(relay): add durable duplex transport and remote runtime services --- Cargo.lock | 354 +++- Cargo.toml | 4 + .../nginx-auth.openbitfun.com.conf | 11 + deploy/relay-v1/README.md | 19 +- deploy/relay-v1/compose.yml | 25 +- deploy/relay-v1/nginx-http.conf | 16 +- deploy/relay-v1/nginx-location.conf | 24 +- docs/architecture/peer-device-mode.md | 275 ++-- .../remote-workspace-transport.md | 50 + docs/interactive-capabilities/README.md | 4 +- .../technical/tauri-command-map.json | 94 +- scripts/check-core-boundaries.test.mjs | 41 +- .../cargo-dependency-boundaries.mjs | 17 +- .../core-boundaries/rules/feature-rules.mjs | 22 +- .../rules/source/required-rules.mjs | 4 +- src/apps/relay-server/Caddyfile | 17 +- src/apps/relay-server/README.md | 36 +- src/apps/relay-server/src/main.rs | 7 +- .../relay-server/test_incremental_upload.py | 340 ---- src/crates/assembly/core/Cargo.toml | 1 + .../src/agentic/coordination/coordinator.rs | 60 +- .../src/agentic/execution/execution_engine.rs | 18 +- .../src/agentic/execution/round_executor.rs | 234 ++- .../core/src/agentic/execution/types.rs | 2 + .../src/agentic/session/session_manager.rs | 13 +- .../agentic/tools/pipeline/tool_pipeline.rs | 217 ++- .../src/agentic/tools/plugin_host_tool.rs | 6 + .../core/src/service/filesystem/mod.rs | 6 + .../src/service/filesystem/path_operations.rs | 408 +++++ .../core/src/service/filesystem/service.rs | 170 +- .../core/src/service/filesystem/upload.rs | 180 +++ .../service/remote_connect/account_runtime.rs | 48 +- .../core/src/service/remote_connect/mod.rs | 212 ++- .../src/service/remote_ssh/workspace_state.rs | 43 + .../core/src/service/workspace/service.rs | 186 ++- .../core/src/service_agent_runtime.rs | 194 ++- .../contracts/product-domains/src/account.rs | 2 +- .../generated/remote-surface-registry.json | 141 +- .../src/remote_surface/table.rs | 77 +- .../product-domains/src/tool_permissions.rs | 4 + .../src/remote_workspace_ports.rs | 4 + .../runtime-ports/src/workspace_ports.rs | 13 + .../execution/agent-runtime/src/permission.rs | 18 + .../agent-runtime/src/user_questions.rs | 69 +- src/crates/services/relay-service/Cargo.toml | 5 +- .../src/account_transport_tests.rs | 244 ++- .../services/relay-service/src/admission.rs | 57 +- src/crates/services/relay-service/src/db.rs | 3 + src/crates/services/relay-service/src/lib.rs | 11 +- .../src/realtime/device_lifecycle.rs | 404 +++++ .../relay-service/src/realtime/mod.rs | 698 ++++++++ .../relay-service/src/realtime/origin.rs | 70 + .../relay-service/src/realtime/payloads.rs | 244 +++ .../relay-service/src/realtime/presence.rs | 103 ++ .../relay-service/src/realtime/store.rs | 518 ++++++ .../relay-service/src/relay/device_manager.rs | 33 +- .../services/relay-service/src/routes/auth.rs | 17 - .../relay-service/src/routes/devices.rs | 351 +--- .../services/relay-service/src/routes/mod.rs | 1 - .../relay-service/src/routes/websocket.rs | 1426 ----------------- src/crates/services/services-core/AGENTS.md | 7 + src/crates/services/services-core/Cargo.toml | 12 + .../services-core/src/file_write_lock.rs | 21 + .../services-core/src/filesystem/mod.rs | 2 + .../services-core/src/filesystem/sorting.rs | 106 ++ .../services-core/src/filesystem/tree.rs | 65 +- src/crates/services/services-core/src/lib.rs | 4 + .../src/session/metadata_store.rs | 58 +- .../services/services-core/src/workspace.rs | 19 + .../services-core/src/workspace_transfer.rs | 654 ++++++++ .../services/services-integrations/AGENTS.md | 2 + .../services/services-integrations/Cargo.toml | 6 + .../examples/relay_realtime_probe.rs | 643 ++++++++ .../src/remote_connect.rs | 191 ++- .../src/remote_connect/account.rs | 271 ++-- .../src/remote_connect/qr_generator.rs | 6 +- .../src/remote_connect/realtime_client.rs | 247 +++ .../src/remote_connect/realtime_payload.rs | 116 ++ .../src/remote_connect/relay_client.rs | 1322 +++++---------- .../src/remote_connect/session_log.rs | 968 +++++++++++ .../src/remote_connect/session_records.rs | 114 ++ .../src/remote_connect/session_subscriber.rs | 562 +++++++ .../src/remote_persistence.rs | 2 +- .../src/remote_ssh/manager.rs | 36 + .../manager/tests/workspace_sftp.rs | 35 + .../src/remote_ssh/remote_fs.rs | 23 + .../src/remote_ssh/remote_terminal.rs | 144 +- .../src/remote_ssh/sftp_file.rs | 25 +- .../src/remote_ssh/workspace_services.rs | 16 + .../tests/remote_connect_contracts.rs | 105 +- .../services/terminal/src/session/manager.rs | 40 +- .../services/terminal/src/session/mod.rs | 2 +- .../services/terminal/src/session/replay.rs | 99 +- .../interactive-capabilities/catalog.json | 13 +- src/shared/relay-transport/AccountRealtime.ts | 261 +++ .../relay-transport/InvalidationSync.ts | 23 + src/shared/relay-transport/RpcPayload.ts | 66 + src/shared/relay-transport/RpcPolicy.ts | 2 + src/shared/relay-transport/SessionCipher.ts | 78 + .../relay-transport/SessionRecordReplica.ts | 123 ++ src/shared/relay-transport/SessionStream.ts | 149 ++ .../session-cipher.fixture.json | 50 + src/shared/terminal/TerminalInputQueue.ts | 82 + src/shared/terminal/webview/README.md | 11 + .../terminal/webview/generated/index.html | 1 + .../terminal/webview/generated/terminal.css | 32 + .../terminal/webview/generated/terminal.js | 58 + src/shared/terminal/webview/index.ts | 39 + third_party/eioc/Cargo.toml | 129 ++ third_party/eioc/LICENSE-APACHE | 201 +++ third_party/eioc/LICENSE-MIT | 7 + third_party/eioc/OPENBITFUN-PATCH.md | 11 + third_party/eioc/README.md | 15 + third_party/eioc/src/engine.rs | 552 +++++++ third_party/eioc/src/error.rs | 257 +++ third_party/eioc/src/lib.rs | 22 + third_party/eioc/src/packet.rs | 458 ++++++ third_party/eioc/src/polling.rs | 356 ++++ third_party/eioc/src/transport.rs | 113 ++ third_party/eioc/src/websocket.rs | 544 +++++++ 120 files changed, 13617 insertions(+), 3830 deletions(-) delete mode 100644 src/apps/relay-server/test_incremental_upload.py create mode 100644 src/crates/assembly/core/src/service/filesystem/path_operations.rs create mode 100644 src/crates/assembly/core/src/service/filesystem/upload.rs create mode 100644 src/crates/services/relay-service/src/realtime/device_lifecycle.rs create mode 100644 src/crates/services/relay-service/src/realtime/mod.rs create mode 100644 src/crates/services/relay-service/src/realtime/origin.rs create mode 100644 src/crates/services/relay-service/src/realtime/payloads.rs create mode 100644 src/crates/services/relay-service/src/realtime/presence.rs create mode 100644 src/crates/services/relay-service/src/realtime/store.rs delete mode 100644 src/crates/services/relay-service/src/routes/websocket.rs create mode 100644 src/crates/services/services-core/src/file_write_lock.rs create mode 100644 src/crates/services/services-core/src/filesystem/sorting.rs create mode 100644 src/crates/services/services-core/src/workspace_transfer.rs create mode 100644 src/crates/services/services-integrations/examples/relay_realtime_probe.rs create mode 100644 src/crates/services/services-integrations/src/remote_connect/realtime_client.rs create mode 100644 src/crates/services/services-integrations/src/remote_connect/realtime_payload.rs create mode 100644 src/crates/services/services-integrations/src/remote_connect/session_log.rs create mode 100644 src/crates/services/services-integrations/src/remote_connect/session_records.rs create mode 100644 src/crates/services/services-integrations/src/remote_connect/session_subscriber.rs create mode 100644 src/shared/relay-transport/AccountRealtime.ts create mode 100644 src/shared/relay-transport/InvalidationSync.ts create mode 100644 src/shared/relay-transport/RpcPayload.ts create mode 100644 src/shared/relay-transport/RpcPolicy.ts create mode 100644 src/shared/relay-transport/SessionCipher.ts create mode 100644 src/shared/relay-transport/SessionRecordReplica.ts create mode 100644 src/shared/relay-transport/SessionStream.ts create mode 100644 src/shared/relay-transport/session-cipher.fixture.json create mode 100644 src/shared/terminal/TerminalInputQueue.ts create mode 100644 src/shared/terminal/webview/README.md create mode 100644 src/shared/terminal/webview/generated/index.html create mode 100644 src/shared/terminal/webview/generated/terminal.css create mode 100644 src/shared/terminal/webview/generated/terminal.js create mode 100644 src/shared/terminal/webview/index.ts create mode 100644 third_party/eioc/Cargo.toml create mode 100644 third_party/eioc/LICENSE-APACHE create mode 100644 third_party/eioc/LICENSE-MIT create mode 100644 third_party/eioc/OPENBITFUN-PATCH.md create mode 100644 third_party/eioc/README.md create mode 100644 third_party/eioc/src/engine.rs create mode 100644 third_party/eioc/src/error.rs create mode 100644 third_party/eioc/src/lib.rs create mode 100644 third_party/eioc/src/packet.rs create mode 100644 third_party/eioc/src/polling.rs create mode 100644 third_party/eioc/src/transport.rs create mode 100644 third_party/eioc/src/websocket.rs diff --git a/Cargo.lock b/Cargo.lock index f4858f3140..44c195a8ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -561,7 +561,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -570,10 +570,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.7", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -720,6 +720,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -854,6 +863,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", + "serde_core", +] + [[package]] name = "bzip2" version = "0.4.4" @@ -1132,7 +1151,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -1279,6 +1298,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.6.0" @@ -1550,6 +1575,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -1647,7 +1681,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -1836,7 +1870,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1937,12 +1971,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "4.0.0" @@ -2165,7 +2210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -2197,6 +2242,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "eioc" +version = "0.5.0" +dependencies = [ + "base64 0.22.1", + "bytes", + "bytestring", + "futures-util", + "http", + "miette", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-tungstenite 0.30.0", + "tokio-util", + "tracing", + "url", +] + [[package]] name = "either" version = "1.17.0" @@ -2214,7 +2280,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -2283,6 +2349,52 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "engineioxide" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0235543b6e1a5fef2a8a7871ae410d5d1c440d8ced9217ec0f1b344edfe30673" +dependencies = [ + "bytes", + "engineioxide-core", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.19", + "tokio", + "tokio-tungstenite 0.30.0", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "engineioxide-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c5f98b2d2338ec0713bc222c753bfd18746bd76cbb115afa18aa07c6096553" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "rand 0.10.2", + "serde", + "serde_json", + "smallvec", +] + [[package]] name = "enigo" version = "0.2.1" @@ -3426,7 +3538,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -3547,6 +3659,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -4733,6 +4854,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + [[package]] name = "md-5" version = "0.10.6" @@ -4740,7 +4867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -4809,6 +4936,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "mime" version = "0.3.17" @@ -5921,7 +6070,7 @@ dependencies = [ "terminal-core", "thiserror 2.0.19", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tokio-util", "tool-runtime", "ts-rs", @@ -6011,7 +6160,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha1", + "sha1 0.10.7", "sha2", "similar", "tar", @@ -6320,10 +6469,11 @@ dependencies = [ "serde", "serde_json", "sha2", + "socketioxide", "sqlx", "tempfile", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower", "tower-http", "tracing", @@ -6523,6 +6673,7 @@ dependencies = [ "sha2", "shellexpand", "sherpa-onnx", + "sioc", "sse-stream", "ssh_config", "tar", @@ -6530,7 +6681,7 @@ dependencies = [ "terminal-core", "thiserror 2.0.19", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tokio-util", "ts-rs", "url", @@ -7227,7 +7378,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", + "digest 0.10.7", "hmac", "password-hash", "sha2", @@ -7239,7 +7390,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", ] @@ -8106,6 +8257,7 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", "h2", @@ -8117,6 +8269,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "mime_guess", "percent-encoding", "pin-project-lite", @@ -8334,8 +8487,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -8389,7 +8542,7 @@ dependencies = [ "ctr", "curve25519-dalek", "des", - "digest", + "digest 0.10.7", "elliptic-curve", "flate2", "futures", @@ -8407,7 +8560,7 @@ dependencies = [ "rand_core 0.6.4", "russh-cryptovec", "russh-keys", - "sha1", + "sha1 0.10.7", "sha2", "ssh-encoding", "ssh-key", @@ -8441,7 +8594,7 @@ dependencies = [ "ctr", "data-encoding", "der", - "digest", + "digest 0.10.7", "ecdsa", "ed25519-dalek", "elliptic-curve", @@ -8465,7 +8618,7 @@ dependencies = [ "russh-cryptovec", "sec1", "serde", - "sha1", + "sha1 0.10.7", "sha2", "spki", "ssh-encoding", @@ -9175,7 +9328,18 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -9186,7 +9350,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -9294,7 +9458,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -9335,6 +9499,43 @@ dependencies = [ "log", ] +[[package]] +name = "sioc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf4b31be7592e5dee0d6f879f84ed0575e075bdaf6f861c29e15886b080b587" +dependencies = [ + "bytes", + "bytestring", + "eioc", + "futures-util", + "miette", + "pin-project", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sioc-macros", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "sioc-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5058247825e87c0b75f7c59a7124da212102118ebbd1c92a16c0396e08f46117" +dependencies = [ + "darling 0.23.0", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -9372,6 +9573,58 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socketioxide" +version = "0.18.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d23ebfa62e73bcc05c40301f8b4cb8400489400c5e794e789300a04fd28a3b8" +dependencies = [ + "bytes", + "engineioxide", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "matchit 0.9.2", + "pin-project-lite", + "serde", + "socketioxide-core", + "socketioxide-parser-common", + "thiserror 2.0.19", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "socketioxide-core" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b1fd1f6de17dab074b59b59bda5e816afb2bc61406588bb1add8273b334800" +dependencies = [ + "arbitrary", + "bytes", + "engineioxide-core", + "futures-core", + "serde", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "socketioxide-parser-common" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a312f6f7686f60e83c1fc9d813c667866db1d345bfd91dda23db4c685112f996" +dependencies = [ + "bytes", + "itoa", + "serde", + "serde_json", + "socketioxide-core", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -10664,7 +10917,23 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.30.0", ] [[package]] @@ -11034,8 +11303,27 @@ dependencies = [ "rand 0.9.5", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.7", + "thiserror 2.0.19", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.2", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", "thiserror 2.0.19", + "url", ] [[package]] @@ -11180,7 +11468,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -12669,7 +12957,7 @@ dependencies = [ "rand 0.8.7", "serde", "serde_repr", - "sha1", + "sha1 0.10.7", "static_assertions", "tracing", "uds_windows", diff --git a/Cargo.toml b/Cargo.toml index 54bd7dd60e..52a1e18ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ members = [ exclude = [ "OpenBitFun-Installer/src-tauri", + "third_party/eioc", ] resolver = "2" @@ -269,6 +270,7 @@ qrcode = { version = "0.14", default-features = false } # WebSocket client tokio-tungstenite = "0.29" +sioc = { version = "0.5.0", default-features = false } # Local speech recognition sherpa-onnx = "1.13.4" @@ -294,6 +296,8 @@ ssh_config = "0.1" rustls = { version = "0.23", default-features = false } [patch.crates-io] +# Keep Socket.IO on the product-owned ring provider; see the patch note. +eioc = { path = "third_party/eioc" } # Tauri 2.11 loses keyboard focus for child webviews after Windows reactivates # an `unstable` multi-webview window. Use the upstream fix until released; # this revision also adopts tao 0.36, which includes the Windows input deadlock fix. diff --git a/deploy/miniapp-market/nginx-auth.openbitfun.com.conf b/deploy/miniapp-market/nginx-auth.openbitfun.com.conf index 29dbce1377..dfe4be5ae8 100644 --- a/deploy/miniapp-market/nginx-auth.openbitfun.com.conf +++ b/deploy/miniapp-market/nginx-auth.openbitfun.com.conf @@ -49,6 +49,17 @@ server { access_log /var/log/nginx/relay-v1-auth-access.log relay_v1_json; } + location ~ ^/v/1\.0\.1/api/(page-auth/(sign-in|login|client\.js)|auth/github/(start|poll))$ { + rewrite ^/v/1\.0\.1/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:19701; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $remote_addr; + limit_req zone=relay_v101_client_requests burst=20 nodelay; + limit_req_status 429; + access_log /var/log/nginx/relay-v1.0.1-auth-access.log relay_v1_json; + } + location = /sign-in { proxy_pass http://127.0.0.1:9710/miniapp/api/v1/auth/github/start; } diff --git a/deploy/relay-v1/README.md b/deploy/relay-v1/README.md index e8171fc37a..5a049ff3cf 100644 --- a/deploy/relay-v1/README.md +++ b/deploy/relay-v1/README.md @@ -1,20 +1,21 @@ # Official Relay v1 deployment The owner guide is [Relay Server](../../src/apps/relay-server/README.md). -Use this independent Compose project for `/v/1.0.0/`. Keep the older Relay +Use this independent Compose project for `/v/1.0.1/`. Keep `/v/1.0.0/` and the older Relay container, paths, image, database, and `/relay` proxy location intact. -Deploy from a committed checkout at `/srv/openbitfun-relay-v1/app`. Set +Deploy from a committed checkout at `/srv/openbitfun-relay-v1.0.1/app`. Set `RELAY_GIT_COMMIT` to that checkout's verified full commit. Build mobile web from the same checkout with `pnpm run build:mobile-web` and stage its `dist` -contents into `/srv/openbitfun-relay-v1/static`. Create `data` and `assets` +contents into `/srv/openbitfun-relay-v1.0.1/static`. Create `data` and `assets` under that root owned by UID/GID 10001 before starting Compose. -The Linux host network plus explicit `127.0.0.1:19700` listener lets the service +The Linux host network plus explicit `127.0.0.1:19701` listener lets the service verify the immediate proxy peer before trusting its overwritten forwarded IP. -Do not publish this listener on a public interface. Install `nginx-http.conf` in the Nginx http context and include -`nginx-location.conf` in the existing remote server after the container passes -its health check. The new location accepts the existing explicit WAF origin +Do not publish this listener on a public interface. Install `nginx-http.conf` as `/etc/nginx/conf.d/relay-v1.0.1.conf` and include +`nginx-location.conf` as `/etc/nginx/relay-v1.0.1-location.conf` in the existing remote server after the container passes +its health check. Keep the existing v1.0.0 includes. The new version uses independent admission zones. +The new location accepts the existing explicit WAF origin ranges and loopback; direct origin requests from other peers receive 403. Forwarded client IPs are recursively resolved only for those trusted WAF peers. Keep the range list synchronized with the WAF control plane. Raise @@ -22,9 +23,9 @@ peers. Keep the range list synchronized with the WAF control plane. Raise 16384; validate with `nginx -t` before a graceful reload. Published Pages use the existing official Relay address: -`https://remote.openbitfun.com/v/1.0.0/p/{github_username}/{slug}`. +`https://remote.openbitfun.com/v/1.0.1/p/{github_username}/{slug}`. Compose sets this public base URL and the separate sign-in base URL -`https://auth.openbitfun.com/v/1.0.0`. Users do not configure domains. +`https://auth.openbitfun.com/v/1.0.1`. Users do not configure domains. Install the versioned Pages sign-in locations from [`nginx-auth.openbitfun.com.conf`](../miniapp-market/nginx-auth.openbitfun.com.conf) in the existing auth server as well. Keep its marketplace sign-in routes intact. diff --git a/deploy/relay-v1/compose.yml b/deploy/relay-v1/compose.yml index ca0a2b053a..f07b8539c0 100644 --- a/deploy/relay-v1/compose.yml +++ b/deploy/relay-v1/compose.yml @@ -1,14 +1,14 @@ -name: openbitfun-relay-v1 +name: openbitfun-relay-v1.0.1 services: relay-v1: - image: openbitfun-relay-v1:${RELAY_GIT_COMMIT:?Set the verified source commit} + image: openbitfun-relay-v1.0.1:${RELAY_GIT_COMMIT:?Set the verified source commit} build: context: ../.. dockerfile: src/apps/relay-server/Dockerfile args: RELAY_GIT_COMMIT: ${RELAY_GIT_COMMIT:?Set the verified source commit} CARGO_BUILD_JOBS: "4" - container_name: openbitfun-relay-v1 + container_name: openbitfun-relay-v1.0.1 network_mode: host user: "10001:10001" read_only: true @@ -23,29 +23,30 @@ services: soft: 16384 hard: 16384 tmpfs: - - /tmp:size=64m,mode=1777 + # The encrypted payload owner admits 512 MiB, plus temporary-file overhead. + - /tmp:size=640m,mode=1777 environment: - RELAY_PORT: "19700" - RELAY_LISTEN_ADDR: 127.0.0.1:19700 + RELAY_PORT: "19701" + RELAY_LISTEN_ADDR: 127.0.0.1:19701 RELAY_STATIC_DIR: /app/static RELAY_DB_PATH: /app/data/relay.db RELAY_ROOM_WEB_DIR: /app/room-web RELAY_ASSET_STORE_MAX_BYTES: "1073741824" RELAY_CORS_ALLOW_ORIGINS: https://remote.openbitfun.com,https://auth.openbitfun.com - RELAY_PAGE_PUBLIC_BASE_URL: https://remote.openbitfun.com/v/1.0.0 - RELAY_PAGE_AUTH_BASE_URL: https://auth.openbitfun.com/v/1.0.0 + RELAY_PAGE_PUBLIC_BASE_URL: https://remote.openbitfun.com/v/1.0.1 + RELAY_PAGE_AUTH_BASE_URL: https://auth.openbitfun.com/v/1.0.1 RUST_LOG: info volumes: - - /srv/openbitfun-relay-v1/data:/app/data - - /srv/openbitfun-relay-v1/assets:/app/room-web - - /srv/openbitfun-relay-v1/static:/app/static:ro + - /srv/openbitfun-relay-v1.0.1/data:/app/data + - /srv/openbitfun-relay-v1.0.1/assets:/app/room-web + - /srv/openbitfun-relay-v1.0.1/static:/app/static:ro logging: driver: json-file options: max-size: 10m max-file: "3" healthcheck: - test: [CMD, curl, -fsS, http://127.0.0.1:19700/health] + test: [CMD, curl, -fsS, http://127.0.0.1:19701/health] interval: 15s timeout: 5s retries: 5 diff --git a/deploy/relay-v1/nginx-http.conf b/deploy/relay-v1/nginx-http.conf index 84d91eb326..8add8998d5 100644 --- a/deploy/relay-v1/nginx-http.conf +++ b/deploy/relay-v1/nginx-http.conf @@ -1,11 +1,11 @@ -# Install in /etc/nginx/conf.d/relay-v1.conf (http context). -map $http_upgrade $relay_v1_connection_upgrade { +# Install in /etc/nginx/conf.d/relay-v1.0.1.conf (http context). +map $http_upgrade $relay_v101_connection_upgrade { default upgrade; '' close; } # Same explicit Huawei WAF origin ranges as the existing market deployment. # Refresh from the WAF control plane when its origin source ranges change. -geo $realip_remote_addr $relay_v1_trusted_origin_peer { +geo $realip_remote_addr $relay_v101_trusted_origin_peer { default 0; 127.0.0.1 1; ::1 1; @@ -14,10 +14,10 @@ geo $realip_remote_addr $relay_v1_trusted_origin_peer { 101.44.169.0/24 1; 2405:f080:110::/120 1; } -limit_conn_zone $binary_remote_addr zone=relay_v1_client_connections:10m; -limit_conn_zone $server_name zone=relay_v1_global_connections:1m; -limit_req_zone $binary_remote_addr zone=relay_v1_client_requests:10m rate=100r/s; -limit_req_zone $server_name zone=relay_v1_global_requests:1m rate=2000r/s; -log_format relay_v1_json escape=json +limit_conn_zone $binary_remote_addr zone=relay_v101_client_connections:10m; +limit_conn_zone $server_name zone=relay_v101_global_connections:1m; +limit_req_zone $binary_remote_addr zone=relay_v101_client_requests:10m rate=100r/s; +limit_req_zone $server_name zone=relay_v101_global_requests:1m rate=2000r/s; +log_format relay_v101_json escape=json '{"time":"$time_iso8601","method":"$request_method","path":"$uri",' '"status":$status,"bytes":$body_bytes_sent,"requestTime":$request_time}'; diff --git a/deploy/relay-v1/nginx-location.conf b/deploy/relay-v1/nginx-location.conf index 7ae1498819..376d46f742 100644 --- a/deploy/relay-v1/nginx-location.conf +++ b/deploy/relay-v1/nginx-location.conf @@ -1,8 +1,8 @@ # Include inside the existing remote.openbitfun.com server block. -location = /v/1.0.0 { - return 308 /v/1.0.0/; +location = /v/1.0.1 { + return 308 /v/1.0.1/; } -location ^~ /v/1.0.0/ { +location ^~ /v/1.0.1/ { # Trust forwarded addresses only from the existing explicit WAF peers. # Recursive extraction selects the last non-trusted address, not a spoofed # client-supplied leftmost XFF entry. Relay receives one overwritten value. @@ -12,28 +12,28 @@ location ^~ /v/1.0.0/ { set_real_ip_from 2405:f080:110::/120; real_ip_header X-Forwarded-For; real_ip_recursive on; - if ($relay_v1_trusted_origin_peer = 0) { return 403; } + if ($relay_v101_trusted_origin_peer = 0) { return 403; } - limit_conn relay_v1_client_connections 256; - limit_conn relay_v1_global_connections 6144; + limit_conn relay_v101_client_connections 256; + limit_conn relay_v101_global_connections 6144; limit_conn_status 429; - limit_req zone=relay_v1_client_requests burst=200 nodelay; - limit_req zone=relay_v1_global_requests burst=1000 nodelay; + limit_req zone=relay_v101_client_requests burst=200 nodelay; + limit_req zone=relay_v101_global_requests burst=1000 nodelay; limit_req_status 429; - proxy_pass http://127.0.0.1:19700/; + proxy_pass http://127.0.0.1:19701/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $relay_v1_connection_upgrade; + proxy_set_header Connection $relay_v101_connection_upgrade; proxy_buffering off; proxy_request_buffering off; - client_max_body_size 49m; + client_max_body_size 65m; client_body_timeout 15s; proxy_connect_timeout 5s; proxy_read_timeout 140s; proxy_send_timeout 30s; - access_log /var/log/nginx/relay-v1-access.log relay_v1_json; + access_log /var/log/nginx/relay-v1.0.1-access.log relay_v101_json; } diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index 870d678466..100502b557 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -93,42 +93,32 @@ rendered, local included. ### Running-Turn attachment -The live WebView/DeviceEvent broadcast is a low-latency delivery path, not the -owner of a running Turn. Desktop and CLI Peer Runtime Hosts keep a materialized -projection of each eligible current Turn even when no client is subscribed. Events enter -that projection after the host's ordering/coalescing boundary and receive a -per-Session monotonic cursor plus a Runtime-process `streamId`. Text and -thinking chunks are materialized without collapsing segments across tool -boundaries; noisy tool progress is compacted. - -`restore_session_view` returns this additive `runtimeEventSnapshot`; the CLI -Peer Host applies its existing Peer-owned-Turn filter before recording or -returning the projection. The persisted Session record of an executing Turn is -a lagging checkpoint, not the live projection: `loadSessionHistory` and -`refreshPeerSessionSnapshot` must not paint that checkpoint's in-progress -tool rows. A restore that races a live store update must still return the -journal so attach can replay. Delivery of a live event to a product listener -is not acceptance — a dropped ToolEvent / TextChunk marks the projection -stale so the next attach replays instead of treating the cursor as current. -`finish()` covers those cursors only after the painted projection has caught -up with journal terminal tools; a matching cursor alone is not enough. -Overlapping attach transfers the in-flight fence rather than delivering it -onto a state machine that is about to reset. Hidden-document and -fresh-TextChunk liveness skips apply only to the 3s poll, not to a dirty -projection. During an attach, the frontend fences live events for -`(DeviceSurfaceId, SessionId)`, -replays the snapshot into an empty current-Turn projection, and then releases -only events newer than the snapshot cursor. A different `streamId` is a new -Runtime process and its cursors are never compared with the old stream. The -Surface epoch rejects a response from a device that is no longer rendered. -This makes attach independent of client-written intermediate checkpoints and -closes the snapshot/live race without restarting, cancelling, or moving the -Turn. Older Hosts may omit the field and use the persisted-snapshot fallback. -Controller presence is an admission boundary, not the lifetime owner: after a -Peer Host accepts a Turn, that Host continues executing and materializing it -while zero controllers are attached. A later controller attaches to the same -Runtime projection; controller loss alone must not cancel or interrupt the -Turn. Actual host event-stream loss remains a fail-closed continuity error. +Desktop and CLI own a durable session journal independently of attached +controllers. The publisher emits `session-record` updates carrying the existing +persisted Turn, ModelRound and item contracts, stable record IDs, revisions and +tombstones. Controllers merge the highest revision for each identity; an older +child record cannot regress a completed parent. Text, thinking and tool-body +content have this one authority. Permission and other control events remain +separate from transcript content. + +`SessionSubscriber` and `SessionStream` initially load the latest bounded page, +complete any fragment crossing the page boundary, and replay older pages through +the same replica. Contiguous WebSocket records are applied directly; reconnects +and sequence gaps fetch only the missing range. Backward pagination does not +move the forward receive cursor. The Surface epoch rejects records and responses +from a device that is no longer rendered. Desktop `RelaySessionHistory` owns the +subscription across initial loading, realtime delivery and older-page prefetch. + +Remote session loading does not combine a full `restore_session_view` response +with token deltas, and the former 3s reconciliation poll is not a Relay history +source. Local/non-Relay runtime surfaces still use their existing materialized +projection, event backfill and restore APIs; those local owners have not been +removed by the Relay migration. + +Controller presence admits a control request; it does not own an accepted Turn. +A disconnected controller leaves the Runtime, pending questions, permission +mailbox and journal alive. Source lag or a journal publication failure is +reported as a continuity gap; observer failure does not cancel accepted work. ### Blocking-interaction reattachment @@ -136,10 +126,17 @@ A push event is a notification, not the owner of an interaction that can block an Agent turn. The owning Runtime keeps every native `AskUserQuestion` and interactive permission request in a live mailbox until it is answered or cancelled; an `AskUserQuestion` registration is also removed if its owning Tool -future is dropped. `restore_session_view` returns an additive -`interactionSnapshot` containing the Session-filtered mailbox and monotonic -revisions. Desktop and CLI Peer Hosts expose the same field; older Hosts may -omit it and remain on the event-only compatibility path. +future is dropped. `get_session_interaction_mailbox` takes `{request:{sessionId}}` +and returns the existing `SessionInteractionSnapshot` contract: session-filtered +`userQuestions` and `permissions`, each with its monotonic revision. Desktop and +CLI expose the same small operation. Controllers read it on initial attachment +and reconnect independently of the durable transcript log; steady control events +update the presentation without repeatedly loading either mailbox or history. +Native question registration, answer, cancellation, timeout, and owning-future +drop also advance the Runtime mailbox watch. Hosts publish the coalesced +`session-interaction-changed` invalidation through the durable session stream; +controllers then refresh only the small mailbox. The watch retains the latest +revision rather than queueing question payloads on the Tool execution path. The frontend projects that mailbox into the active Surface container. Permission requests are retained for inactive Surfaces by source device, while missed @@ -261,7 +258,7 @@ FS) and must not be mixed with Peer Device Mode. keep working. `Disconnect` in the switcher is the separate, explicit action that ends a peer's control link and discards that peer's cached Surface state on the controller. It does not cancel a Turn the peer has already accepted; - reconnecting later reattaches to the Host-owned Runtime projection. Pending + reconnecting later reattaches to the Host-owned durable session journal. Pending controller-only interactions still follow their owner's mailbox or fail-closed policy. - Local-only commands (window chrome, updater, account login/logout, peer @@ -278,110 +275,66 @@ FS) and must not be mixed with Peer Device Mode. ## Transport -- The shared `services-integrations::remote_connect::relay_client` owns one - cancellable connection task. Its read and write futures remain full-duplex, - while heartbeat, dial, write deadlines and reconnect belong to that same - lifetime. Disconnect joins cancellation; replacing or dropping the client - retires the old socket and its reconnect attempts. A generation fence prevents - an old connection from publishing state into its replacement. Initial dial - failure returns to `Disconnected`. Reconnect verifies the selected Relay account and device context before - admitting new outgoing commands. Anonymous room contexts are not supported. - The outgoing queue holds at most 64 messages and reports saturation explicitly; - its failed-socket contents are never replayed. Dial/write deadlines are 15s, - heartbeat cadence is 30s, with due heartbeats taking priority over queued - commands, and inbound idle detection is 75s. These transport - facts do not imply authentication success or application-level acceptance. -- Mobile delegated-auth recovery retries only a real Relay HTTP 401. An - authenticated, encrypted host error mentioning `Unauthorized` or an upstream - `HTTP 401` is an application result and must not cause a second mutation. - Account-generation fences still apply before and after credential refresh. -- Controller: `PeerDeviceTransportAdapter` wraps product `invoke` as - `RemoteCommand::HostInvoke` over `account_device_rpc`. -- HostInvoke on the controller is **priority-queued** with four requests in - flight. Session restore / session-list / dialog / workspace-startup commands - outrank background `git_*` / `ssh_*` / `search_*` / FS / canvas / - editor RPCs so hydrate is not starved into relay HTTP 504s. Terminal commands - are always interactive priority, and one slot is kept free from normal and - low-priority work so input cannot be trapped behind slow polling requests. -- Idempotent read HostInvokes use a 10s per-attempt deadline and at most four - exponential-backoff retries. Mutating commands use a 30s deadline and are - not replayed unless both ends share an explicit idempotency contract. - `start_dialog_turn` and `start_acp_dialog_turn` use their stable - `(sessionId, turnId)` identity for bounded retry: the controller reuses the - exact payload, while the host coalesces concurrent attempts and caches the - completed result for the retry window. The controller enables this exception - only when the initial `peer_mode_ping` advertises - `idempotent_dialog_submit`, so mixed-version peers remain single-shot. - Other mutations remain single-shot because a timed-out outcome is unknown. - Identity-based Session rollback is sent only when `peer_mode_ping` advertises - `targeted_session_rollback`; older peers fail explicitly and never fall back - to controller-local files, history, or the removed numeric rollback command. - The desktop `account_device_rpc` command enforces the requested deadline - around the native HTTP future; the controller's Promise deadline is not - merely a UI timer. Failed session-list loads leave the spinner and expose an - explicit retry action. -- While Peer Mode is active, background noise is reduced further: - - controller-local SSH heartbeats and remote-workspace auto-reconnect pause - - Git / FilesPanel window-focus refresh pauses - - editor disk sync poll slows to 15s (from 1s) - - canvas snapshot poll slows to 15s (from 2s) - - workspace search-index poll slows to 30s idle / 5s active -- Peer: decrypt → allow/deny → execute on the peer host: - - Desktop: webview bridge `peer-host-invoke://request` → same Tauri handlers - as local UI → `peer_host_invoke_complete` - - CLI: the invocation-scoped CLI product runtime handles dialog submit/cancel - through the Agent Runtime SDK and session/snapshot gaps through one Core - compatibility facade — no webview and no second scheduler, persistence - manager, or event queue. Desktop-only surfaces (MiniApp / cron / ACP list) - return empty or no-op so hydrate does not fail. -- Events: peer agentic projection (and other product events such as terminal / - FS / MCP interaction) fan-out as `RemoteCommand::DeviceEvent` to attached - controllers; controller re-emits the same event names locally. This includes - SSH-backed remote PTY Ready / Data / Exit events created on B, not only B's - local terminal service events. -- Relay DeviceEvent delivery itself has no ACK/replay contract. The active chat - therefore attaches immediately when the selected Session becomes hydrated, - after Surface/visibility changes, and after a detected data gap. The Peer - Host's `runtimeEventSnapshot` plus `(streamId, cursor)` is the resumable - current-Turn contract: live events are fenced while the snapshot is in - flight, the materialized Turn is replayed, and only later cursors are - released. The 3s reconciliation remains a liveness retry and an older-Host - persisted-snapshot fallback, not the source of Turn continuity. The host - still overlays its authoritative in-memory Session state so an executing - Turn is not misclassified as interrupted history. Native blocking - interactions are reconciled from the Runtime-owned `interactionSnapshot` - after event replay, because a Turn can wait indefinitely without emitting - another text chunk or producing a newer persisted checkpoint. -- CLI Peer Host forwards only turns submitted through Peer Host and linked - child turns. A background-result follow-up inherits ownership only when its - Core-internal metadata identifies the exact tracked parent and source child - turns; if an unrelated turn is running in the same session, the result queues - behind it without losing Peer ownership. Completed source lineage uses a - bounded, one-shot tombstone while delivery waits on session serialization; - session drain or event-stream interruption clears it. Peer Host - requires an attached controller before submit and binds tool confirmation to - the exact observed tool and turn. Confirmable Peer tools always wait for the - controller even when the host's global policy skips confirmation, so an Agent - pauses until the controller responds; exact background-result follow-ups - retain this Peer-only confirmation requirement. The host keeps tracked Turns - running when the last controller detaches or goes offline and continues - materializing their Runtime projection for a later attach. Actual agent-event - subscription lag or closure remains a continuity failure: it cancels tracked - Turns and projects the existing dialog-turn-failed terminal event. Terminal - ownership remains tracked until the event reaches the delivery attempt, and a - closed local delivery queue uses the same direct DeviceEvent path. Delivery - targets are captured when an event is queued and rechecked against the - currently attached set before each send. A per-target delivery lease serializes - detach or offline removal with the local Relay enqueue attempt. An explicit - disconnect still restores the local controller UI and reports a warning when - the host does not confirm attachment teardown; that uncertainty concerns the - control link and controller-scoped interaction cleanup, not cancellation of - Host-accepted work. This boundary does not change the Relay envelope or add - ACK or replay. -- Relay `POST /api/devices/:id/rpc` still permits up to **120s** for generic - callers. Peer controllers normally cancel earlier through their per-command - 10s/30s deadlines; reverse proxies must still accommodate any other caller - that relies on the Relay maximum. +- One account-scoped Socket.IO connection owns RPC acknowledgements, method + registration, presence and session updates. Rust `relay_client` supplies account + epochs and lifecycle cancellation around `realtime_client`; TypeScript uses + `AccountRealtime`. Replacing an account retires its socket and pending replies. + Transient disconnection does not destroy the Runtime's tasks or journals. +- Relay `rpc-call` routes to the authenticated target's registered method and + carries the caller's deadline (120s when omitted). The server checks account + membership before routing. Missing acknowledgement after dispatch is an unknown + mutation outcome; a replacement socket is not grounds to execute it again. + Socket.IO server ping interval is 15s, ping timeout 45s, and connect timeout + 15s. These transport facts do not establish Runtime readiness. +- Relay admission reserves estimated in-flight RPC memory, with 16MiB per account + and 64MiB globally. Exhaustion rejects the call explicitly before submission. + Large encrypted RPC payloads use the separate HTTP bulk lane; TypeScript + `RpcPayload` inlines up to 128KiB and bounds one transfer block at 64MiB. + File upload uses bounded chunks rather than increasing the whole-file envelope. + Each upload action carries the captured `workspacePath` and saved SSH + `remoteConnectionId` (absent/empty denotes local), or a session identity. + The runtime binds transfer state to account, provider, and workspace root; + changing the selected workspace cannot redirect an in-flight upload. + The Relay stores ciphertext, not decrypted workspace content or credentials. +- Desktop `PeerDeviceTransportAdapter` orders each pending dispatch burst as + interactive, normal, then background work. It does not maintain an independent + in-flight count limit or reserved slot. Actual admission belongs to the Relay + memory budget. Its read and mutation deadlines use the same 120s + `DEFAULT_RPC_TIMEOUT_MS` contract as `AccountRealtime`; explicit caller + deadlines continue to flow to the native transport and server. Retryable reads + retain bounded exponential-backoff recovery. Explicitly idempotent dialog + submissions reuse their stable session/turn identity; ordinary mutations remain + single-shot because a missing acknowledgement is an unknown outcome. +- Mobile delegated-auth recovery retries only a Relay HTTP 401. A decrypted host + application error mentioning an upstream 401 must not repeat a mutation. + Captured account and target identities fence credential refresh and delivery. +- Controller product operations use `RemoteCommand::HostInvoke` and the Product + Operation Registry. Desktop dispatches through its Tauri bridge; CLI uses + boxed, invocation-scoped portable handlers. Unsupported operations return an + explicit reason. CLI has real workspace, file and terminal providers rather + than treating the absence of a desktop IDE as absence of these capabilities. +- Canonical session content travels through the durable encrypted journal, not + per-controller `DeviceEvent` fan-out. Remaining device events carry ancillary + product/control notifications. Their loss cannot become the authority for + transcript content or cancel a running Turn. Both direct record updates and + journal catch-up feed the same stable-ID replica. +- `get_session_interaction_mailbox` restores revisioned questions and permissions + on attach/reconnect. Runtime question changes produce a coalesced + `session-interaction-changed` journal event; controllers then read only that + small mailbox. Surface/event fences prevent stale responses from reviving + completed interactions. Answers still go to the existing Runtime owner. +- PTY execution and bounded replay history belong to the target, including saved + SSH workspaces. Local CLI publishes coalesced cursor notifications from a + watch channel, so a slow observer cannot exhaust a raw-output tap and silently + lose its subscription. SSH notification consumers tolerate broadcast lag and + read the retained cursor. Controllers fetch bounded replay pages and render + them in their terminal surface. Account retirement stops that publisher's + notification observers without terminating the PTY. +- During Peer Mode, controller-local SSH maintenance and selected background + editor/Git/search refreshes retain their existing noise-reduction policy. + These UI refresh policies are separate from durable session synchronization. + Explicit detach restores the controller shell; uncertainty about attachment + teardown does not cancel Host-accepted work. ## Workspace directory picking @@ -410,6 +363,31 @@ create the corresponding tree on A. Never forward A's selected destination to B through `export_local_file_to_path`; paths and permissions are host-specific and may represent a different operating system. +Session attachments bind file reads to the session workspace. Downloads without +an associated session capture `workspace_path` and the runtime's saved +`remote_connection_id` once and carry them on every metadata/chunk request. +The runtime resolves that explicit provider and rejects missing identities or +unknown saved profiles; changing its selected workspace cannot redirect an +in-flight download to a same-named local file. The legacy filesystem/terminal +HostInvoke adapters distinguish an explicit empty connection id (runtime-local) +from an omitted identity (native runtime path inference). Workspace menu actions +send the selected workspace identity; local and SSH roots with identical paths +must not select each other's provider. Controllers await each bounded +chunk write and verify offset, size, and file revision before accepting more. +A failed or cancelled native save is not reported as a completed destination. Desktop peer downloads use the controller-local +`local_file_download` sink: the native adapter checks the save dialog's destination +scope, creates private sibling staging, validates write offsets, and atomically +replaces the target only after the complete stream passes validation. Cancel and +failure discard staging; they never truncate or remove an existing destination. +The webview resource owns the sink, and no parent-directory permission is added. + + +Host directory observers subscribe once to the encrypted `@host/catalog` +stream. `host-catalog-changed` invalidates session/workspace lists; initial +attachment, reconnect, and foreground recovery coalesce directory refreshes. +Revision values are invalidations, not cross-process clocks. Device presence +comes from the account WebSocket, without a second periodic directory RPC. + ## Ownership - Command policy and peer capabilities (all surfaces): @@ -419,12 +397,7 @@ and may represent a different operating system. - CLI host invoke / fan-out: `src/apps/cli/src/peer_host/` (Core registry; no webview bridge). Device routing in `src/apps/cli/src/account.rs` special-cases `HostInvoke` / `DeviceEvent`. Same machine Desktop+CLI share one `device_id`; - last `AuthConnect` wins. -- Shared account settings sync engine: - `src/crates/assembly/core/src/service/remote_connect/settings_sync.rs` - (debounced push, 30s pull, persisted cursor); app wiring in - `src/apps/desktop/src/api/remote_connect_api.rs` and - `src/apps/cli/src/account_sync.rs`. + the authenticated connection epoch retires the previous connection. - Frontend mode + transport: `src/web-ui/src/infrastructure/peer-device/`, `adapters/peer-device-adapter.ts` - Surface routing / switcher: `deviceSurfaceRouting.ts`, diff --git a/docs/architecture/remote-workspace-transport.md b/docs/architecture/remote-workspace-transport.md index 6472491c08..6fc97ab78a 100644 --- a/docs/architecture/remote-workspace-transport.md +++ b/docs/architecture/remote-workspace-transport.md @@ -6,6 +6,38 @@ file-tool algorithms through Session-bound IO providers; search retains native acceleration with shared matching and reduction. The convergence section below describes this boundary and the remaining capability limits. +## Controller and runtime ownership + +Desktop and CLI are full OpenBitFun runtime hosts. Mobile apps and mobile-web +are controllers: they select a host and invoke its product operations through +the authenticated relay. They do not become a task runtime, filesystem owner, +SSH credential store, or terminal process host. + +The selected runtime owns tasks, sessions, workspaces, saved SSH connections, +credentials, files, and PTYs. A controller may open a new directory on that +runtime, or a new POSIX directory through one of that runtime's **saved** SSH +connections. It selects the saved connection ID returned by the runtime; it +cannot create an arbitrary SSH target or send a replacement host/credential. +The runtime reconnects using its saved configuration and credential vault. +Explicit connection identities must match exactly. Missing connections and +credentials return an error and preserve saved data; they never select another +host at the same path or fall back to the controller/local filesystem. + +Controller file editors pass `expectedHash` (SHA-256 of the UTF-8 bytes they +read) to `write_file_content`. The portable target service checks the revision +and writes under a per-target/path critical section, following Happy's +`registerCommonHandlers` optimistic file-write contract. An empty hash requests +creation only; omitting it preserves ordinary runtime write semantics. Conflict +errors retain the editor buffer. The lock serializes participating runtime +writes; it is not a filesystem transaction against unrelated external editors. + +Terminal output is retained by the PTY owner and read by monotonic byte cursor. +Relay notifications announce that output is available; controllers catch up +through bounded pages, detect evicted cursors explicitly, and do not repeatedly +transfer a complete terminal buffer while idle. Desktop and CLI expose the same +product operations; lack of a desktop window is not a reason to disable a +portable runtime capability. + ## Goals One saved target must have the same workspace semantics across: @@ -446,3 +478,21 @@ measured compatible accelerator. Moving a remote shell builder into a wrapper without sharing its semantics does not satisfy the gate. Remote Control, Peer Device and Detached Dispatch remain separate scenarios requiring their own regression evidence. + +Workspace file uploads use `workspace_file_upload` with `begin`, `append`, +`status`, `finish`, and `cancel` actions. The runtime selects the session/current +workspace provider and binds the transfer to its account, connection identity, +and workspace root. A caller-generated 32-byte random transfer ID makes begin +idempotent; acknowledged offsets and duplicate-last-chunk hashes allow recovery +without blindly replaying a mutation. Each chunk is at most 3 MiB and uses the +existing encrypted relay bulk transport. Relay never receives plaintext file +content. + +The services-core upload owner writes an exclusive temporary file beside the +destination, hashes incrementally, and publishes only after final length/digest +and optional `expectedHash` checks. Ordinary optimistic edits and upload commit +share the same target/path lock through comparison and rename. Disconnecting an +observer does not interrupt an accepted write; explicit account retirement +invalidates upload epochs and cleans staging files. Current streaming writers +support local files and SFTP workspaces; shell-only container providers answer +unsupported rather than buffering the entire upload or writing locally. diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 00613bc920..052a9dc38a 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **648** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **654** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **648** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **654** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index d1e96c1a85..2d675f2ebd 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,12 +2,12 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "124222af073f8c5164b15b625723c0e3b81f0b200e41855cd343ef1f07d5f39b", - "commandCount": 648, + "commandCount": 654, "coverage": { - "commandCount": 648, + "commandCount": 654, "documentedCommandCount": 611, - "implementationCommandCount": 37, - "implementationDigest": "3c5e64b6ff16d55d95c2a6b4f77046be009a554b395b4a52bf8ed7c162ab1093" + "implementationCommandCount": 43, + "implementationDigest": "df3e33bfde51134717334587b62fe6c202c5ea56f2b63e990432de70fd19e0cd" }, "commands": [ { @@ -206,6 +206,20 @@ "signature": "fn account_list_devices() -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "account_load_older_session", + "moduleId": "remote_connect", + "capabilityId": "feature.remote-connect", + "capabilityIds": [ + "feature.remote-connect" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::remote_connect_api::account_load_older_session", + "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", + "signature": "fn account_load_older_session(request: UnsubscribeSessionRequest) -> Result<(), String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "account_login", "moduleId": "remote_connect", @@ -270,6 +284,20 @@ "signature": "fn account_status() -> Result", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "account_subscribe_session", + "moduleId": "remote_connect", + "capabilityId": "feature.remote-connect", + "capabilityIds": [ + "feature.remote-connect" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::remote_connect_api::account_subscribe_session", + "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", + "signature": "fn account_subscribe_session(request: SubscribeSessionRequest) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "account_token_expired", "moduleId": "remote_connect", @@ -284,6 +312,20 @@ "signature": "fn account_token_expired() -> bool", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "account_unsubscribe_session", + "moduleId": "remote_connect", + "capabilityId": "feature.remote-connect", + "capabilityIds": [ + "feature.remote-connect" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::remote_connect_api::account_unsubscribe_session", + "sourceFile": "src/apps/desktop/src/api/remote_connect_api.rs", + "signature": "fn account_unsubscribe_session(request: UnsubscribeSessionRequest) -> Result<(), String>", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "acknowledge_external_ecosystems_command", "moduleId": "external_sources", @@ -3559,6 +3601,20 @@ "signature": "fn get_session_files( runtime: State<'_, DesktopRuntimeContext>, request: GetSessionFilesRequest, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "get_session_interaction_mailbox", + "moduleId": "agentic", + "capabilityId": "feature.ai-assistant", + "capabilityIds": [ + "feature.ai-assistant" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::agentic_api::get_session_interaction_mailbox", + "sourceFile": "src/apps/desktop/src/api/agentic_api.rs", + "signature": "fn get_session_interaction_mailbox( runtime: State<'_, DesktopRuntimeContext>, request: SessionInteractionMailboxRequest, ) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "get_session_lineage", "moduleId": "session", @@ -5232,6 +5288,20 @@ "signature": "fn load_session_turns( request: LoadSessionTurnsRequest, runtime: State<'_, DesktopRuntimeContext>, startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "local_file_download", + "moduleId": "local_file_download", + "capabilityId": "feature.files-editor", + "capabilityIds": [ + "feature.files-editor" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "api::local_file_download::local_file_download", + "sourceFile": "src/apps/desktop/src/api/local_file_download.rs", + "signature": "fn local_file_download( webview: tauri::Webview, request: LocalFileDownloadRequest, ) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "logout_subscription_account", "moduleId": "commands", @@ -9701,7 +9771,7 @@ "visibility": "documented", "rustPath": "api::terminal_api::terminal_get_history", "sourceFile": "src/apps/desktop/src/api/terminal_api.rs", - "signature": "fn terminal_get_history( session_id: String, state: State<'_, TerminalState>, ) -> Result", + "signature": "fn terminal_get_history( session_id: String, after_offset: Option, state: State<'_, TerminalState>, ) -> Result", "remoteWorkspacePolicy": "RemoteRouted" }, { @@ -10247,6 +10317,20 @@ "signature": "fn webdriver_bridge_result(request: WebdriverBridgeResultRequest) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "workspace_file_upload", + "moduleId": "commands", + "capabilityId": "feature.projects", + "capabilityIds": [ + "feature.projects" + ], + "documentedItemIds": [], + "visibility": "implementation", + "rustPath": "workspace_file_upload", + "sourceFile": "src/apps/desktop/src/api/commands.rs", + "signature": "fn workspace_file_upload( request: openbitfun_core::service::filesystem::upload::WorkspaceUploadRequest, ) -> Result", + "remoteWorkspacePolicy": "RemoteRouted" + }, { "id": "worktree_bind_session", "moduleId": "worktree", diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 2602c53a15..67020d2a6a 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -583,6 +583,7 @@ test('Services Core feature-free dependencies stay behind exact text and async I 'workspace-instructions', 'workspace-runtime', 'workspace-text-runtime', + 'workspace-transfer', ]), ); }); @@ -3888,6 +3889,7 @@ test('services-core capability profiles keep heavy owners out of the empty profi 'dep:tokio', 'tokio/fs', 'tokio/rt', + 'tokio/sync', ]); assert.deepEqual(profiles.get('product-identity'), ['dep:openbitfun-core-types']); assert.deepEqual(profiles.get('json-io'), [ @@ -4061,7 +4063,7 @@ test('Services Core accepts only the reviewed feature-owned Tokio runtime graph' features: { 'credential-vault': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt'], diff: ['dep:tokio', 'tokio/rt', 'tokio/time'], - filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt'], + filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync'], 'json-io': ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync', 'tokio/time'], 'local-storage': [ 'dep:tokio', @@ -4088,6 +4090,7 @@ test('Services Core accepts only the reviewed feature-owned Tokio runtime graph' 'tokio/sync', ], 'session-git': ['local-storage'], + 'workspace-transfer': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt', 'tokio/sync'], }, }; @@ -4108,7 +4111,7 @@ test('Services Core Tokio owners cannot be hidden behind an unreviewed alias', ( ], features: { diff: ['dep:tokio', 'tokio/rt', 'tokio/time'], - filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt'], + filesystem: ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync'], 'json-io': ['dep:tokio', 'tokio/fs', 'tokio/rt', 'tokio/sync', 'tokio/time'], 'local-storage': [ 'dep:tokio', @@ -4768,3 +4771,37 @@ test('capability contract consumers cannot remove reviewed dependency edges', as assert.ok(messages.some((message) => /openbitfun-plugin-runtime-client.*missing reviewed.*normal.*edge/.test(message))); assert.ok(messages.some((message) => /openbitfun-opencode-adapter.*missing reviewed.*dev.*edge/.test(message))); }); + +test('vendored Engine.IO remains in resolved feature audits without becoming a product owner', () => { + const owner = packageAt('owner', 'src/apps/example/Cargo.toml'); + const vendor = { ...packageAt('eioc', 'third_party/eioc/Cargo.toml'), version: '0.5.0' }; + const collect = members => collectCargoMetadataGraph({ + root: TEST_ROOT, + manifestPaths: [join(TEST_ROOT, 'Cargo.toml')], + loadMetadata: () => ({ + packages: [owner, vendor], workspace_members: members, + resolve: { nodes: [{ id: vendor.id, features: ['rustls-tls'], deps: [] }] }, + }), + }); + const graph = collect([owner.id]); + assert.deepEqual(graph.packages.map(pkg => pkg.name), ['owner']); + assert.ok(graph.resolvedPackageFeatures.some(record => record.name === 'eioc')); + assert.ok(collect([owner.id, vendor.id]).packages.some(pkg => pkg.name === 'eioc'), + 'a product workspace member cannot bypass ownership checks through a vendor path'); +}); + + +test('workspace transfer admits IO without process or networking capabilities', () => { + const pkg = { + name: 'openbitfun-services-core', + manifest_path: 'src/crates/services/services-core/Cargo.toml', + dependencies: [{ name: 'tokio', kind: null, optional: true, features: [] }], + features: { + 'workspace-transfer': ['dep:tokio', 'tokio/fs', 'tokio/io-util', 'tokio/rt', 'tokio/sync'], + }, + }; + assert.deepEqual(findTokioDependencyFeatureViolations([pkg]).filter(v => v.message.includes(':workspace-transfer ')), []); + pkg.features['workspace-transfer'].push('tokio/process', 'tokio/net'); + assert.ok(findTokioDependencyFeatureViolations([pkg]).some(v => + v.message.includes('workspace-transfer has unexpected effective Tokio capabilities'))); +}); diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index d211d3adc5..68ed649b53 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -48,6 +48,13 @@ function repositoryPath(root, path) { return result; } +// This crates.io source is patched only to select the product TLS provider. +// It is an external dependency, not an OpenBitFun runtime owner. Resolved +// feature checks below still include it and its full dependency closure. +function isVendoredExternalManifest(root, manifestPath) { + return repositoryPath(root, manifestPath) === 'third_party/eioc/Cargo.toml'; +} + function layerForManifest(manifestPath, { root, crateLayoutRules }) { const repoManifestPath = repositoryPath(root, manifestPath); if (repoManifestPath === null) { @@ -160,7 +167,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ const SERVICES_CORE_TOKIO_FEATURES = new Map([ ['credential-vault', ['fs', 'io-util', 'rt']], ['diff', ['rt', 'time']], - ['filesystem', ['fs', 'rt']], + ['filesystem', ['fs', 'rt', 'sync']], ['json-io', ['fs', 'rt', 'sync', 'time']], ['local-storage', ['fs', 'rt', 'sync', 'time']], ['permission', ['rt']], @@ -169,6 +176,7 @@ const SERVICES_CORE_TOKIO_FEATURES = new Map([ ['workspace-instructions', ['fs', 'io-util', 'rt']], ['workspace-text-runtime', ['rt']], ['workspace-runtime', ['fs', 'io-util', 'process', 'rt', 'sync', 'time']], + ['workspace-transfer', ['fs', 'io-util', 'rt', 'sync']], ]); const SERVICES_CORE_BASE_TOKIO_FEATURES = []; const SERVICES_INTEGRATIONS_TOKIO_AGGREGATES = new Set(['product-full']); @@ -2481,7 +2489,8 @@ export function discoverCargoManifestPaths(root) { } continue; } - if (entry.isFile() && entry.name === 'Cargo.toml') { + if (entry.isFile() && entry.name === 'Cargo.toml' + && !isVendoredExternalManifest(root, join(directory, entry.name))) { manifests.push(join(directory, entry.name)); } } @@ -2620,6 +2629,10 @@ export function collectCargoMetadataGraph({ continue; } const packageManifestKey = normalizedPath(pkg.manifest_path); + if (isVendoredExternalManifest(root, pkg.manifest_path) + && pkg.name === 'eioc' && pkg.version === '0.5.0' && !workspaceMemberIds.has(pkg.id)) { + continue; + } if (workspaceMemberIds.has(pkg.id)) { coveredManifests.add(packageManifestKey); } diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index d856776db6..926c055ba0 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -44,7 +44,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'services-core optional implementation dependencies must stay behind their exact owner capability', dependencies: [ { depName: 'aes-gcm', ownerFeatures: ['credential-vault'] }, - { depName: 'anyhow', ownerFeatures: ['credential-vault', 'dispatch-workspace', 'workspace-runtime'] }, + { depName: 'anyhow', ownerFeatures: ['credential-vault', 'dispatch-workspace', 'workspace-runtime', 'workspace-transfer'] }, { depName: 'async-trait', ownerFeatures: ['permission', 'workspace-runtime'] }, { depName: 'base64', ownerFeatures: ['credential-vault', 'filesystem'] }, { @@ -52,7 +52,7 @@ export const optionalDependencyFeatureOwnerRules = [ ownerFeatures: ['filesystem', 'local-storage', 'product-identity', 'workspace-persistence'], }, { depName: 'openbitfun-events', ownerFeatures: ['local-storage', 'session-event-format'] }, - { depName: 'openbitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime', 'workspace-persistence'] }, + { depName: 'openbitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime', 'workspace-persistence', 'workspace-transfer'] }, { depName: 'chrono', ownerFeatures: ['filesystem', 'local-storage', 'workspace-persistence'] }, { depName: 'chrono-tz', ownerFeatures: ['token-usage-statistics'] }, { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, @@ -84,6 +84,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'local-storage', 'runtime-ownership', 'workspace-identity', + 'workspace-transfer', ], }, { depName: 'which', ownerFeatures: ['process-runtime'] }, @@ -103,6 +104,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'workspace-instructions', 'workspace-runtime', 'workspace-text-runtime', + 'workspace-transfer', ], }, ], @@ -499,8 +501,9 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('permission', 'permission'), capabilityForwarder('workspace-runtime', 'runtime-event-port'), capabilityForwarder('workspace-runtime', 'workspace-ports'), + capabilityForwarder('workspace-transfer', 'workspace-ports'), ], - ['permission', 'workspace-runtime', 'workspace-persistence'], + ['permission', 'workspace-runtime', 'workspace-persistence', 'workspace-transfer'], )], ['openbitfun-services-integrations', capabilityConsumer( [capabilityEdge([], { optional: true })], @@ -1134,6 +1137,7 @@ export const coreClosedFeatureProfileRules = [ 'git', 'model-catalog', 'openbitfun-services-integrations/remote-connect', + 'openbitfun-services-core/workspace-transfer', ], allowedTransitiveFeatureRefs: [ 'ai-adapter-runtime', @@ -1462,6 +1466,7 @@ export const coreClosedFeatureProfileRules = [ 'dep:tokio', 'tokio/fs', 'tokio/rt', + 'tokio/sync', ], exact: true, reason: 'services-core filesystem must own only local file operations and recursive search dependencies', @@ -1564,6 +1569,17 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'services-core workspace-text-runtime must own only bounded asynchronous local workspace reads', }, + { + manifestPath: 'src/crates/services/services-core/Cargo.toml', + featureName: 'workspace-transfer', + requiredFeatureRefs: [ + 'dep:anyhow', 'dep:openbitfun-runtime-ports', + 'openbitfun-runtime-ports/workspace-ports', 'dep:sha2', 'dep:tokio', + 'tokio/fs', 'tokio/io-util', 'tokio/rt', 'tokio/sync', + ], + exact: true, + reason: 'workspace-transfer owns bounded upload IO and optimistic commit through injected workspace ports, without process or transport implementations', + }, { manifestPath: 'src/crates/services/services-core/Cargo.toml', featureName: 'workspace-runtime', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index befda8e0a2..a59739a94e 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -6262,8 +6262,8 @@ export const requiredContentRules = [ message: 'missing remote-connect QR compatibility export', }, { - regex: /pub use relay_client::\{[\s\S]*\bConnectionState\b[\s\S]*\bRelayClient\b[\s\S]*\bRelayEvent\b[\s\S]*\bRelayMessage\b[\s\S]*\}/, - message: 'missing remote-connect relay compatibility export', + regex: /pub use relay_client::\{[\s\S]*\bConnectionState\b[\s\S]*\bRelayClient\b[\s\S]*\bRelayEvent\b[\s\S]*\}/, + message: 'missing shared account relay lifecycle export', }, { regex: /\bpub struct RemoteSessionStateTracker\b/, diff --git a/src/apps/relay-server/Caddyfile b/src/apps/relay-server/Caddyfile index 8a6e0382ad..9cdec2869d 100644 --- a/src/apps/relay-server/Caddyfile +++ b/src/apps/relay-server/Caddyfile @@ -1,22 +1,15 @@ # Caddyfile for OpenBitFun Relay Server # Replace YOUR_DOMAIN with your actual domain, or use :443 with self-signed cert. # -# Body size: account sync POSTs full encrypted session bundles. Axum allows -# up to 64 MiB on /api/sync/* (see README). If you terminate TLS here, raise -# the proxy body limit to at least that size, e.g.: -# request_body { -# max_size 100MB -# } -# -# Device RPC (/api/devices/*/rpc) waits up to 120s for the peer HostInvoke -# response. Keep reverse-proxy read/response timeouts above that, or the -# proxy returns HTTP 504 before Axum can. +# Bulk RPC ciphertext uses /v1/rpc/payloads. Configure body limits for your +# deployment's file size needs; WebSocket frames carry small RPC envelopes. +# Keep WebSocket upgrade support and idle timeouts above the heartbeat window. # # IMPORTANT: URL structure when using a /relay path prefix # / → static homepage (from static/homepage/) # /relay/* → relay service (prefix stripped before proxying) # /relay/health → health check -# /relay/ws → WebSocket endpoint +# /relay/v1/updates → WebSocket endpoint # /relay/api/* → all API endpoints # # Option A: With a domain (Let's Encrypt auto-HTTPS), root path @@ -114,7 +107,7 @@ # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-Proto $scheme; # -# # WebSocket support (/relay/ws endpoint) +# # WebSocket support (/relay/v1/updates endpoint) # proxy_http_version 1.1; # proxy_set_header Upgrade $http_upgrade; # proxy_set_header Connection "upgrade"; diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 4633e48a3e..b77cca5fd6 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -4,7 +4,7 @@ The official Relay connects devices signed in to the same GitHub identity. GitHub identity is shared with the marketplaces. Users sign in from OpenBitFun; they do not create a Relay account or deploy a server. -The official endpoint is `https://remote.openbitfun.com/v/1.0.0`. This release is deployed with +The official endpoint is `https://remote.openbitfun.com/v/1.0.1`. This release is deployed with its own process, database, assets, and reverse-proxy location. An existing `/relay` deployment remains on its existing binary and data directory. @@ -43,7 +43,7 @@ Relay URL. A private Relay therefore needs a matching client build. and `src/mobile-web/src/services/pairingLink.ts`. Native clients have matching constants in KMP `core-transport/AccountDeviceLink.kt` and HarmonyOS `services/AccountDeviceLink.ets`; update the HarmonyOS account-link parser too. - Search for `https://remote.openbitfun.com/v/1.0.0` to verify every runtime + Search for `https://remote.openbitfun.com/v/1.0.1` to verify every runtime reference and corresponding test before building your distribution. 3. Decide who owns identity. You can retain the official GitHub identity authority, or run the [shared identity service](../../../deploy/miniapp-market/README.md) @@ -93,7 +93,7 @@ RELAY_PORT=9700 RELAY_DB_PATH=/var/lib/openbitfun-relay-v1/relay.db \ ``` Use the isolated [v1 Compose project](../../../deploy/relay-v1/README.md). -Set `RELAY_LISTEN_ADDR=127.0.0.1:19700` with host networking so the service can +Set `RELAY_LISTEN_ADDR=127.0.0.1:19701` with host networking so the service can verify the immediate loopback proxy peer. Invalid listener values fail startup. Expose only the TLS reverse proxy. Keep the database and asset paths distinct from older deployments. `relay-admin` supports listing and explicitly deleting accounts; GitHub login @@ -156,8 +156,8 @@ observed source address, and the upstream port must be unreachable externally. The Relay trusts forwarded client IPs only from an immediate loopback peer. ```nginx -location ^~ /v/1.0.0/ { - proxy_pass http://127.0.0.1:19700/; +location ^~ /v/1.0.1/ { + proxy_pass http://127.0.0.1:19701/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; @@ -196,17 +196,23 @@ location and process. | `POST /api/auth/provision-device` | Authorized SSH host bootstrap | | `GET /api/devices` | Same-account device directory | | `GET /api/devices/{id}/key` | Same-account device public key | -| `POST /api/devices/{id}/rpc` | Encrypted request/response forwarding | -| `POST /api/devices/{id}/messages` | Authenticated device responses and same-account messages | | `DELETE /api/devices/{id}` | Explicit device removal and revocation | -| `GET /ws` | Authenticated device presence and encrypted messages | - -`auth_connect` verifies a device token before WebSocket routing is enabled. -Devices receive requests over WebSocket and submit payloads through the HTTP -`messages` endpoint, which reserves memory before buffering. Correlation replies -must come from the expected account and device. Small legacy `device_message` -envelopes remain recognized; attachment-sized WebSocket ingress is rejected. -The versioned client and server must be deployed together for this transport. +| `GET /v1/updates` | Authenticated Socket.IO account, machine and session scopes | +| `POST /v1/sessions`, `GET /v1/sessions/{id}` | Opaque session metadata | +| `GET/POST /v3/sessions/{id}/messages` | Ordered encrypted session history and catch-up | +| `POST /v1/rpc/payloads`, `GET /v1/rpc/payloads/{id}` | Account-scoped encrypted bulk RPC bodies | + +Realtime clients authenticate the namespace and wait for `auth-ok` before +registering or calling methods. Machine-owned RPC methods route inside the +same account; only the selected target socket can acknowledge a request. +A lost acknowledgement reports an unknown outcome and never replays a mutation. +Small encrypted messages travel over the live connection; larger RPC bodies use +short-lived HTTP references. Session history uses durable records, independent +of those temporary references. Continuous session sequences apply directly; +missing sequences and reconnects use the same encrypted history API. + +The old `/ws`, HTTP device `rpc` and `messages` routes are retired. Deploy the +new client and server together under a separate versioned relay prefix. ## Configuration diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs index 9d69463b6b..6bc8d143e9 100644 --- a/src/apps/relay-server/src/main.rs +++ b/src/apps/relay-server/src/main.rs @@ -98,7 +98,10 @@ async fn main() -> anyhow::Result<()> { let listener = tokio::net::TcpListener::bind(cfg.listen_addr).await?; info!("Relay server listening on {}", cfg.listen_addr); - info!("Device WebSocket endpoint: ws://{}/ws", cfg.listen_addr); + info!( + "Realtime Socket.IO endpoint: ws://{}/v1/updates", + cfg.listen_addr + ); axum::serve( listener, @@ -165,7 +168,7 @@ mod tests { } for path in [ "/health", - "/ws", + "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/v1/updates", "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/api/devices", "/api/auth/login", "/privacy", diff --git a/src/apps/relay-server/test_incremental_upload.py b/src/apps/relay-server/test_incremental_upload.py deleted file mode 100644 index 31f07e190c..0000000000 --- a/src/apps/relay-server/test_incremental_upload.py +++ /dev/null @@ -1,340 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for the incremental web file upload feature on the relay server. - -Tests: - 1. Create room via WebSocket (keep connection alive) - 2. Legacy full upload (upload-web) with global content store - 3. check-web-files: all files should already exist - 4. check-web-files: 2 existing + 1 new - 5. upload-web-files: only the needed file - 6. Serve uploaded files via /r/{room_id}/ - 7. Second room reuses global store — zero uploads needed - 8. Hash mismatch rejection - -Usage: - python3 test_incremental_upload.py [relay_url] -""" - -import asyncio -import base64 -import hashlib -import json -import sys -import time -import urllib.request -import urllib.error - -try: - import websockets -except ImportError: - print("Installing websockets...") - import subprocess - subprocess.check_call([sys.executable, "-m", "pip", "install", "websockets", "-q"]) - import websockets - -RELAY_URL = sys.argv[1] if len(sys.argv) > 1 else "https://remote.openbitfun.com/relay" -WS_URL = RELAY_URL.replace("http://", "ws://").replace("https://", "wss://") + "/ws" - -PASS = 0 -FAIL = 0 - - -def green(s): - print(f"\033[32m PASS: {s}\033[0m") - - -def red(s): - print(f"\033[31m FAIL: {s}\033[0m") - - -def assert_eq(desc, expected, actual): - global PASS, FAIL - if expected == actual: - green(desc) - PASS += 1 - else: - red(f"{desc} (expected={expected!r}, actual={actual!r})") - FAIL += 1 - - -def assert_contains(desc, haystack, needle): - global PASS, FAIL - if needle in str(haystack): - green(desc) - PASS += 1 - else: - red(f"{desc} (expected to contain {needle!r}, got: {haystack!r})") - FAIL += 1 - - -def b64enc(s: str) -> str: - return base64.b64encode(s.encode()).decode() - - -def sha256hex(s: str) -> str: - return hashlib.sha256(s.encode()).hexdigest() - - -def http_get(url: str) -> tuple: - """Returns (status_code, body_text).""" - try: - req = urllib.request.Request(url) - with urllib.request.urlopen(req, timeout=10) as resp: - return resp.status, resp.read().decode() - except urllib.error.HTTPError as e: - return e.code, e.read().decode() if e.fp else "" - except Exception as e: - return 0, str(e) - - -def http_post_json(url: str, data: dict) -> tuple: - """Returns (status_code, parsed_json_or_None).""" - try: - body = json.dumps(data).encode() - req = urllib.request.Request(url, data=body, method="POST") - req.add_header("Content-Type", "application/json") - with urllib.request.urlopen(req, timeout=10) as resp: - text = resp.read().decode() - try: - return resp.status, json.loads(text) - except json.JSONDecodeError: - return resp.status, text - except urllib.error.HTTPError as e: - text = e.read().decode() if e.fp else "" - try: - return e.code, json.loads(text) - except (json.JSONDecodeError, Exception): - return e.code, text - except Exception as e: - return 0, str(e) - - -async def create_room_ws(room_id: str): - """Create a room via WebSocket and return the connection (kept alive).""" - ws = await websockets.connect(WS_URL) - await ws.send(json.dumps({ - "type": "create_room", - "room_id": room_id, - "device_id": f"test-{room_id}", - "device_type": "desktop", - "public_key": "dGVzdA==", - })) - resp = await asyncio.wait_for(ws.recv(), timeout=5) - data = json.loads(resp) - return ws, data - - -async def run_tests(): - global PASS, FAIL - - ts = int(time.time()) - room1 = f"test_incr_{ts}_1" - room2 = f"test_incr_{ts}_2" - - # Test data - file1_content = "Hello OpenBitFun Test" - file2_content = "body { margin: 0; background: #1a1a2e; }" - file3_content = "console.log('OpenBitFun incremental upload test');" - - file1_b64 = b64enc(file1_content) - file2_b64 = b64enc(file2_content) - file3_b64 = b64enc(file3_content) - - file1_hash = sha256hex(file1_content) - file2_hash = sha256hex(file2_content) - file3_hash = sha256hex(file3_content) - - file1_size = len(file1_content) - file2_size = len(file2_content) - file3_size = len(file3_content) - - print("=" * 50) - print(" Incremental Upload Test Suite") - print(f" Relay: {RELAY_URL}") - print(f" WS: {WS_URL}") - print("=" * 50) - print() - - # ── [0] Health check ── - print("[0] Health check") - status, body = http_get(f"{RELAY_URL}/health") - assert_eq("Server is healthy", 200, status) - assert_contains("Response contains 'healthy'", body, "healthy") - print() - - print("Test files prepared:") - print(f" index.html hash={file1_hash[:12]}... size={file1_size}") - print(f" assets/style.css hash={file2_hash[:12]}... size={file2_size}") - print(f" assets/app.js hash={file3_hash[:12]}... size={file3_size}") - print() - - # ── [1] Create rooms ── - print("[1] Create rooms via WebSocket (kept alive)") - ws1, data1 = await create_room_ws(room1) - assert_eq(f"Room 1 ({room1}) created", "room_created", data1.get("type")) - - ws2, data2 = await create_room_ws(room2) - assert_eq(f"Room 2 ({room2}) created", "room_created", data2.get("type")) - print() - - try: - # ── [2] Legacy full upload ── - print("[2] Legacy full upload (upload-web) to Room 1") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/{room1}/upload-web", - {"files": {"index.html": file1_b64, "assets/style.css": file2_b64}}, - ) - assert_eq("HTTP 200", 200, status) - assert_eq("Status is ok", "ok", resp.get("status") if isinstance(resp, dict) else "") - assert_eq("2 new files written", 2, resp.get("files_written", 0) if isinstance(resp, dict) else 0) - assert_eq("0 files reused (first upload)", 0, resp.get("files_reused", 0) if isinstance(resp, dict) else -1) - print() - - # ── [3] Serve uploaded files ── - print("[3] Serve uploaded files via /r/{room_id}/") - status, html = http_get(f"{RELAY_URL}/r/{room1}/index.html") - assert_eq("index.html status 200", 200, status) - assert_eq("index.html content correct", file1_content, html) - - status, css = http_get(f"{RELAY_URL}/r/{room1}/assets/style.css") - assert_eq("style.css status 200", 200, status) - assert_eq("style.css content correct", file2_content, css) - print() - - # ── [4] check-web-files: all exist ── - print("[4] check-web-files: all files should already exist in store") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/{room1}/check-web-files", - { - "files": [ - {"path": "index.html", "hash": file1_hash, "size": file1_size}, - {"path": "assets/style.css", "hash": file2_hash, "size": file2_size}, - ] - }, - ) - assert_eq("HTTP 200", 200, status) - needed = resp.get("needed", []) if isinstance(resp, dict) else [] - assert_eq("0 files needed", 0, len(needed)) - assert_eq("2 files exist", 2, resp.get("existing_count", 0) if isinstance(resp, dict) else 0) - assert_eq("Total count 2", 2, resp.get("total_count", 0) if isinstance(resp, dict) else 0) - print() - - # ── [5] check-web-files: 2 existing + 1 new ── - print("[5] check-web-files: 2 existing + 1 new file") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/{room1}/check-web-files", - { - "files": [ - {"path": "index.html", "hash": file1_hash, "size": file1_size}, - {"path": "assets/style.css", "hash": file2_hash, "size": file2_size}, - {"path": "assets/app.js", "hash": file3_hash, "size": file3_size}, - ] - }, - ) - assert_eq("HTTP 200", 200, status) - needed = resp.get("needed", []) if isinstance(resp, dict) else [] - assert_eq("1 file needed", 1, len(needed)) - assert_eq("2 files exist", 2, resp.get("existing_count", 0) if isinstance(resp, dict) else 0) - assert_eq("Needed file is assets/app.js", "assets/app.js", needed[0] if needed else "") - print() - - # ── [6] upload-web-files: only needed ── - print("[6] Upload only the needed file via upload-web-files") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/{room1}/upload-web-files", - { - "files": { - "assets/app.js": { - "content": file3_b64, - "hash": file3_hash, - } - } - }, - ) - assert_eq("HTTP 200", 200, status) - assert_eq("Status is ok", "ok", resp.get("status") if isinstance(resp, dict) else "") - assert_eq("1 file stored", 1, resp.get("files_stored", 0) if isinstance(resp, dict) else 0) - print() - - # ── [7] Verify newly uploaded file ── - print("[7] Verify newly uploaded file is served") - status, js = http_get(f"{RELAY_URL}/r/{room1}/assets/app.js") - assert_eq("app.js status 200", 200, status) - assert_eq("app.js content correct", file3_content, js) - print() - - # ── [8] Second room: all files reused ── - print("[8] Room 2: check-web-files should find all 3 in global store") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/{room2}/check-web-files", - { - "files": [ - {"path": "index.html", "hash": file1_hash, "size": file1_size}, - {"path": "assets/style.css", "hash": file2_hash, "size": file2_size}, - {"path": "assets/app.js", "hash": file3_hash, "size": file3_size}, - ] - }, - ) - assert_eq("HTTP 200", 200, status) - needed = resp.get("needed", []) if isinstance(resp, dict) else [] - assert_eq("0 files needed (all reused)", 0, len(needed)) - assert_eq("3 files exist in global store", 3, resp.get("existing_count", 0) if isinstance(resp, dict) else 0) - print() - - # ── [9] Room 2 files served via symlinks ── - print("[9] Room 2: files served correctly via symlinks") - status, html2 = http_get(f"{RELAY_URL}/r/{room2}/index.html") - assert_eq("Room 2 index.html correct", file1_content, html2) - - status, css2 = http_get(f"{RELAY_URL}/r/{room2}/assets/style.css") - assert_eq("Room 2 style.css correct", file2_content, css2) - - status, js2 = http_get(f"{RELAY_URL}/r/{room2}/assets/app.js") - assert_eq("Room 2 app.js correct", file3_content, js2) - print() - - # ── [10] Hash mismatch rejection ── - print("[10] Upload with wrong hash should be rejected") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/{room1}/upload-web-files", - { - "files": { - "bad.js": { - "content": file3_b64, - "hash": "0" * 64, - } - } - }, - ) - assert_eq("Wrong hash returns 400", 400, status) - print() - - # ── [11] check-web-files on nonexistent room ── - print("[11] check-web-files on nonexistent room should return 404") - status, resp = http_post_json( - f"{RELAY_URL}/api/rooms/nonexistent_room/check-web-files", - {"files": [{"path": "a.html", "hash": "abc", "size": 1}]}, - ) - assert_eq("Nonexistent room returns 404", 404, status) - print() - - finally: - await ws1.close() - await ws2.close() - - # ── Summary ── - print("=" * 50) - total = PASS + FAIL - if FAIL == 0: - print(f"\033[32m All {total} tests passed!\033[0m") - else: - print(f" Results: \033[32m{PASS} passed\033[0m, \033[31m{FAIL} failed\033[0m") - print("=" * 50) - - return FAIL == 0 - - -if __name__ == "__main__": - ok = asyncio.run(run_tests()) - sys.exit(0 if ok else 1) diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index bc18f342dd..977b88d554 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -297,6 +297,7 @@ mcp-runtime = [ "tokio/rt-multi-thread", ] remote-connect = [ + "openbitfun-services-core/workspace-transfer", "agent-runtime", "git", "model-catalog", diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 544752ea20..6dc6537d85 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -2431,12 +2431,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet snapshot_log_context: &str, ) -> OpenBitFunResult { let known_remote = workspace_service - .find_known_remote_workspace_for_path( + .resolve_remote_workspace_for_open( &path.to_string_lossy(), remote_connection_id, remote_ssh_host, ) - .await; + .await?; if known_remote.is_none() && !path.exists() { return Err(OpenBitFunError::service(format!( "Workspace path does not exist locally and is not a known remote SSH workspace: {}. Open it once from the desktop SSH remote UI so OpenBitFun can remember the connection, then try again.", @@ -8641,6 +8641,62 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + /// Read stable persisted identities plus already-completed runtime message + /// blocks under the same history mutation boundary. Streaming token buffers + /// are not copied; semantic messages enter context at block boundaries. + pub async fn load_relay_session_turns( + &self, + storage: &Path, + session_id: &str, + turn_id: Option<&str>, + ) -> OpenBitFunResult> { + let _mutation = self + .session_manager + .acquire_session_mutation(session_id) + .await?; + self.prepare_persisted_session_read_locked(storage, session_id) + .await?; + let mut turns = if let Some(turn_id) = turn_id { + let index = self + .session_manager + .get_session(session_id) + .and_then(|session| session.dialog_turn_ids.iter().position(|id| id == turn_id)) + .ok_or_else(|| { + OpenBitFunError::NotFound(format!("Session turn unavailable: {turn_id}")) + })?; + self.session_manager + .persistence_manager() + .load_dialog_turn(storage, session_id, index) + .await? + .into_iter() + .collect::>() + } else { + self.session_manager + .persistence_manager() + .load_visible_session_turns(storage, session_id) + .await? + }; + let context = self + .session_manager + .get_context_messages(session_id) + .await?; + for turn in &mut turns { + if turn.status == TurnStatus::InProgress { + let messages: Vec<_> = context + .iter() + .filter(|message| { + message.metadata.turn_id.as_deref() == Some(turn.turn_id.as_str()) + }) + .cloned() + .collect(); + let id = turn.turn_id.clone(); + let timestamp = turn.start_time; + SessionManager::append_generation_rounds(turn, &id, &messages, timestamp); + } + } + Ok(turns) + } + /// Export a transcript while retaining the same Session history boundary /// from marker admission through artifact generation. pub async fn export_visible_persisted_session_transcript( diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 36ec85425f..55a7f0ddca 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -4293,6 +4293,7 @@ impl ExecutionEngine { tool_definitions.clone(), Some(context_window), round_lifecycle, + Some(self.session_manager.as_ref()), ) .await { @@ -4452,13 +4453,15 @@ impl ExecutionEngine { &round_result.assistant_message, ); - // Update the in-memory message caches immediately so subsequent rounds see it. - if let Err(e) = self - .session_manager - .add_message(&context.session_id, round_result.assistant_message.clone()) - .await - { - warn!("Failed to update assistant message in memory: {}", e); + if !round_result.assistant_message_committed { + // Update the in-memory message caches immediately so subsequent rounds see it. + if let Err(e) = self + .session_manager + .add_message(&context.session_id, round_result.assistant_message.clone()) + .await + { + warn!("Failed to update assistant message in memory: {}", e); + } } // Add tool result messages to history @@ -6123,6 +6126,7 @@ mod tests { .with_round_id(round_id.to_string()); RoundResult { assistant_message: assistant, + assistant_message_committed: false, tool_calls: Vec::new(), tool_result_messages: vec![tool_result], has_more_rounds: true, diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index a5fb364624..b5d2885823 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -17,6 +17,7 @@ use crate::agentic::memories::{ use crate::agentic::permission_policy::{ permission_mode_from_context, resolve_effective_permission_policy, }; +use crate::agentic::session::SessionManager; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::pipeline::{ SubagentBatchExecutionPolicy as PipelineSubagentBatchExecutionPolicy, ToolExecutionContext, @@ -358,6 +359,7 @@ impl RoundExecutor { tool_definitions, context_window, &mut lifecycle, + None, ) .await } @@ -370,6 +372,7 @@ impl RoundExecutor { tool_definitions: Option>, context_window: Option, lifecycle: &mut ModelRoundLifecycle, + session_manager: Option<&SessionManager>, ) -> OpenBitFunResult { let round_started_at = lifecycle.started_at; let subagent_parent_info = context.subagent_parent_info.clone(); @@ -1044,6 +1047,7 @@ impl RoundExecutor { return Ok(RoundResult { assistant_message, + assistant_message_committed: false, tool_calls: vec![], tool_result_messages: vec![], has_more_rounds: false, @@ -1056,6 +1060,43 @@ impl RoundExecutor { }); } + let mut tool_calls = stream_result.tool_calls.clone(); + normalize_deferred_tool_calls_for_replay(&mut tool_calls); + + // Create assistant message (includes tool calls and thinking content, supports interleaved thinking mode) + let reasoning = if stream_result.full_thinking.is_empty() { + if stream_result.reasoning_content_present { + Some(String::new()) + } else { + None + } + } else { + Some(stream_result.full_thinking.clone()) + }; + let parsed_memory_citation = + Self::parsed_memory_citation_from_stream_result(&stream_result); + let model_response_replay = Self::model_response_replay(&stream_result); + let (clean_text, _) = strip_openbitfun_memory_citations(&stream_result.full_text); + let assistant_message = + Message::assistant_with_reasoning(reasoning, clean_text, tool_calls.clone()) + .with_turn_id(context.dialog_turn_id.clone()) + .with_round_id(round_id.clone()) + .with_thinking_signature(stream_result.thinking_signature.clone()) + .with_reasoning_content_kind(stream_result.reasoning_content_kind) + .with_memory_citation(parsed_memory_citation) + .with_model_response_replay(model_response_replay); + + // Commit the complete provider response before any tool can block on IO, + // approval, or cancellation. Tool results remain separate semantic messages. + let assistant_message_committed = if let Some(session_manager) = session_manager { + session_manager + .add_message(&context.session_id, assistant_message.clone()) + .await?; + true + } else { + false + }; + // Check cancellation token before executing tools if cancel_token.is_cancelled() { debug!( @@ -1067,9 +1108,6 @@ impl RoundExecutor { )); } - let mut tool_calls = stream_result.tool_calls.clone(); - normalize_deferred_tool_calls_for_replay(&mut tool_calls); - // Execute tool calls debug!( "Preparing to execute tool calls: count={}", @@ -1231,29 +1269,6 @@ impl RoundExecutor { }; let tool_phase_ms = elapsed_ms_u64(tool_phase_started_at); - // Create assistant message (includes tool calls and thinking content, supports interleaved thinking mode) - let reasoning = if stream_result.full_thinking.is_empty() { - if stream_result.reasoning_content_present { - Some(String::new()) - } else { - None - } - } else { - Some(stream_result.full_thinking.clone()) - }; - let parsed_memory_citation = - Self::parsed_memory_citation_from_stream_result(&stream_result); - let model_response_replay = Self::model_response_replay(&stream_result); - let (clean_text, _) = strip_openbitfun_memory_citations(&stream_result.full_text); - let assistant_message = - Message::assistant_with_reasoning(reasoning, clean_text, tool_calls.clone()) - .with_turn_id(context.dialog_turn_id.clone()) - .with_round_id(round_id.clone()) - .with_thinking_signature(stream_result.thinking_signature.clone()) - .with_reasoning_content_kind(stream_result.reasoning_content_kind) - .with_memory_citation(parsed_memory_citation) - .with_model_response_replay(model_response_replay); - debug!( "Tool execution completed, creating message: assistant_msg_len={}, tool_results={}", match &assistant_message.content { @@ -1304,6 +1319,7 @@ impl RoundExecutor { Ok(RoundResult { assistant_message, + assistant_message_committed, tool_calls, tool_result_messages, has_more_rounds, @@ -1923,6 +1939,172 @@ mod tests { ) } + struct SemanticCommitTestTool { + entered: Arc, + release: Arc, + } + + #[async_trait::async_trait] + impl crate::agentic::tools::framework::Tool for SemanticCommitTestTool { + fn name(&self) -> &str { + "SemanticCommitTest" + } + async fn description(&self) -> super::OpenBitFunResult { + Ok("semantic commit test".into()) + } + fn short_description(&self) -> String { + "semantic commit test".into() + } + fn is_readonly(&self) -> bool { + true + } + fn input_schema(&self) -> serde_json::Value { + json!({"type":"object"}) + } + async fn validate_input( + &self, + _: &serde_json::Value, + _: Option<&crate::agentic::tools::framework::ToolUseContext>, + ) -> crate::agentic::tools::framework::ValidationResult { + crate::agentic::tools::framework::ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + async fn call_impl( + &self, + _: &serde_json::Value, + _: &crate::agentic::tools::framework::ToolUseContext, + ) -> super::OpenBitFunResult> { + self.entered.notify_one(); + self.release.notified().await; + Ok(vec![crate::agentic::tools::framework::ToolResult::Result { + data: json!({"done":true}), + result_for_assistant: Some("done".into()), + image_attachments: None, + }]) + } + } + + #[tokio::test] + async fn semantic_tool_call_is_readable_while_tool_is_running() { + assert_semantic_tool_call_commit(false).await; + } + + #[tokio::test] + async fn semantic_tool_call_survives_cancellation_during_execution() { + assert_semantic_tool_call_commit(true).await; + } + + async fn assert_semantic_tool_call_commit(cancel: bool) { + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::{SessionContextStore, SessionManager, SessionManagerConfig}; + use crate::agentic::tools::pipeline::{ToolPipeline, ToolStateManager}; + use crate::agentic::tools::registry::ToolRegistry; + let temp = tempfile::tempdir().unwrap(); + let context_store = Arc::new(SessionContextStore::new()); + let manager = SessionManager::new( + context_store.clone(), + Arc::new( + PersistenceManager::new(Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests( + temp.path().into(), + ), + )) + .unwrap(), + ), + SessionManagerConfig { + enable_persistence: false, + ..Default::default() + }, + ); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let mut registry = ToolRegistry::new(); + registry.register_tool(Arc::new(SemanticCommitTestTool { + entered: entered.clone(), + release: release.clone(), + })); + let mut executor = test_round_executor(); + executor.tool_pipeline = Some(Arc::new(ToolPipeline::new( + Arc::new(tokio::sync::RwLock::new(registry)), + Arc::new(ToolStateManager::new(executor.event_queue.clone())), + None, + ))); + let server = RetryTestServer::new(vec![( + 200, + format!( + "data: {}\n\ndata: [DONE]\n\n", + json!({ + "id":"semantic", "object":"chat.completion.chunk", "created":1, "model":"retry-test-model", + "choices":[{"index":0,"delta":{"content":"Checking now", "tool_calls":[{"index":0,"id":"call-semantic","type":"function","function":{"name":"SemanticCommitTest","arguments":"{}"}}]},"finish_reason":"tool_calls"}] + }) + ), + )]); + let mut context = test_round_context(); + context.available_tools = vec!["SemanticCommitTest".into()]; + let cancellation = CancellationToken::new(); + executor.register_cancel_token("turn-1", cancellation.clone()); + let mut lifecycle = ModelRoundLifecycle::new(); + let execution = executor.execute_round_with_lifecycle( + server.client(), + context, + vec![super::AIMessage::user("Run the tool".into())], + None, + None, + &mut lifecycle, + Some(&manager), + ); + let observer = async { + entered.notified().await; + let messages = context_store.get_context_messages("session-1"); + assert_eq!( + messages.len(), + 1, + "semantic response must be committed before the slow tool starts" + ); + let encoded = serde_json::to_value(&messages[0]).unwrap(); + assert!(encoded.to_string().contains("call-semantic")); + assert!(encoded.to_string().contains("Checking now")); + let id = messages[0].id.clone(); + if cancel { + cancellation.cancel(); + executor + .tool_pipeline + .as_ref() + .unwrap() + .cancel_dialog_turn_tools("turn-1") + .await + .unwrap(); + } else { + release.notify_one(); + } + id + }; + let (result, committed_id) = tokio::time::timeout(Duration::from_secs(10), async { + tokio::join!(execution, observer) + }) + .await + .expect("tool must enter before its result completes"); + if cancel { + let messages = context_store.get_context_messages("session-1"); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].id, committed_id); + return; + } + let result = result.unwrap(); + assert!(result.assistant_message_committed); + assert_eq!(result.assistant_message.id, committed_id); + assert_eq!(result.tool_result_messages.len(), 1); + assert_eq!( + context_store.get_context_messages("session-1").len(), + 1, + "completion must not create a second assistant message" + ); + } + #[tokio::test] async fn cancelling_live_stream_does_not_record_a_failed_retry() { let body = format!( diff --git a/src/crates/assembly/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs index a6fbfbd060..267e69fec0 100644 --- a/src/crates/assembly/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -95,6 +95,8 @@ pub struct RoundContext { #[derive(Debug, Clone)] pub struct RoundResult { pub assistant_message: Message, + /// Complete tool-call response already committed before tool execution. + pub assistant_message_committed: bool, pub tool_calls: Vec, pub tool_result_messages: Vec, pub has_more_rounds: bool, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 81a366eef9..834e559c5b 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -7720,6 +7720,9 @@ impl SessionManager { }, duration_ms: None, }); + tool_item.status = Some( + if *is_error { "error" } else { "completed" }.to_string(), + ); tool_item.end_time = Some(timestamp); break; } @@ -7929,7 +7932,7 @@ impl SessionManager { Ok(()) } - fn append_generation_rounds( + pub(crate) fn append_generation_rounds( turn: &mut DialogTurnData, turn_id: &str, new_messages: &[Message], @@ -8010,8 +8013,12 @@ impl SessionManager { tool_item.subagent_model_id = previous.subagent_model_id.clone(); tool_item.subagent_model_display_name = previous.subagent_model_display_name.clone(); - tool_item.status = previous.status.clone().or(tool_item.status.clone()); - tool_item.interruption_reason = previous.interruption_reason.clone(); + // A runtime result settles the request immediately. A persisted + // waiting/running checkpoint must not overwrite that newer fact. + if tool_item.tool_result.is_none() { + tool_item.status = previous.status.clone().or(tool_item.status.clone()); + tool_item.interruption_reason = previous.interruption_reason.clone(); + } } } // Client-derived display cards are not part of the model's diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index a1d7596dcb..0da9453d74 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -499,6 +499,7 @@ fn recovered_write_has_potentially_truncated_marked_path( enum PermissionAuthorization { Allowed, + AllowedWithInput { updated_input: serde_json::Value }, UserRejected { feedback: Option }, PolicyDenied { reason: String }, } @@ -1312,6 +1313,7 @@ impl ToolPipeline { PermissionExecutionPlan::Awaiting(receivers) => receivers, }; + let mut updated_input = serde_json::Map::new(); let mut receivers = receivers.into_iter(); while let Some(pending) = receivers.next() { let request_id = pending.request_id().to_string(); @@ -1334,6 +1336,16 @@ impl ToolPipeline { match outcome { PermissionWaitOutcome::Replied(PermissionReply::Once | PermissionReply::Always) => { } + PermissionWaitOutcome::Replied(PermissionReply::OnceWithInput { + updated_input: patch, + }) => { + let patch = patch.as_object().ok_or_else(|| { + OpenBitFunError::Validation( + "Edited approval input must be an object".to_string(), + ) + })?; + updated_input.extend(patch.clone()); + } PermissionWaitOutcome::Replied(PermissionReply::Reject { feedback }) => { self.cancel_permission_request_ids( receivers @@ -1373,7 +1385,13 @@ impl ToolPipeline { } } - Ok(PermissionAuthorization::Allowed) + Ok(if updated_input.is_empty() { + PermissionAuthorization::Allowed + } else { + PermissionAuthorization::AllowedWithInput { + updated_input: serde_json::Value::Object(updated_input), + } + }) } async fn cancel_permission_request_ids(&self, request_ids: Vec, reason: String) { @@ -1802,7 +1820,7 @@ impl ToolPipeline { debug!("Starting tool execution: tool_id={}", tool_id); // Get task - let task = self.state_manager.get_task(&tool_id).ok_or_else(|| { + let mut task = self.state_manager.get_task(&tool_id).ok_or_else(|| { OpenBitFunError::NotFound(format!("Tool task not found: {}", tool_id)) })?; @@ -2054,6 +2072,19 @@ impl ToolPipeline { let rejected = match permission_authorization { Ok(PermissionAuthorization::Allowed) => None, + Ok(PermissionAuthorization::AllowedWithInput { updated_input }) => { + let arguments = match self.validate_approval_input(&task, updated_input).await { + Ok(arguments) => arguments, + Err(error) => { + self.cancellation_tokens.remove(&tool_id); + return Err(error); + } + }; + task.invocation.effective_arguments = arguments.clone(); + self.state_manager + .apply_hook_input_rewrite(&tool_id, arguments, None); + None + } Ok(PermissionAuthorization::UserRejected { feedback }) => { let reason = user_rejection_audit_reason(&tool_name, feedback.as_deref()); let result = build_user_rejected_tool_result( @@ -2571,6 +2602,74 @@ impl ToolPipeline { Ok(()) } + /// User approval edits reuse the tool's validation and policy owner before execution. + async fn validate_approval_input( + &self, + task: &ToolTask, + patch: serde_json::Value, + ) -> OpenBitFunResult { + let mut arguments = task + .invocation + .effective_arguments + .as_object() + .cloned() + .ok_or_else(|| { + OpenBitFunError::Validation( + "This tool does not support object input edits".to_string(), + ) + })?; + arguments.extend(patch.as_object().cloned().ok_or_else(|| { + OpenBitFunError::Validation("Edited approval input must be an object".to_string()) + })?); + let arguments = serde_json::Value::Object(arguments); + if let Some(rejection) = self + .non_relaxable_original_input_rejection(task, &arguments) + .await + { + return Err(OpenBitFunError::Validation( + rejection + .message + .unwrap_or_else(|| "This tool input is immutable".to_string()), + )); + } + let context = self.build_tool_use_context(task, CancellationToken::new()); + let tool = self + .tool_registry + .read() + .await + .get_tool(task.effective_tool_name()) + .ok_or_else(|| OpenBitFunError::NotFound("Approval tool is unavailable".to_string()))?; + if tool.is_concurrency_safe(Some(task.effective_arguments())) + && !tool.is_concurrency_safe(Some(&arguments)) + { + return Err(OpenBitFunError::Validation( + "Edited input changes the admitted concurrency class; submit it as a new tool call" + .to_string(), + )); + } + let validation = tool.validate_input(&arguments, Some(&context)).await; + if !validation.result { + return Err(OpenBitFunError::Validation( + validation + .message + .unwrap_or_else(|| "Invalid edited tool input".to_string()), + )); + } + let intents = tool.permission_intents(&arguments, &context)?; + if let PermissionPlanDraft::Rejected { reason } = self + .draft_permission_plan( + task.clone(), + task.effective_tool_name().to_string(), + intents, + context, + ) + .await? + { + return Err(OpenBitFunError::Validation(reason)); + } + Ok(arguments) + } + pub async fn reply_to_tool( &self, tool_id: &str, @@ -2590,6 +2689,19 @@ impl ToolPipeline { "Permission request not found for tool: {tool_id}" )) })?; + if let PermissionReply::OnceWithInput { updated_input } = &reply { + let task = request + .tool_call_id + .as_deref() + .and_then(|id| self.state_manager.get_task(id)) + .ok_or_else(|| { + OpenBitFunError::Validation( + "This permission request does not own editable tool input".to_string(), + ) + })?; + self.validate_approval_input(&task, updated_input.clone()) + .await?; + } manager .reply(&request.request_id, reply, PermissionReplySource::User) .await @@ -2946,12 +3058,12 @@ mod tests { async fn call_impl( &self, - _input: &serde_json::Value, + input: &serde_json::Value, _context: &ToolUseContext, ) -> OpenBitFunResult> { self.call_count.fetch_add(1, Ordering::SeqCst); Ok(vec![ToolResult::Result { - data: json!({ "written": true }), + data: json!({ "written": true, "input": input }), result_for_assistant: None, image_attachments: None, }]) @@ -4056,6 +4168,103 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn edited_approval_executes_merged_input_and_rejection_never_executes() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(store); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + let running = pipeline.clone(); + let execution = tokio::spawn(async move { + running + .execute_tools( + vec![ + test_tool_call("edited", "Write"), + test_tool_call("denied", "Write"), + ], + permission_test_context(), + ToolExecutionOptions::default(), + ) + .await + }); + wait_for_permission_request_count(&manager, 2).await; + pipeline + .reply_to_tool( + "edited", + PermissionReply::OnceWithInput { + updated_input: json!({"content":"approved replacement"}), + }, + ) + .await + .expect("edited approval"); + pipeline + .reply_to_tool("denied", PermissionReply::Reject { feedback: None }) + .await + .expect("reject"); + let results = execution.await.unwrap().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + results[0].result.result["input"]["content"], + "approved replacement" + ); + assert_eq!(results[1].result.result["category"], "user_rejected"); + assert_eq!( + pipeline + .state_manager + .get_task("edited") + .unwrap() + .effective_arguments()["content"], + "approved replacement" + ); + } + + #[tokio::test] + async fn edited_approval_rejects_immutable_invalid_and_changed_concurrency_inputs() { + let pipeline = test_tool_pipeline(); + let received = Arc::new(Mutex::new(None)); + pipeline + .tool_registry + .write() + .await + .register_tool(Arc::new(CapturingTestTool { + name: "Capture".to_string(), + received_arguments: received.clone(), + })); + let protected = + test_tool_task_with_arguments("protected", "Capture", json!({"city":"protected"})); + assert!(pipeline + .validate_approval_input(&protected, json!({"city":"safe"})) + .await + .unwrap_err() + .to_string() + .contains("protected")); + let ordinary = test_tool_task_with_arguments("ordinary", "Capture", json!({"city":"safe"})); + assert!(pipeline + .validate_approval_input(&ordinary, json!({"city":42})) + .await + .is_err()); + assert!(pipeline + .validate_approval_input(&ordinary, json!({"city":"unsafe"})) + .await + .unwrap_err() + .to_string() + .contains("concurrency")); + assert!(pipeline + .validate_approval_input(&ordinary, json!([])) + .await + .is_err()); + assert!(received.lock().unwrap().is_none()); + } + #[tokio::test] async fn v2_rejecting_one_parallel_tool_does_not_reject_sibling() { let store = Arc::new(MemoryPermissionStore::default()); diff --git a/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs index 962ba09934..5309bf35ec 100644 --- a/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs @@ -517,6 +517,12 @@ async fn handle_tool_ask(params: HookFunctionReverseAsk) -> PortResult Err(PortError::new( + PortErrorKind::Backend, + "Plugin-owned permission requests do not support edited input", + )), openbitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( openbitfun_runtime_ports::PermissionReply::Once, ) => Ok(HookFunctionReverseReply::Once), diff --git a/src/crates/assembly/core/src/service/filesystem/mod.rs b/src/crates/assembly/core/src/service/filesystem/mod.rs index b139d2f72b..078f819e0f 100644 --- a/src/crates/assembly/core/src/service/filesystem/mod.rs +++ b/src/crates/assembly/core/src/service/filesystem/mod.rs @@ -4,8 +4,12 @@ pub mod factory; pub mod listing; +#[cfg(feature = "ssh-remote")] +pub mod path_operations; pub mod service; pub mod types; +#[cfg(feature = "remote-connect")] +pub mod upload; pub use factory::FileSystemServiceFactory; pub use listing::{ @@ -14,3 +18,5 @@ pub use listing::{ }; pub use service::FileSystemService; pub use types::{DirectoryScanResult, DirectoryStats, FileSearchOptions, FileSystemConfig}; + +pub use openbitfun_services_core::filesystem::sort_directory_nodes; diff --git a/src/crates/assembly/core/src/service/filesystem/path_operations.rs b/src/crates/assembly/core/src/service/filesystem/path_operations.rs new file mode 100644 index 0000000000..b14696d1e5 --- /dev/null +++ b/src/crates/assembly/core/src/service/filesystem/path_operations.rs @@ -0,0 +1,408 @@ +//! Product path routing shared by headless and graphical hosts. +//! Concrete IO stays in the existing local and SSH filesystem services. + +use super::FileSystemService; +use crate::service::remote_ssh::{ + get_remote_workspace_manager, lookup_remote_connection_with_hint, RemoteFileService, +}; + +async fn remote( + path: &str, + hint: Option<&str>, +) -> Result, String> { + if hint == Some("") { + return Ok(None); + } + let hint = hint.map(str::trim).filter(|value| !value.is_empty()); + let entry = if let Some(id) = hint { + crate::service::remote_ssh::workspace_state::lookup_remote_connection_scoped(path, id).await + } else { + lookup_remote_connection_with_hint(path, None).await + }; + let Some(entry) = entry else { + if hint.is_some() { + return Err( + "Remote workspace is unavailable or does not own the requested path".into(), + ); + } + return Ok(None); + }; + let manager = + get_remote_workspace_manager().ok_or("Remote workspace manager is unavailable")?; + let service = manager + .get_file_service() + .await + .ok_or("Remote file service is unavailable")?; + Ok(Some((service, entry.connection_id))) +} + +pub async fn read_text( + service: &FileSystemService, + path: &str, + hint: Option<&str>, +) -> Result { + if let Some((fs, id)) = remote(path, hint).await? { + return String::from_utf8(fs.read_file(&id, path).await.map_err(|e| e.to_string())?) + .map_err(|_| "File is not UTF-8 text".into()); + } + let file = service.read_file(path).await.map_err(|e| e.to_string())?; + if file.is_binary || file.encoding != "UTF-8" { + return Err("File is not UTF-8 text".into()); + } + Ok(file.content) +} + +/// Serialize optimistic writes by target/path. As in Happy's writeFile handler, +/// expected_hash is checked on the runtime, never by a controller-side read. +/// Empty hash means create-only; absent hash preserves ordinary runtime writes. +/// External editors are not participants in this in-process critical section. +async fn write_guard(key: String) -> std::sync::Arc> { + openbitfun_services_core::file_write_lock::write_guard(key) +} + +pub async fn write_text( + service: &FileSystemService, + path: &str, + content: &str, + hint: Option<&str>, +) -> Result<(), String> { + write_text_checked(service, path, content, hint, None).await +} + +pub async fn write_text_checked( + service: &FileSystemService, + path: &str, + content: &str, + hint: Option<&str>, + expected_hash: Option<&str>, +) -> Result<(), String> { + write_target_checked( + service, + path, + content, + remote(path, hint).await?, + expected_hash, + ) + .await +} + +/// A desktop path resolver has already established that this is host-local. +pub async fn write_local_text_checked( + service: &FileSystemService, + path: &str, + content: &str, + expected_hash: Option<&str>, +) -> Result<(), String> { + write_target_checked(service, path, content, None, expected_hash).await +} + +async fn write_target_checked( + service: &FileSystemService, + path: &str, + content: &str, + target: Option<(RemoteFileService, String)>, + expected_hash: Option<&str>, +) -> Result<(), String> { + use sha2::{Digest, Sha256}; + let lock_path = if target.is_none() { + match tokio::fs::canonicalize(path).await { + Ok(path) => path.to_string_lossy().into_owned(), + Err(_) => { + let value = std::path::Path::new(path); + match (value.parent(), value.file_name()) { + (Some(parent), Some(name)) => tokio::fs::canonicalize(parent) + .await + .map(|parent| parent.join(name).to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()), + _ => path.to_string(), + } + } + } + } else { + path.to_string() + }; + let key = format!( + "{}\0{}", + target + .as_ref() + .map(|(_, id)| id.as_str()) + .unwrap_or("local"), + lock_path + ); + let lock = write_guard(key).await; + let _guard = lock.lock().await; + if let Some(expected) = expected_hash { + let exists = if let Some((fs, id)) = &target { + fs.exists(id, path).await.map_err(|e| e.to_string())? + } else { + service.exists(path).await + }; + if expected.is_empty() { + if exists { + return Err( + "FILE_CONFLICT: File already exists; refresh before replacing it".into(), + ); + } + } else { + if !exists { + return Err("FILE_CONFLICT: File was removed after it was read".into()); + } + let bytes = if let Some((fs, id)) = &target { + fs.read_file(id, path).await.map_err(|e| e.to_string())? + } else { + service + .read_file_bytes(path) + .await + .map_err(|e| e.to_string())? + }; + if format!("{:x}", Sha256::digest(&bytes)) != expected { + return Err( + "FILE_CONFLICT: File changed after it was read; refresh before saving".into(), + ); + } + } + } + if let Some((fs, id)) = target { + fs.write_workspace_file(&id, path, content.as_bytes()) + .await + .map_err(|e| e.to_string()) + } else { + service + .write_file_with_options( + path, + content, + openbitfun_services_core::filesystem::FileOperationOptions { + // Interactive saves do not request retained sibling backups. + // Explicit backup operations retain their own policy. + backup_on_overwrite: false, + ..Default::default() + }, + ) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + } +} + +pub async fn rename( + service: &FileSystemService, + from: &str, + to: &str, + hint: Option<&str>, +) -> Result<(), String> { + let source = remote(from, hint).await?; + let destination = remote(to, hint).await?; + match (source, destination) { + (Some((fs, id)), Some((_, target))) if id == target => { + fs.rename(&id, from, to).await.map_err(|e| e.to_string()) + } + (None, None) => service.move_file(from, to).await.map_err(|e| e.to_string()), + _ => Err("Rename cannot cross filesystem connections".into()), + } +} + +pub async fn remove( + service: &FileSystemService, + path: &str, + directory: bool, + recursive: bool, + hint: Option<&str>, +) -> Result<(), String> { + if let Some((fs, id)) = remote(path, hint).await? { + return if !directory { + fs.remove_file(&id, path).await + } else if recursive { + fs.remove_dir_all(&id, path).await + } else { + fs.remove_dir(&id, path).await + } + .map_err(|e| e.to_string()); + } + if directory { + service.delete_directory(path, recursive).await + } else { + service.delete_file(path).await + } + .map_err(|e| e.to_string()) +} + +pub async fn create_directory( + service: &FileSystemService, + path: &str, + hint: Option<&str>, +) -> Result<(), String> { + if let Some((fs, id)) = remote(path, hint).await? { + return fs + .create_dir_all(&id, path) + .await + .map_err(|e| e.to_string()); + } + service + .create_directory(path) + .await + .map_err(|e| e.to_string()) +} + +pub async fn exists( + service: &FileSystemService, + path: &str, + hint: Option<&str>, +) -> Result { + if let Some((fs, id)) = remote(path, hint).await? { + return fs.exists(&id, path).await.map_err(|e| e.to_string()); + } + Ok(service.exists(path).await) +} + +#[cfg(test)] +mod tests { + #[tokio::test] + async fn explicit_local_scope_bypasses_same_path_remote_routing() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_string_lossy().to_string(); + std::fs::write(temp.path().join("local.txt"), "local bytes").unwrap(); + let manager = crate::service::remote_ssh::workspace_state::init_remote_workspace_manager(); + manager + .register_remote_workspace( + root.clone(), + "path-ops-shadow".into(), + "Other provider".into(), + "test-host".into(), + ) + .await; + let service = super::FileSystemService::default(); + let path = format!("{root}/local.txt"); + assert_eq!( + super::read_text(&service, &path, Some("")).await.unwrap(), + "local bytes" + ); + super::write_text_checked(&service, &path, "updated local", Some(""), None) + .await + .unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "updated local"); + manager + .unregister_remote_workspace("path-ops-shadow", &root) + .await; + } + + use super::*; + + #[tokio::test] + async fn interactive_saves_and_conflicts_do_not_leave_sibling_backups() { + use sha2::{Digest, Sha256}; + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("edited.txt"); + let path = path.to_str().unwrap(); + let service = FileSystemService::default(); + write_text_checked(&service, path, "first", None, Some("")) + .await + .unwrap(); + let first_hash = format!("{:x}", Sha256::digest(b"first")); + write_text_checked(&service, path, "second", None, Some(&first_hash)) + .await + .unwrap(); + let error = write_text_checked(&service, path, "stale", None, Some(&first_hash)) + .await + .unwrap_err(); + assert!(error.contains("FILE_CONFLICT")); + assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), "second"); + write_local_text_checked(&service, path, "third", None) + .await + .unwrap(); + write_text_checked(&service, path, "fourth", None, None) + .await + .unwrap(); + let entries: Vec<_> = std::fs::read_dir(root.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("edited.txt")]); + } + + #[tokio::test] + async fn optimistic_writes_reject_stale_and_create_collisions() { + use sha2::{Digest, Sha256}; + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("revision.txt"); + let path = path.to_str().unwrap(); + let service = FileSystemService::default(); + write_local_text_checked(&service, path, "initial", Some("")) + .await + .unwrap(); + assert!( + write_local_text_checked(&service, path, "overwrite", Some("")) + .await + .is_err() + ); + let hash = format!("{:x}", Sha256::digest(b"initial")); + let (first, second) = tokio::join!( + write_local_text_checked(&service, path, "first", Some(&hash)), + write_local_text_checked(&service, path, "second", Some(&hash)) + ); + assert_ne!(first.is_ok(), second.is_ok()); + assert!( + write_local_text_checked(&service, path, "stale", Some(&hash)) + .await + .is_err() + ); + assert_ne!(read_text(&service, path, None).await.unwrap(), "stale"); + } + + #[tokio::test] + async fn explicit_missing_remote_never_reads_or_mutates_local_twin() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("twin.txt"); + tokio::fs::write(&path, "local-secret").await.unwrap(); + let path = path.to_str().unwrap(); + let service = FileSystemService::default(); + let hint = Some("unavailable-filesystem-test-connection"); + assert!(read_text(&service, path, hint).await.is_err()); + assert!(write_text(&service, path, "changed", hint).await.is_err()); + assert!(remove(&service, path, false, false, hint).await.is_err()); + assert!(exists(&service, path, hint).await.is_err()); + assert_eq!( + tokio::fs::read_to_string(path).await.unwrap(), + "local-secret" + ); + } + + #[tokio::test] + async fn local_file_management_round_trip_and_nonrecursive_delete() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("managed"); + let first = directory.join("first.txt"); + let second = directory.join("second.txt"); + let service = FileSystemService::default(); + create_directory(&service, directory.to_str().unwrap(), None) + .await + .unwrap(); + write_text(&service, first.to_str().unwrap(), "hello 世界", None) + .await + .unwrap(); + rename( + &service, + first.to_str().unwrap(), + second.to_str().unwrap(), + None, + ) + .await + .unwrap(); + assert_eq!( + read_text(&service, second.to_str().unwrap(), None) + .await + .unwrap(), + "hello 世界" + ); + assert!( + remove(&service, directory.to_str().unwrap(), true, false, None) + .await + .is_err() + ); + remove(&service, second.to_str().unwrap(), false, false, None) + .await + .unwrap(); + remove(&service, directory.to_str().unwrap(), true, false, None) + .await + .unwrap(); + } +} diff --git a/src/crates/assembly/core/src/service/filesystem/service.rs b/src/crates/assembly/core/src/service/filesystem/service.rs index 902db0628a..05b4e76276 100644 --- a/src/crates/assembly/core/src/service/filesystem/service.rs +++ b/src/crates/assembly/core/src/service/filesystem/service.rs @@ -23,9 +23,17 @@ async fn read_remote_directory_contents( path: &str, preferred_remote_connection_id: Option<&str>, ) -> Option>> { + // Present empty identity is an explicit runtime-local scope, never path inference. + if preferred_remote_connection_id.is_some_and(|id| id.is_empty()) { + return None; + } let explicit_connection_id = preferred_remote_connection_id .map(str::trim) .filter(|value| !value.is_empty()); + #[cfg(feature = "ssh-remote")] + if let Some(connection_id) = explicit_connection_id { + return Some(read_saved_directory_contents(path, connection_id).await); + } let remote_entry = if let Some(connection_id) = explicit_connection_id { crate::service::remote_ssh::workspace_state::lookup_remote_connection_scoped( path, @@ -62,18 +70,7 @@ async fn read_remote_directory_contents( Some( match file_service.read_dir(&entry.connection_id, path).await { - Ok(entries) => Ok(entries - .into_iter() - .filter(|entry| entry.name != "." && entry.name != "..") - .map(|entry| { - FileTreeNode::new( - entry.path.clone(), - entry.name.clone(), - entry.path, - entry.is_dir, - ) - }) - .collect()), + Ok(entries) => Ok(remote_directory_nodes(entries)), Err(error) => Err(OpenBitFunError::service(format!( "Failed to read remote directory: {}", error @@ -82,6 +79,82 @@ async fn read_remote_directory_contents( ) } +#[cfg(feature = "remote-workspace")] +fn remote_directory_nodes( + entries: Vec, +) -> Vec { + entries + .into_iter() + .filter(|entry| entry.name != "." && entry.name != "..") + .map(|entry| { + let mut node = + FileTreeNode::new(entry.path.clone(), entry.name, entry.path, entry.is_dir); + node.size = entry.size; + node.last_modified = entry + .modified + .and_then(|seconds| i64::try_from(seconds).ok()) + .and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0)) + .map(|time| time.to_rfc3339()); + node.is_symlink = Some(entry.is_symlink); + node.permissions = entry.permissions; + node + }) + .collect() +} + +#[cfg(feature = "ssh-remote")] +async fn read_saved_directory_contents( + path: &str, + connection_id: &str, +) -> OpenBitFunResult> { + let state = crate::service::remote_ssh::workspace_state::ensure_saved_connection_services() + .await + .map_err(OpenBitFunError::service)?; + let ssh = state + .get_ssh_manager() + .await + .ok_or_else(|| OpenBitFunError::service("SSH manager is unavailable"))?; + let files = state + .get_file_service() + .await + .ok_or_else(|| OpenBitFunError::service("Remote file service is unavailable"))?; + read_saved_directory_with_provider(path, connection_id, &ssh, &files).await +} + +#[cfg(feature = "ssh-remote")] +async fn read_saved_directory_with_provider( + path: &str, + connection_id: &str, + ssh: &crate::service::remote_ssh::SSHConnectionManager, + files: &crate::service::remote_ssh::RemoteFileService, +) -> OpenBitFunResult> { + if !path.starts_with('/') || path.contains('\0') { + return Err(OpenBitFunError::service( + "Remote directory must be an absolute POSIX path", + )); + } + if !ssh + .get_saved_connections() + .await + .iter() + .any(|profile| profile.id == connection_id) + { + return Err(OpenBitFunError::service( + "Directory connection is not saved on this runtime; local fallback was not attempted", + )); + } + ssh.ensure_connected(connection_id) + .await + .map_err(|error| OpenBitFunError::service(error.to_string()))?; + // A saved profile owns credentials, not a workspace root. Browsing must not + // register or select a workspace before the user chooses a directory. + let entries = files + .read_dir(connection_id, path) + .await + .map_err(|error| OpenBitFunError::service(error.to_string()))?; + Ok(remote_directory_nodes(entries)) +} + /// Without the `remote-workspace` feature there is no SSH provider to route /// to. A request that carries a remote marker (an explicit connection id or a /// path owned by an opened workspace of kind `Remote`) is refused here so it @@ -91,6 +164,10 @@ async fn read_remote_directory_contents( path: &str, preferred_remote_connection_id: Option<&str>, ) -> Option>> { + // Present empty identity is an explicit runtime-local scope, never path inference. + if preferred_remote_connection_id.is_some_and(|id| id.is_empty()) { + return None; + } use crate::service::remote_ssh::workspace_state::{ is_remote_path, remote_workspace_not_compiled_message, }; @@ -611,6 +688,31 @@ mod tests { use super::FileSystemService; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; + #[tokio::test] + async fn explicit_local_scope_does_not_follow_a_same_path_remote_workspace() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().to_string_lossy().to_string(); + std::fs::write(temp.path().join("local.txt"), "local bytes").unwrap(); + let manager = init_remote_workspace_manager(); + manager + .register_remote_workspace( + root.clone(), + "same-path-other-runtime".into(), + "Other provider".into(), + "test-host".into(), + ) + .await; + let service = FileSystemService::default(); + let nodes = service + .get_directory_contents_with_remote_hint(&root, Some("")) + .await + .unwrap(); + assert!(nodes.iter().any(|node| node.name == "local.txt")); + manager + .unregister_remote_workspace("same-path-other-runtime", &root) + .await; + } + #[tokio::test] async fn registered_remote_path_without_file_provider_fails_closed() { let temp = tempfile::tempdir().expect("tempdir"); @@ -677,3 +779,47 @@ mod tests { ); } } + +#[cfg(all(test, feature = "ssh-remote"))] +mod saved_directory_tests { + use super::*; + #[tokio::test] + async fn unknown_saved_profile_cannot_read_a_same_named_local_directory() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("private-local.txt"), "local").unwrap(); + let ssh = + crate::service::remote_ssh::SSHConnectionManager::new(temp.path().join("profiles")); + let files = crate::service::remote_ssh::RemoteFileService::new(Arc::new( + tokio::sync::RwLock::new(Some(ssh.clone())), + )); + let error = read_saved_directory_with_provider( + &temp.path().to_string_lossy(), + "missing-saved-id", + &ssh, + &files, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("not saved on this runtime")); + } + #[test] + fn remote_directory_keeps_metadata_needed_for_runtime_sorting() { + let mut nodes = remote_directory_nodes(vec![crate::service::remote_ssh::RemoteDirEntry { + name: "large.txt".into(), + path: "/outside/registered/workspaces/large.txt".into(), + is_dir: false, + is_file: true, + is_symlink: true, + size: Some(200_000_000), + modified: Some(1_700_000_000), + permissions: Some("rw-------".into()), + }]); + let node = nodes.pop().unwrap(); + assert_eq!(node.size, Some(200_000_000)); + assert_eq!( + node.last_modified.as_deref(), + Some("2023-11-14T22:13:20+00:00") + ); + assert_eq!(node.is_symlink, Some(true)); + } +} diff --git a/src/crates/assembly/core/src/service/filesystem/upload.rs b/src/crates/assembly/core/src/service/filesystem/upload.rs new file mode 100644 index 0000000000..a9898bfd3e --- /dev/null +++ b/src/crates/assembly/core/src/service/filesystem/upload.rs @@ -0,0 +1,180 @@ +//! Product assembly for the runtime-owned workspace upload service. +use openbitfun_services_core::workspace_transfer::{ + TransferScope, UploadStatus, UploadTarget, UploadTransfers, MAX_UPLOAD_CHUNK, +}; +use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceUploadRequest { + pub action: String, + pub transfer_id: String, + pub path: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub total_bytes: Option, + #[serde(default)] + pub sha256: Option, + #[serde(default)] + pub expected_hash: Option, + #[serde(default)] + pub offset: Option, + #[serde(default)] + pub content_base64: Option, +} +fn transfers() -> &'static UploadTransfers { + static TRANSFERS: OnceLock = OnceLock::new(); + TRANSFERS.get_or_init(Default::default) +} + +pub async fn retire_account_uploads(account_id: &str) -> Result<(), String> { + transfers() + .retire_account(account_id) + .await + .map_err(|error| error.to_string()) +} + +pub async fn workspace_file_upload( + account_id: String, + request: WorkspaceUploadRequest, +) -> Result { + use crate::service_agent_runtime::CoreServiceAgentRuntime; + use base64::Engine; + let (target, target_id) = CoreServiceAgentRuntime::scoped_remote_file_target_with_identity( + &request.path, + request.session_id.as_deref(), + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + ) + .await?; + let scope = TransferScope { + account_id, + target_id, + workspace_root: target.root, + }; + let id = request.transfer_id; + let result = match request.action.as_str() { + "begin" => { + transfers() + .begin( + id, + scope, + UploadTarget { + fs: target.fs, + path: target.path, + remote: target.remote, + }, + request.total_bytes.ok_or("Upload totalBytes is required")?, + request.sha256.ok_or("Upload sha256 is required")?, + request.expected_hash, + ) + .await + } + "append" => { + let encoded = request + .content_base64 + .ok_or("Upload contentBase64 is required")?; + if encoded.len() > (MAX_UPLOAD_CHUNK + 2) / 3 * 4 { + return Err("Upload chunk exceeds the bounded transfer size".into()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| "Invalid upload base64")?; + transfers() + .append( + &scope, + &id, + request.offset.ok_or("Upload offset is required")?, + &bytes, + ) + .await + } + "status" => transfers().status(&scope, &id).await, + "finish" => transfers().finish(&scope, &id).await, + "cancel" => { + let status = transfers() + .status(&scope, &id) + .await + .map_err(|error| error.to_string())?; + transfers() + .cancel(&scope, &id) + .await + .map_err(|error| error.to_string())?; + Ok(status) + } + _ => return Err("Unknown workspace upload action".into()), + }; + result.map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn upload_requires_explicit_workspace_without_session() { + let request = serde_json::from_value( + json!({"action":"begin","transferId":"unused","path":"/tmp/file"}), + ) + .unwrap(); + let error = workspace_file_upload("scope-test".into(), request) + .await + .unwrap_err(); + assert!(error.contains("workspace identity is required"), "{error}"); + } + + #[tokio::test] + async fn upload_stays_bound_to_explicit_local_workspace() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + let transfer = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let account = format!("scope-{}", uuid::Uuid::new_v4()); + let make = |action: &str, root: &std::path::Path| { + json!({ + "action":action,"transferId":transfer,"path":root.join("new.txt"),"workspacePath":root, + "remoteConnectionId":"","totalBytes":5,"sha256":"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "expectedHash":"","offset":0,"contentBase64":"aGVsbG8=" + }) + }; + workspace_file_upload( + account.clone(), + serde_json::from_value(make("begin", &second)).unwrap(), + ) + .await + .unwrap(); + assert!(workspace_file_upload( + account.clone(), + serde_json::from_value(make("append", &first)).unwrap() + ) + .await + .is_err()); + workspace_file_upload( + account.clone(), + serde_json::from_value(make("append", &second)).unwrap(), + ) + .await + .unwrap(); + workspace_file_upload( + account, + serde_json::from_value(make("finish", &second)).unwrap(), + ) + .await + .unwrap(); + assert_eq!(std::fs::read(second.join("new.txt")).unwrap(), b"hello"); + assert!(!first.join("new.txt").exists()); + } +} diff --git a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs index 00ed91bc7b..8f41cdda3f 100644 --- a/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs +++ b/src/crates/assembly/core/src/service/remote_connect/account_runtime.rs @@ -196,7 +196,28 @@ impl AccountRuntime { self.read_account_context().await.is_ok() } - pub async fn try_restore_session(&self) -> Option { + pub async fn try_restore_session(self: &Arc) -> Option { + if let Ok(Some(loaded)) = session_store::load_session_detailed() { + if openbitfun_services_integrations::remote_connect::account::is_retired_official_relay( + &loaded.relay_url, + ) { + if let Some(device_id) = loaded.device_id.as_deref() { + if let Err(error) = DeviceIdentity::adopt_account_device_id(device_id) { + log::warn!("Failed to adopt migrating account device id: {error}"); + return None; + } + } + return match self.login_with_identity().await { + Ok(result) => Some(result.user_id), + Err(error) => { + log::warn!( + "New Relay sign-in required; previous credential retained: {error}" + ); + None + } + }; + } + } let transition = self.begin_account_transition().await; self.host.stop_device_routing().await; let restored = match session_store::load_session_detailed() { @@ -205,7 +226,6 @@ impl AccountRuntime { Ok(url) => url, Err(error) => { log::warn!("Ignoring invalid persisted relay URL: {error}"); - session_store::clear_session(); transition.finish(); return None; } @@ -314,6 +334,15 @@ impl AccountRuntime { return Err(anyhow!("account context changed")); }; self.host.stop_device_routing().await; + if let Some(previous) = previous_account_context.as_ref() { + if let Err(error) = crate::service::filesystem::upload::retire_account_uploads( + &previous.session.user_id, + ) + .await + { + log::warn!("Failed to clean up retired account uploads: {error}"); + } + } session_store::clear_session(); let user_id = session.user_id.clone(); @@ -379,6 +408,11 @@ impl AccountRuntime { log::info!("Signalled the background account routing owner to shut down"); } if let Ok((session, relay_url)) = self.read_account_context_raw().await { + if let Err(error) = + crate::service::filesystem::upload::retire_account_uploads(&session.user_id).await + { + log::warn!("Failed to clean up retired account uploads: {error}"); + } let _ = AccountClient::new() .revoke_token(&relay_url, &session) .await; @@ -411,8 +445,18 @@ impl AccountRuntime { transition.finish(); return false; } + let retired_user = context + .as_ref() + .map(|context| context.session.user_id.clone()); *context = None; drop(context); + if let Some(user_id) = retired_user { + if let Err(error) = + crate::service::filesystem::upload::retire_account_uploads(&user_id).await + { + log::warn!("Failed to clean up retired account uploads: {error}"); + } + } self.token_expired.store(true, Ordering::Relaxed); session_store::clear_session(); transition.finish(); diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index f06364887c..405dfc358e 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -38,14 +38,23 @@ pub mod account { pub use openbitfun_services_integrations::remote_connect::account::*; } +pub mod session_subscriber { + pub use openbitfun_services_integrations::remote_connect::session_subscriber::*; +} + +pub mod session_log { + pub use openbitfun_services_integrations::remote_connect::session_log::*; +} + +pub mod session_records { + pub use openbitfun_services_integrations::remote_connect::session_records::*; +} + pub mod session_store { pub use openbitfun_services_integrations::remote_connect::session_store::*; } -pub use account::{ - build_relay_websocket_url, validate_relay_base_url, AccountClient, AccountSession, - DelegateToken, -}; +pub use account::{validate_relay_base_url, AccountClient, AccountSession, DelegateToken}; pub use device::DeviceIdentity; pub use encryption::{decrypt_from_base64, encrypt_to_base64, KeyPair}; pub use pairing::PairingState; @@ -149,7 +158,8 @@ pub struct RemoteConnectService { /// Stores the peer description (e.g. "Telegram(7096812005)") when a bot is active. bot_connected_info: Arc>>, /// The single account-authenticated transport for every Relay endpoint. - device_relay_client: Arc>>, + device_relay_client: Arc>>>, + session_publisher: Arc>>>, device_relay_lifecycle: Arc>, device_connection_generation: AtomicU64, active_device_connection_id: Arc>>, @@ -187,6 +197,7 @@ impl RemoteConnectService { weixin_bot: Arc::new(RwLock::new(None)), bot_connected_info: Arc::new(RwLock::new(None)), device_relay_client: Arc::new(RwLock::new(None)), + session_publisher: Arc::new(RwLock::new(None)), device_relay_lifecycle: Arc::new(Mutex::new(())), device_connection_generation: AtomicU64::new(0), active_device_connection_id: Arc::new(RwLock::new(None)), @@ -847,8 +858,8 @@ impl RemoteConnectService { /// active (i.e. `start_device_connection` has been called and not yet /// disconnected). pub async fn is_device_connected(&self) -> bool { - let guard = self.device_relay_client.read().await; - let Some(client) = guard.as_ref() else { + let client = self.device_relay_client.read().await.clone(); + let Some(client) = client else { return false; }; matches!( @@ -873,7 +884,7 @@ impl RemoteConnectService { token: &str, device_name: &str, ) -> Result<( - tokio::sync::mpsc::UnboundedReceiver, + tokio::sync::mpsc::Receiver, String, u64, )> { @@ -881,10 +892,8 @@ impl RemoteConnectService { // Disconnect previous device connection if any. self.stop_device_connection_inner().await; - let ws_url = build_relay_websocket_url(relay_url)?; - let (client, mut event_rx) = RelayClient::new(); - client.connect(&ws_url).await?; + client.connect(relay_url).await?; client.connect_authenticated(token, device_name).await?; // Wait for AuthOk (or AuthError) before proceeding so that the @@ -906,6 +915,16 @@ impl RemoteConnectService { if let Err(e) = DeviceIdentity::adopt_account_device_id(&device_id) { log::warn!("Failed to adopt AuthOk device_id: {e}"); } + let publisher = session_log::SessionPublisher::start_for_host( + user_id, + device_id.clone(), + relay_url.to_owned(), + token.to_owned(), + ) + .await?; + let publisher = Arc::new(publisher); + start_session_interaction_publication(&publisher); + *self.session_publisher.write().await = Some(publisher); authenticated_device_id = Some(device_id); } Ok(Some(relay_client::RelayEvent::AuthError { message })) => { @@ -939,14 +958,14 @@ impl RemoteConnectService { .device_connection_generation .fetch_add(1, Ordering::AcqRel) + 1; - *device_client_arc.write().await = Some(client); + *device_client_arc.write().await = Some(Arc::new(client)); *active_connection_id.write().await = Some(connection_id); *self.authenticated_device_id.write().await = Some(authenticated_device_id.clone()); *self.device_relay_url.write().await = Some(relay_url.to_string()); let authenticated_id = self.authenticated_device_id.clone(); // Spawn event forwarder that updates presence state; the raw event stream // is also forwarded to a new channel for the caller to consume. - let (forward_tx, forward_rx) = tokio::sync::mpsc::unbounded_channel(); + let (forward_tx, forward_rx) = tokio::sync::mpsc::channel(128); tokio::spawn(async move { while let Some(event) = event_rx.recv().await { let _effect = device_lifecycle.lock().await; @@ -962,7 +981,9 @@ impl RemoteConnectService { } _ => {} } - let _ = forward_tx.send(event); + if forward_tx.try_send(event).is_err() { + break; + } } let _effect = device_lifecycle.lock().await; let mut active = active_connection_id.write().await; @@ -984,11 +1005,19 @@ impl RemoteConnectService { self.stop_device_connection_inner().await; } + pub async fn session_publisher(&self) -> Option> { + self.session_publisher.read().await.clone() + } + async fn stop_device_connection_inner(&self) { + if let Some(publisher) = self.session_publisher.write().await.take() { + publisher.close(); + } *self.authenticated_device_id.write().await = None; *self.device_relay_url.write().await = None; *self.active_device_connection_id.write().await = None; - if let Some(client) = self.device_relay_client.write().await.take() { + let client = self.device_relay_client.write().await.take(); + if let Some(client) = client { client.disconnect().await; } self.online_devices.write().await.clear(); @@ -1002,9 +1031,11 @@ impl RemoteConnectService { encrypted_data: &str, nonce: &str, ) -> Result<()> { - let guard = self.device_relay_client.read().await; - let client = guard - .as_ref() + let client = self + .device_relay_client + .read() + .await + .clone() .ok_or_else(|| anyhow::anyhow!("device routing not connected"))?; client .send_device_message(target_device_id, correlation_id, encrypted_data, nonce) @@ -1012,8 +1043,9 @@ impl RemoteConnectService { } /// Send through the exact device-routing client captured by the caller. - /// The lifecycle lease prevents connection replacement between the owner - /// check and the transport write. + /// Capture the transport under the lifecycle lease, then release that + /// lease before awaiting IO. Replacement disconnects the captured epoch; + /// a slow acknowledgement must not block logout or presence delivery. pub async fn send_device_message_if_connection( &self, expected_connection_id: u64, @@ -1026,10 +1058,13 @@ impl RemoteConnectService { if *self.active_device_connection_id.read().await != Some(expected_connection_id) { return Ok(false); } - let guard = self.device_relay_client.read().await; - let client = guard - .as_ref() + let client = self + .device_relay_client + .read() + .await + .clone() .ok_or_else(|| anyhow::anyhow!("device routing not connected"))?; + drop(_lifecycle); client .send_device_message(target_device_id, correlation_id, encrypted_data, nonce) .await?; @@ -1044,3 +1079,134 @@ impl RemoteConnectService { #[cfg(test)] mod host_lifecycle_tests; + +/// Publish the owning runtime's stable persisted session records. +pub async fn synchronize_session_records( + publisher: &session_log::SessionPublisher, + session_id: &str, +) -> Result<(), String> { + crate::service_agent_runtime::CoreServiceAgentRuntime::synchronize_relay_session( + publisher, session_id, None, + ) + .await +} + +/// Publish only a dirty turn at a semantic message boundary. +pub async fn synchronize_session_record_turn( + publisher: &session_log::SessionPublisher, + session_id: &str, + turn_id: &str, +) -> Result<(), String> { + crate::service_agent_runtime::CoreServiceAgentRuntime::synchronize_relay_session( + publisher, + session_id, + Some(turn_id), + ) + .await +} + +/// Bridge the runtime-owned question mailbox to the account log. Only revisions +/// travel here; controllers read the authoritative live mailbox on invalidation. +pub fn start_session_interaction_publication(publisher: &Arc) { + #[cfg(feature = "workspace-runtime")] + start_host_catalog_publication(publisher); + let mut closed = publisher.subscribe_closed(); + let weak = Arc::downgrade(publisher); + let manager = crate::agentic::tools::user_input_manager::get_user_input_manager(); + let mut changes = manager.subscribe_changes(); + tokio::spawn(async move { + let mut previous = std::collections::HashSet::::new(); + loop { + if *closed.borrow() { + break; + } + let revision = *changes.borrow_and_update(); + let current: std::collections::HashSet<_> = + manager.pending_question_counts().into_keys().collect(); + let sessions: std::collections::HashSet<_> = + previous.union(¤t).cloned().collect(); + let Some(publisher) = weak.upgrade() else { + break; + }; + let events = sessions.into_iter().map(|session_id| { + (session_id.clone(), "session-interaction-changed".to_string(), serde_json::json!({"sessionId":session_id,"userQuestionsRevision":revision})) + }).collect::>(); + if !events.is_empty() { + tokio::select! { + biased; + _ = closed.changed() => break, + result = publisher.append_batch(events) => if let Err(error) = result { + log::error!("Unable to persist session interaction invalidation: {error}"); + } + } + } + drop(publisher); + previous = current; + tokio::select! { + biased; + _ = closed.changed() => break, + result = changes.changed() => if result.is_err() { break; } + } + } + }); +} + +#[cfg(feature = "workspace-runtime")] +fn start_host_catalog_publication(publisher: &Arc) { + let mut closed = publisher.subscribe_closed(); + let weak = Arc::downgrade(publisher); + let mut sessions = crate::service::session::SessionMetadataStore::subscribe_catalog_changes(); + let mut workspaces = crate::service::workspace::WorkspaceService::subscribe_catalog_changes(); + tokio::spawn(async move { + loop { + if *closed.borrow() { + break; + } + // Coalesce metadata writes from one runtime operation; no periodic + // reads or broadcasts occur while the catalog is unchanged. + tokio::select! { + biased; + _ = closed.changed() => break, + _ = tokio::time::sleep(std::time::Duration::from_millis(75)) => {} + } + let sessions_revision = *sessions.borrow_and_update(); + let workspaces_revision = *workspaces.borrow_and_update(); + let Some(publisher) = weak.upgrade() else { + break; + }; + tokio::select! { + biased; + _ = closed.changed() => break, + result = publisher.append(session_log::HOST_CATALOG_ID.into(), "host-catalog-changed".into(), serde_json::json!({"sessionsRevision":sessions_revision,"workspacesRevision":workspaces_revision})) => { + if let Err(error) = result { log::error!("Unable to persist host catalog invalidation: {error}"); } + } + } + drop(publisher); + tokio::select! { + biased; + _ = closed.changed() => break, + result = sessions.changed() => if result.is_err() { break; }, + result = workspaces.changed() => if result.is_err() { break; } + } + } + }); +} + +/// Live session-list state is runtime-owned but not always a metadata write. +/// Only semantic lifecycle facts invalidate the catalog; output chunks do not. +pub fn notify_session_catalog_event(event: &openbitfun_events::AgenticEvent) { + use openbitfun_events::AgenticEvent; + if matches!( + event, + AgenticEvent::SessionCreated { .. } + | AgenticEvent::SessionDeleted { .. } + | AgenticEvent::SessionTitleGenerated { .. } + | AgenticEvent::SessionStateChanged { .. } + | AgenticEvent::DialogTurnStarted { .. } + | AgenticEvent::DialogTurnCompleted { .. } + | AgenticEvent::DialogTurnFailed { .. } + | AgenticEvent::DialogTurnCancelled { .. } + ) { + crate::service::session::SessionMetadataStore::notify_catalog_changed(); + } +} diff --git a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs index a2dab72b7e..eec2554adc 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs @@ -646,3 +646,46 @@ mod tests { let _ = std::fs::remove_dir_all(workspace_root); } } + +/// Initialize the same saved-profile providers for headless hosts. Existing +/// graphical-host providers are reused, never replaced during a live session. +#[cfg(feature = "ssh-remote")] +pub async fn ensure_saved_connection_services() -> Result, String> +{ + static INITIALIZED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); + let state = init_remote_workspace_manager(); + if state.get_ssh_manager().await.is_some() && state.get_file_service().await.is_some() { + return Ok(state); + } + INITIALIZED + .get_or_try_init(|| async { + let data_dir = if std::env::var_os("OPENBITFUN_HOME").is_some() { + crate::infrastructure::try_get_path_manager_arc() + .map_err(|e| e.to_string())? + .user_data_dir() + .join("ssh") + } else { + dirs::data_local_dir() + .ok_or("Application data directory is unavailable")? + .join("OpenBitFun") + .join("ssh") + }; + let ssh = SSHConnectionManager::new(data_dir); + ssh.load_saved_connections() + .await + .map_err(|e| e.to_string())?; + ssh.load_known_hosts().await.map_err(|e| e.to_string())?; + state + .set_file_service(RemoteFileService::new(Arc::new(RwLock::new(Some( + ssh.clone(), + ))))) + .await; + state + .set_terminal_manager(RemoteTerminalManager::new(ssh.clone())) + .await; + state.set_ssh_manager(ssh).await; + Ok::<(), String>(()) + }) + .await?; + Ok(state) +} diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index 64ac27764a..6df0b7ab51 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -343,12 +343,12 @@ impl WorkspaceService { ) -> OpenBitFunResult { let path_str = path.to_string_lossy().to_string(); let known = self - .find_known_remote_workspace_for_path( + .resolve_remote_workspace_for_open( &path_str, preferred_connection_id, preferred_ssh_host, ) - .await; + .await?; self.open_workspace_after_known_resolution(path, known) .await } @@ -454,27 +454,19 @@ impl WorkspaceService { return None; } - if let Some(connection_id) = preferred_connection_id { - if let Some(matched) = matches - .iter() - .find(|workspace| workspace.remote_ssh_connection_id() == Some(connection_id)) - { - return Some((*matched).clone()); - } - } - - if let Some(ssh_host) = preferred_ssh_host { - if let Some(matched) = matches.iter().find(|workspace| { - workspace - .metadata - .get("sshHost") - .and_then(|value| value.as_str()) - .map(str::trim) - == Some(ssh_host) - }) { - return Some((*matched).clone()); - } - } + // Explicit identity is a constraint, never a hint to another host. + matches.retain(|workspace| { + preferred_connection_id + .is_none_or(|id| workspace.remote_ssh_connection_id() == Some(id)) + && preferred_ssh_host.is_none_or(|host| { + workspace + .metadata + .get("sshHost") + .and_then(|v| v.as_str()) + .map(str::trim) + == Some(host) + }) + }); // Prefer the most recently accessed match when the path alone is ambiguous // (e.g. the same POSIX root opened on two SSH hosts). @@ -482,6 +474,102 @@ impl WorkspaceService { matches.first().map(|workspace| (*workspace).clone()) } + /// Resolve persisted identity or verify a new path against an existing SSH + /// connection. Caller input alone never authorizes a remote runtime scope. + pub(crate) async fn resolve_remote_workspace_for_open( + &self, + path: &str, + connection_id: Option<&str>, + ssh_host: Option<&str>, + ) -> OpenBitFunResult> { + let known = self + .find_known_remote_workspace_for_path(path, connection_id, ssh_host) + .await; + let connection_id = connection_id.map(str::trim).filter(|id| !id.is_empty()); + let ssh_host = ssh_host.map(str::trim).filter(|id| !id.is_empty()); + if connection_id.is_none() && ssh_host.is_none() { + return Ok(known); + } + #[cfg(feature = "ssh-remote")] + { + let connection_id = connection_id.ok_or_else(|| { + OpenBitFunError::service( + "Opening a new remote workspace requires its saved SSH connection ID", + ) + })?; + if !path.starts_with('/') || path.contains('\0') { + return Err(OpenBitFunError::service( + "Remote workspace path must be an absolute POSIX path", + )); + } + let manager = + crate::service::remote_ssh::workspace_state::ensure_saved_connection_services() + .await + .map_err(OpenBitFunError::service)?; + let ssh = manager + .get_ssh_manager() + .await + .ok_or_else(|| OpenBitFunError::service("SSH connection manager is unavailable"))?; + if !ssh + .get_saved_connections() + .await + .iter() + .any(|profile| profile.id == connection_id) + { + return Err(OpenBitFunError::service( + "Remote workspace connection is not saved on this host", + )); + } + if let Some(known) = known { + return Ok(Some(known)); + } + ssh.ensure_connected(connection_id) + .await + .map_err(|e| OpenBitFunError::service(e.to_string()))?; + let config = ssh + .get_connection_config(connection_id) + .await + .ok_or_else(|| OpenBitFunError::service("SSH connection is unavailable"))?; + let host = ssh + .get_saved_host_for_connection_id(connection_id) + .await + .unwrap_or(config.host); + if ssh_host.is_some_and(|requested| requested != host) { + return Err(OpenBitFunError::service( + "SSH host identity does not match the selected connection", + )); + } + let fs = manager + .get_file_service() + .await + .ok_or_else(|| OpenBitFunError::service("Remote file service is unavailable"))?; + let path = normalize_remote_workspace_path(path); + if !fs + .is_dir(connection_id, &path) + .await + .map_err(|e| OpenBitFunError::service(e.to_string()))? + { + return Err(OpenBitFunError::service( + "Remote workspace path is not a directory", + )); + } + let options = WorkspaceOpenOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some(connection_id.to_string()), + remote_ssh_host: Some(host.clone()), + stable_workspace_id: Some(remote_workspace_stable_id(&host, &path)), + ..Default::default() + }; + return WorkspaceInfo::new_without_worktree(PathBuf::from(path), options) + .await + .map(Some); + } + #[cfg(not(feature = "ssh-remote"))] + Err(OpenBitFunError::service( + "Opening a new remote workspace requires SSH support on this host", + )) + } + async fn open_known_remote_workspace( &self, known: &WorkspaceInfo, @@ -1735,6 +1823,10 @@ impl WorkspaceService { } /// Saves workspace data locally. + pub fn subscribe_catalog_changes() -> tokio::sync::watch::Receiver { + workspace_catalog_revision().subscribe() + } + async fn save_workspace_data(&self) -> OpenBitFunResult<()> { let manager = self.manager.read().await; @@ -1756,7 +1848,7 @@ impl WorkspaceService { .map_err(|e| { OpenBitFunError::service(format!("Failed to save workspace data: {}", e)) })?; - + workspace_catalog_revision().send_modify(|revision| *revision = revision.wrapping_add(1)); Ok(()) } @@ -2727,6 +2819,46 @@ mod tests { assert_eq!(entry.ssh_host, "remote-host"); } + #[tokio::test] + async fn explicit_remote_identity_does_not_select_another_host_at_same_path() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let root = PathBuf::from("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/tests/identity-selection"); + service + .track_workspace_activity( + root.clone(), + WorkspaceCreateOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("saved-alpha".into()), + remote_ssh_host: Some("alpha.example".into()), + ..Default::default() + }, + WorkspaceActivityMode::RefreshMetadata, + ) + .await + .unwrap(); + assert!(service + .find_known_remote_workspace_for_path(root.to_str().unwrap(), Some("saved-beta"), None) + .await + .is_none()); + assert!(service + .find_known_remote_workspace_for_path( + root.to_str().unwrap(), + Some("saved-alpha"), + Some("beta.example") + ) + .await + .is_none()); + assert!(service + .find_known_remote_workspace_for_path( + root.to_str().unwrap(), + Some("saved-alpha"), + Some("alpha.example") + ) + .await + .is_some()); + } + #[tokio::test] async fn open_workspace_resolving_known_reports_unknown_remote_paths_clearly() { let env = TestEnvironment::new(); @@ -2824,3 +2956,9 @@ mod tests { ); } } + +fn workspace_catalog_revision() -> &'static tokio::sync::watch::Sender { + static REVISION: std::sync::OnceLock> = + std::sync::OnceLock::new(); + REVISION.get_or_init(|| tokio::sync::watch::channel(0).0) +} diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 93a0e3d317..1a9602a197 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -1534,6 +1534,22 @@ impl CoreServiceAgentRuntime { ) -> Result< openbitfun_services_integrations::remote_connect::file_projection::SessionFileTarget, String, + > { + Self::remote_file_target_with_identity(path, session_id) + .await + .map(|(target, _)| target) + } + + #[cfg(feature = "remote-connect")] + pub(crate) async fn remote_file_target_with_identity( + path: &str, + session_id: Option<&str>, + ) -> Result< + ( + openbitfun_services_integrations::remote_connect::file_projection::SessionFileTarget, + String, + ), + String, > { let binding = if let Some(session_id) = session_id { Self::resolve_session_workspace_binding(session_id).await @@ -1559,7 +1575,118 @@ impl CoreServiceAgentRuntime { "Cannot resolve the selected workspace for file access".to_string() })? }; - Self::file_target_for_binding(path, session_id, binding).await + let connection_id = binding.connection_id().map(str::to_string); + let target = Self::file_target_for_binding(path, session_id, binding).await?; + let target_id = if target.remote { + connection_id.ok_or("Remote file target has no connection identity")? + } else { + "local".to_string() + }; + Ok((target, target_id)) + } + + #[cfg(feature = "remote-connect")] + async fn scoped_remote_file_target( + path: &str, + session_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + ) -> Result< + openbitfun_services_integrations::remote_connect::file_projection::SessionFileTarget, + String, + > { + Self::scoped_remote_file_target_with_identity( + path, + session_id, + workspace_path, + remote_connection_id, + ) + .await + .map(|(target, _)| target) + } + + #[cfg(feature = "remote-connect")] + pub(crate) async fn scoped_remote_file_target_with_identity( + path: &str, + session_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + ) -> Result< + ( + openbitfun_services_integrations::remote_connect::file_projection::SessionFileTarget, + String, + ), + String, + > { + // An empty connection ID is the explicit local-provider marker shared + // with directory/CRUD calls; absence is also local for scoped files. + let remote_connection_id = remote_connection_id.filter(|id| !id.is_empty()); + if session_id.is_some() { + if workspace_path.is_some() || remote_connection_id.is_some() { + return Err("Use either a session or an explicit file workspace".into()); + } + return Self::remote_file_target_with_identity(path, session_id).await; + } + let workspace_path = workspace_path + .filter(|value| !value.trim().is_empty()) + .ok_or("File workspace identity is required when no session is supplied")?; + if let Some(connection_id) = remote_connection_id { + #[cfg(feature = "ssh-remote")] + { + let state = + crate::service::remote_ssh::workspace_state::ensure_saved_connection_services() + .await?; + let ssh = state + .get_ssh_manager() + .await + .ok_or("SSH connection manager is unavailable")?; + if !ssh + .get_saved_connections() + .await + .iter() + .any(|profile| profile.id == connection_id) + { + return Err("File workspace connection is not saved on this runtime".into()); + } + ssh.ensure_connected(connection_id) + .await + .map_err(|error| error.to_string())?; + } + #[cfg(not(feature = "ssh-remote"))] + { + let _ = connection_id; + return Err("SSH file workspaces are unavailable on this runtime".into()); + } + } + let config = crate::agentic::core::SessionConfig { + workspace_path: Some(workspace_path.into()), + remote_connection_id: remote_connection_id.map(str::to_string), + ..Default::default() + }; + let binding = if remote_connection_id.is_some() { + ConversationCoordinator::build_workspace_binding(&config) + .await + .ok_or("Explicit file workspace cannot be resolved")? + } else { + // An explicit local provider must not infer SSH from a same-named + // open remote workspace or the runtime's currently selected root. + WorkspaceBinding::new( + ConversationCoordinator::resolve_workspace_id_for_config(&config).await, + std::path::PathBuf::from(workspace_path), + ) + }; + if binding.connection_id() != remote_connection_id { + return Err( + "Explicit file workspace provider does not match its connection identity".into(), + ); + } + let target = Self::file_target_for_binding(path, None, binding).await?; + let target_id = if target.remote { + remote_connection_id.ok_or("Remote file target has no connection identity")? + } else { + "local" + }; + Ok((target, target_id.to_string())) } #[cfg(feature = "remote-connect")] @@ -1656,6 +1783,22 @@ impl CoreServiceAgentRuntime { image_context_from_remote_image_context(context) } + /// One source read/commit owner for both migration and live block updates. + #[cfg(feature = "remote-connect")] + pub async fn synchronize_relay_session( + publisher: &openbitfun_services_integrations::remote_connect::session_log::SessionPublisher, + session_id: &str, + turn_id: Option<&str>, + ) -> Result<(), String> { + publisher.synchronize_records(session_id.to_owned(),turn_id.is_none(),||async { + let directory=Self::resolve_session_storage_dir(session_id).await + .ok_or_else(||anyhow::anyhow!("Session storage is unavailable on this host"))?; + let coordinator=get_global_coordinator().ok_or_else(||anyhow::anyhow!("Runtime is unavailable"))?; + let turns=coordinator.load_relay_session_turns(&directory,session_id,turn_id).await?; + openbitfun_services_integrations::remote_connect::session_records::records_from_turns(&turns) + }).await.map_err(|error|error.to_string()) + } + #[cfg(feature = "remote-connect")] pub(crate) async fn load_remote_chat_messages( session_storage_dir: &std::path::Path, @@ -2725,26 +2868,40 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { &self, path: &str, session_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, offset: u64, limit: u64, ) -> Result, String> { - CoreServiceAgentRuntime::remote_file_target(path, session_id) - .await? - .read_chunk(offset, limit) - .await - .map(Some) + CoreServiceAgentRuntime::scoped_remote_file_target( + path, + session_id, + workspace_path, + remote_connection_id, + ) + .await? + .read_chunk(offset, limit) + .await + .map(Some) } async fn remote_file_info( &self, path: &str, session_id: Option<&str>, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, ) -> Result, String> { - CoreServiceAgentRuntime::remote_file_target(path, session_id) - .await? - .info() - .await - .map(Some) + CoreServiceAgentRuntime::scoped_remote_file_target( + path, + session_id, + workspace_path, + remote_connection_id, + ) + .await? + .info() + .await + .map(Some) } } @@ -3018,11 +3175,22 @@ impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { #[cfg(feature = "remote-connect")] #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { - async fn confirm_tool(&self, tool_id: &str) -> Result<(), String> { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<(), String> { self.coordinator()? .reply_to_tool( tool_id, - openbitfun_agent_runtime::sdk::PermissionReply::Once, + match updated_input { + Some(updated_input) => { + openbitfun_agent_runtime::sdk::PermissionReply::OnceWithInput { + updated_input, + } + } + None => openbitfun_agent_runtime::sdk::PermissionReply::Once, + }, ) .await .map_err(|error| error.to_string()) diff --git a/src/crates/contracts/product-domains/src/account.rs b/src/crates/contracts/product-domains/src/account.rs index 00108ebd0c..72bc378da5 100644 --- a/src/crates/contracts/product-domains/src/account.rs +++ b/src/crates/contracts/product-domains/src/account.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// Versioned hosted Relay deployment for the GitHub account/device-key protocol. -pub const DEFAULT_RELAY_URL: &str = "https://remote.openbitfun.com/v/1.0.0"; +pub const DEFAULT_RELAY_URL: &str = "https://remote.openbitfun.com/v/1.0.1"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 8ca2a61cc2..b222e128ff 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:df958db3f7fb0f53", + "digest": "fnv1a64:708b24394aee421b", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -198,6 +198,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "account_load_older_session", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "account_login", "surface": "tauri_command", @@ -246,6 +258,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "account_subscribe_session", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "account_token_expired", "surface": "tauri_command", @@ -258,6 +282,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "account_unsubscribe_session", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "acknowledge_external_ecosystems_command", "surface": "tauri_command", @@ -1286,8 +1322,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { @@ -1298,8 +1333,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { @@ -2827,6 +2861,17 @@ "kind": "handled" } }, + { + "id": "get_session_interaction_mailbox", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "handled" + } + }, { "id": "get_session_lineage", "surface": "tauri_command", @@ -3796,8 +3841,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { @@ -4099,6 +4143,18 @@ "kind": "handled" } }, + { + "id": "local_file_download", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "logout_subscription_account", "surface": "tauri_command", @@ -4767,8 +4823,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not expose the remote-workspace UI surface" + "kind": "handled" } }, { @@ -5097,8 +5152,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { @@ -5622,8 +5676,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { @@ -6414,8 +6467,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } }, { @@ -7087,8 +7139,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7371,8 +7422,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7383,8 +7433,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7395,8 +7444,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7407,8 +7455,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7419,8 +7466,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7431,8 +7477,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7443,8 +7488,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7455,8 +7499,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7467,8 +7510,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7479,8 +7521,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7491,8 +7532,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7503,8 +7543,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7515,8 +7554,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7527,8 +7565,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host does not run this desktop IDE surface" + "kind": "handled" } }, { @@ -7836,6 +7873,17 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "workspace_file_upload", + "surface": "tauri_command", + "remoteWorkspace": "RemoteRouted", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "handled" + } + }, { "id": "worktree_bind_session", "surface": "tauri_command", @@ -7940,8 +7988,7 @@ "kind": "proxied" }, "cliPeer": { - "kind": "unsupported", - "reason": "the CLI peer host has no handler for this command" + "kind": "handled" } } ] diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index c44f513d43..18abf23c5b 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -4,7 +4,7 @@ //! sorted by id and kept one per line so that a new command has exactly one //! insertion point and a review diff shows exactly one row. //! `#[rustfmt::skip]` keeps that layout; `operations_are_sorted_and_unique` -//! in `mod.rs` enforces the ordering. +//! in `mod.rs` verifies lookup coverage; a const assertion also rejects invalid builds. //! //! Columns: id, remote workspace stance, peer stance, CLI peer host support. //! Desktop peer host support is derived (see `OperationDefinition::host_support`). @@ -96,11 +96,14 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("account_github_poll", Agnostic, ControllerLocal, REFUSED), op("account_github_start", Agnostic, ControllerLocal, REFUSED), op("account_list_devices", Agnostic, ControllerLocal, REFUSED), + op("account_load_older_session", Agnostic, ControllerLocal, REFUSED), op("account_login", Agnostic, ControllerLocal, REFUSED), op("account_logout", Agnostic, ControllerLocal, REFUSED), op("account_online_devices", Agnostic, ControllerLocal, REFUSED), op("account_status", Agnostic, ControllerLocal, REFUSED), + op("account_subscribe_session", Agnostic, ControllerLocal, REFUSED), op("account_token_expired", Agnostic, ControllerLocal, REFUSED), + op("account_unsubscribe_session", Agnostic, ControllerLocal, REFUSED), op("acknowledge_external_ecosystems_command", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("activate_session_goal", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("add_skill", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -187,8 +190,8 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("delete_assistant_workspace", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("delete_cron_job", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("delete_custom_agent", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("delete_directory", Routed, Proxied, CLI_NOT_IMPLEMENTED), - op("delete_file", Routed, Proxied, CLI_NOT_IMPLEMENTED), + op("delete_directory", Routed, Proxied, HANDLED), + op("delete_file", Routed, Proxied, HANDLED), op("delete_mcp_server", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("delete_miniapp", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("delete_persisted_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -319,6 +322,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("get_runtime_logging_info", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_session_file_diff_stats", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_session_files", Unaudited, Proxied, HANDLED), + op("get_session_interaction_mailbox", Agnostic, Proxied, HANDLED), op("get_session_lineage", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("get_session_operations", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("get_session_permission_mode", Routed, Proxied, HANDLED), @@ -400,7 +404,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("list_archived_sessions", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("list_background_command_activities", Unaudited, Proxied, SOFT_EMPTY_BACKGROUND_COMMANDS), op("list_cron_jobs", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("list_directory_files", Routed, Proxied, CLI_NOT_IMPLEMENTED), + op("list_directory_files", Routed, Proxied, HANDLED), host_invoke_only("list_files", Routed, Proxied, HANDLED), op("list_manageable_subagents", Routed, Proxied, HANDLED), op("list_mcp_prompts", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -427,6 +431,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("load_session_event_backfill", Agnostic, Proxied, HANDLED), op("load_session_turn_window", Routed, Proxied, HANDLED), op("load_session_turns", Unaudited, Proxied, HANDLED), + op("local_file_download", Agnostic, ControllerLocal, REFUSED), op("logout_subscription_account", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("mark_announcement_seen", Agnostic, ControllerLocal, REFUSED), op("mark_openbitfun_control_surface_ready", Agnostic, ControllerLocal, REFUSED), @@ -482,7 +487,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("never_show_announcement", Agnostic, ControllerLocal, REFUSED), op("notify_cron_host_ready", Unaudited, Proxied, SOFT_EMPTY_NO_CRON_HOST), op("open_html_file_in_browser", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), - op("open_remote_workspace", Routed, Proxied, CLI_NO_REMOTE_WORKSPACE_UI), + op("open_remote_workspace", Routed, Proxied, HANDLED), op("open_workspace", Routed, Proxied, HANDLED), op("page_create_open_link", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), op("page_delete", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), @@ -510,7 +515,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("quick_commit_message", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("quit_app", LocalOnly, ControllerLocal, REFUSED), op("read_background_command_output", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("read_file_content", Routed, Proxied, CLI_NOT_IMPLEMENTED), + op("read_file_content", Routed, Proxied, HANDLED), op("read_mcp_resource", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("recover_interrupted_dialog_turn", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("refresh_model_client", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -554,7 +559,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("remote_write_file", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("remove_project_permission_grant", Agnostic, Proxied, HANDLED), op("remove_recent_workspace", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), - op("rename_file", Routed, Proxied, CLI_NOT_IMPLEMENTED), + op("rename_file", Routed, Proxied, HANDLED), host_invoke_only("rename_session", Agnostic, Proxied, HANDLED), op("reorder_opened_workspaces", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("replace_mode_skill_selection", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), @@ -621,7 +626,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("send_system_notification", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), op("set_acp_session_config_option", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("set_acp_session_model", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), - op("set_active_workspace", Agnostic, Proxied, CLI_NOT_IMPLEMENTED), + op("set_active_workspace", Agnostic, Proxied, HANDLED), op("set_agent_profile_config", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("set_config", Unaudited, Proxied, HANDLED), op("set_external_mcp_server_decision_command", Unsupported, Proxied, HANDLED), @@ -678,7 +683,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("ssh_list_docker_containers", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_list_port_forwards", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_list_remote_listening_ports", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("ssh_list_saved_connections", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), + op("ssh_list_saved_connections", Agnostic, Proxied, HANDLED), op("ssh_list_wsl_distributions", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_save_connection", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_start_port_forward", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), @@ -702,20 +707,20 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("submit_mcp_interaction_response", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("submit_user_answers", Agnostic, Proxied, HANDLED), op("subscribe_permission_requests", Agnostic, Proxied, HANDLED), - op("terminal_ack", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_close", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_create", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_execute", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_get", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_get_history", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_get_shells", LocalOnly, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_has_shell_integration", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_list", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_resize", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_send_command", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_shutdown_all", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_signal", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), - op("terminal_write", Routed, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), + op("terminal_ack", Routed, Proxied, HANDLED), + op("terminal_close", Routed, Proxied, HANDLED), + op("terminal_create", Routed, Proxied, HANDLED), + op("terminal_execute", Routed, Proxied, HANDLED), + op("terminal_get", Routed, Proxied, HANDLED), + op("terminal_get_history", Routed, Proxied, HANDLED), + op("terminal_get_shells", LocalOnly, Proxied, HANDLED), + op("terminal_has_shell_integration", Routed, Proxied, HANDLED), + op("terminal_list", Routed, Proxied, HANDLED), + op("terminal_resize", Routed, Proxied, HANDLED), + op("terminal_send_command", Routed, Proxied, HANDLED), + op("terminal_shutdown_all", Routed, Proxied, HANDLED), + op("terminal_signal", Routed, Proxied, HANDLED), + op("terminal_write", Routed, Proxied, HANDLED), op("test_ai_config_connection", Unaudited, Proxied, HANDLED), op("test_ai_connection", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("toggle_main_window_fullscreen", LocalOnly, ControllerLocal, REFUSED), @@ -742,6 +747,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("validate_skill_path", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("validate_tool_input", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("webdriver_bridge_result", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("workspace_file_upload", Routed, Proxied, HANDLED), op("worktree_bind_session", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("worktree_create", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("worktree_create_branch", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), @@ -750,5 +756,28 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("worktree_promote", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("worktree_recreate", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), op("worktree_remove", Unsupported, Proxied, CLI_NOT_IMPLEMENTED), - op("write_file_content", Routed, Proxied, CLI_NOT_IMPLEMENTED), + op("write_file_content", Routed, Proxied, HANDLED), ]; + +// Binary search is a production precondition, not merely a test expectation. +// A new misplaced/duplicate row must fail every build before it can hide APIs. +const _: () = { + let mut row = 1; + while row < OPERATIONS.len() { + let left = OPERATIONS[row - 1].id.as_bytes(); + let right = OPERATIONS[row].id.as_bytes(); + let mut byte = 0; + while byte < left.len() && byte < right.len() && left[byte] == right[byte] { + byte += 1; + } + let less = if byte == left.len() { + byte < right.len() + } else if byte == right.len() { + false + } else { + left[byte] < right[byte] + }; + assert!(less, "Product Operation Registry must be sorted and unique"); + row += 1; + } +}; diff --git a/src/crates/contracts/product-domains/src/tool_permissions.rs b/src/crates/contracts/product-domains/src/tool_permissions.rs index ca5ba3ebda..887f5da23b 100644 --- a/src/crates/contracts/product-domains/src/tool_permissions.rs +++ b/src/crates/contracts/product-domains/src/tool_permissions.rs @@ -590,6 +590,10 @@ pub struct PermissionRequest { #[serde(tag = "reply", rename_all = "snake_case")] pub enum PermissionReply { Once, + /// Approve this invocation once with a shallow object patch to its input. + OnceWithInput { + updated_input: Value, + }, Always, Reject { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs index 520422eec8..ed5b7022a9 100644 --- a/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/remote_workspace_ports.rs @@ -196,6 +196,8 @@ pub trait RemoteWorkspaceFileRuntimeHost: Send + Sync { &self, _path: &str, _session_id: Option<&str>, + _workspace_path: Option<&str>, + _remote_connection_id: Option<&str>, _offset: u64, _limit: u64, ) -> Result, String> { @@ -206,6 +208,8 @@ pub trait RemoteWorkspaceFileRuntimeHost: Send + Sync { &self, _path: &str, _session_id: Option<&str>, + _workspace_path: Option<&str>, + _remote_connection_id: Option<&str>, ) -> Result, String> { Ok(None) } diff --git a/src/crates/contracts/runtime-ports/src/workspace_ports.rs b/src/crates/contracts/runtime-ports/src/workspace_ports.rs index dd8d227a3e..7e5a183d72 100644 --- a/src/crates/contracts/runtime-ports/src/workspace_ports.rs +++ b/src/crates/contracts/runtime-ports/src/workspace_ports.rs @@ -164,9 +164,22 @@ impl WorkspaceReadHandle for T where T: tokio::io::AsyncRead + tokio::io::Asy pub type WorkspaceReader = Box; +pub trait WorkspaceWriteHandle: tokio::io::AsyncWrite + Unpin + Send {} +impl WorkspaceWriteHandle for T where T: tokio::io::AsyncWrite + Unpin + Send {} +pub type WorkspaceWriter = Box; + /// Unified file system operations that work for both local and remote workspaces. #[async_trait::async_trait] pub trait WorkspaceFileSystem: Send + Sync { + /// Create a new stream exclusively. Existing paths must never be truncated. + async fn open_write_new(&self, _path: &str) -> anyhow::Result { + anyhow::bail!("streaming writes are not supported by this workspace filesystem") + } + /// Atomically publish a same-directory staged file, replacing a regular target. + async fn atomic_replace(&self, _from: &str, _to: &str) -> anyhow::Result<()> { + anyhow::bail!("atomic replacement is not supported by this workspace filesystem") + } + /// Open a stream without downloading the entire file before returning. async fn open_read(&self, _path: &str) -> anyhow::Result { anyhow::bail!("streaming reads are not supported by this workspace filesystem") diff --git a/src/crates/execution/agent-runtime/src/permission.rs b/src/crates/execution/agent-runtime/src/permission.rs index 2175389070..e07cfe7639 100644 --- a/src/crates/execution/agent-runtime/src/permission.rs +++ b/src/crates/execution/agent-runtime/src/permission.rs @@ -207,6 +207,8 @@ pub struct PermissionReplyResolution { #[derive(Debug, thiserror::Error)] pub enum PermissionRequestManagerError { + #[error("Invalid permission reply: {0}")] + InvalidReply(String), #[error("Duplicate pending permission request: {0}")] DuplicateRequest(String), #[error("Pending permission request not found: {0}")] @@ -568,6 +570,13 @@ impl PermissionRequestManager { reply: PermissionReply, source: PermissionReplySource, ) -> Result { + if let PermissionReply::OnceWithInput { updated_input } = &reply { + if include_following || !updated_input.is_object() { + return Err(PermissionRequestManagerError::InvalidReply( + "Edited approval requires an object input and a single request".to_string(), + )); + } + } let _operation = self.operations.lock().await; let request = self .pending @@ -576,6 +585,15 @@ impl PermissionRequestManager { .ok_or_else(|| { PermissionRequestManagerError::RequestNotFound(request_id.to_string()) })?; + if matches!(reply, PermissionReply::OnceWithInput { .. }) + && (request.source.kind + != openbitfun_runtime_ports::PermissionRequestSourceKind::ToolCall + || request.tool_call_id.is_none()) + { + return Err(PermissionRequestManagerError::InvalidReply( + "This permission owner does not support edited input".to_string(), + )); + } let timestamp_ms = self.clock.now_unix_millis(); let resolution_requests = if include_following { self.ordered_pending_requests(|pending| { diff --git a/src/crates/execution/agent-runtime/src/user_questions.rs b/src/crates/execution/agent-runtime/src/user_questions.rs index 41ef965f03..ab76b1482e 100644 --- a/src/crates/execution/agent-runtime/src/user_questions.rs +++ b/src/crates/execution/agent-runtime/src/user_questions.rs @@ -144,11 +144,28 @@ impl PendingUserInput { } } -#[derive(Default)] struct UserInputState { pending: HashMap, next_registration_sequence: u64, revision: u64, + changes: watch::Sender, +} + +impl Default for UserInputState { + fn default() -> Self { + Self { + pending: HashMap::new(), + next_registration_sequence: 0, + revision: 0, + changes: watch::channel(0).0, + } + } +} +impl UserInputState { + fn bump_revision(&mut self) { + self.revision = self.revision.saturating_add(1); + self.changes.send_replace(self.revision); + } } /// Drop guard tying a pending mailbox record to the Tool future that waits on @@ -175,7 +192,7 @@ impl UserInputRegistration { }); if can_expire { state.pending.remove(&self.tool_id); - state.revision = state.revision.saturating_add(1); + state.bump_revision(); } can_expire } @@ -224,7 +241,7 @@ impl Drop for UserInputRegistration { .is_some_and(|pending| pending.registration_sequence == self.registration_sequence); if belongs_to_registration { state.pending.remove(&self.tool_id); - state.revision = state.revision.saturating_add(1); + state.bump_revision(); debug!( "Removed dropped user-input registration: tool_id={}", self.tool_id @@ -251,6 +268,13 @@ impl UserInputManager { } } + /// Coalesced invalidation of the live question mailbox. Consumers read + /// pending_question_counts plus their previous session set to also publish + /// removals; no tool payload or bounded queue sits on the execution path. + pub fn subscribe_changes(&self) -> watch::Receiver { + lock_user_input_state(&self.state).changes.subscribe() + } + pub fn register_channel(&self, tool_id: String, sender: oneshot::Sender) { debug!("Registered waiting channel: tool_id={}", tool_id); self.insert_pending(tool_id, sender, None, Vec::new()); @@ -308,7 +332,7 @@ impl UserInputManager { interaction_started, }, ); - state.revision = state.revision.saturating_add(1); + state.bump_revision(); (registration_sequence, activity) } @@ -331,7 +355,7 @@ impl UserInputManager { if let Some(question) = pending.question.as_mut() { question.interaction_started = true; } - state.revision = state.revision.saturating_add(1); + state.bump_revision(); } Ok(()) } @@ -343,7 +367,7 @@ impl UserInputManager { let mut state = lock_user_input_state(&self.state); let pending = state.pending.remove(tool_id); if pending.is_some() { - state.revision = state.revision.saturating_add(1); + state.bump_revision(); } pending }; @@ -382,7 +406,7 @@ impl UserInputManager { }); } state.pending.remove(tool_id); - state.revision = state.revision.saturating_add(1); + state.bump_revision(); Ok(()) } @@ -391,7 +415,7 @@ impl UserInputManager { let mut state = lock_user_input_state(&self.state); let removed = state.pending.remove(tool_id).is_some(); if removed { - state.revision = state.revision.saturating_add(1); + state.bump_revision(); } removed }; @@ -655,6 +679,35 @@ mod tests { use super::{PendingUserQuestion, UserInputManager, UserInputResponse, UserInputSendError}; use serde_json::json; + #[tokio::test] + async fn mailbox_watch_coalesces_without_losing_sessions_and_notifies_removal() { + let manager = UserInputManager::new(); + let mut changes = manager.subscribe_changes(); + let (sender_a, receiver_a) = tokio::sync::oneshot::channel(); + let (sender_b, _receiver_b) = tokio::sync::oneshot::channel(); + let a = manager.register_question( + PendingUserQuestion::new("a", "session-a", None, None, json!({"questions":[]})), + sender_a, + ); + let b = manager.register_question( + PendingUserQuestion::new("b", "session-b", None, None, json!({"questions":[]})), + sender_b, + ); + changes.changed().await.unwrap(); + let revision = *changes.borrow_and_update(); + let sessions = manager.pending_question_counts(); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions.get("session-a"), Some(&1)); + assert_eq!(sessions.get("session-b"), Some(&1)); + manager.send_answer("a", json!({"0":"yes"})).unwrap(); + assert_eq!(receiver_a.await.unwrap().answers, json!({"0":"yes"})); + drop(b); + changes.changed().await.unwrap(); + assert!(*changes.borrow_and_update() > revision); + assert!(manager.pending_question_counts().is_empty()); + drop(a); + } + #[tokio::test] async fn user_input_manager_delivers_answer_and_clears_channel() { let manager = UserInputManager::new(); diff --git a/src/crates/services/relay-service/Cargo.toml b/src/crates/services/relay-service/Cargo.toml index fbd365c650..e8030708ab 100644 --- a/src/crates/services/relay-service/Cargo.toml +++ b/src/crates/services/relay-service/Cargo.toml @@ -16,8 +16,10 @@ axum = { version = "0.8", features = ["json", "ws"] } tower-http = { version = "0.6.11", features = ["cors"] } # Keep the runtime surface explicit: relay needs multi-thread RT, timers, # channels/semaphores, and TCP — not tokio's full feature set. -tokio = { version = "1.52", default-features = false, features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +tokio = { version = "1.52", default-features = false, features = ["rt-multi-thread", "macros", "net", "time", "sync", "fs", "io-util"] } futures-util = "0.3.31" +tempfile = "3" +socketioxide = { version = "0.18.7", default-features = false, features = ["extensions"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" @@ -52,7 +54,6 @@ rusqlite = { version = "0.32", features = ["bundled", "hooks", "limits"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } -tempfile = "3" tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "handshake"] } [lints.rust] diff --git a/src/crates/services/relay-service/src/account_transport_tests.rs b/src/crates/services/relay-service/src/account_transport_tests.rs index e0df7f1ad7..131a0d815d 100644 --- a/src/crates/services/relay-service/src/account_transport_tests.rs +++ b/src/crates/services/relay-service/src/account_transport_tests.rs @@ -39,6 +39,78 @@ async fn json_body(response: axum::response::Response) -> Value { serde_json::from_slice(&to_bytes(response.into_body(), 1_000_000).await.unwrap()).unwrap() } +type Socket = + tokio_tungstenite::WebSocketStream>; + +async fn packet(socket: &mut Socket) -> String { + loop { + let message = tokio::time::timeout(Duration::from_secs(6), socket.next()) + .await + .expect("realtime packet deadline") + .expect("socket open") + .expect("valid frame"); + match message { + Message::Text(text) if text == "2" => { + socket.send(Message::Text("3".into())).await.unwrap() + } + Message::Text(text) => return text.to_string(), + Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await.unwrap(), + other => panic!("unexpected realtime frame: {other:?}"), + } + } +} + +async fn connect_scope( + address: std::net::SocketAddr, + prefix: &str, + token: &str, + scope: &str, +) -> Socket { + let (mut socket, _) = tokio_tungstenite::connect_async(format!( + "ws://{address}{prefix}/v1/updates/?EIO=4&transport=websocket" + )) + .await + .unwrap(); + assert!(packet(&mut socket).await.starts_with('0')); + socket + .send(Message::Text( + format!("40{}", json!({"token":token,"clientType":scope})).into(), + )) + .await + .unwrap(); + assert!(packet(&mut socket).await.starts_with("40")); + loop { + let ready = packet(&mut socket).await; + if let Some(event) = ready.strip_prefix("42") { + let data: Value = serde_json::from_str(event).unwrap(); + if data[0] == "auth-ok" { + break; + } + } + } + socket +} + +async fn event(socket: &mut Socket, id: u32, name: &str, value: Value) { + socket + .send(Message::Text( + format!("42{id}{}", json!([name, value])).into(), + )) + .await + .unwrap(); +} + +async fn acknowledgement(socket: &mut Socket, id: u32) -> Value { + let prefix = format!("43{id}["); + loop { + let value = packet(socket).await; + if value.starts_with(&prefix) { + let array: Value = serde_json::from_str(&value[prefix.len() - 1..]).unwrap(); + return array[0].clone(); + } + } +} + #[tokio::test] async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -63,7 +135,7 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { let authority_task = tokio::spawn(async move { axum::serve(listener, authority).await.unwrap(); }); - for prefix in ["", "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/v/1.0.0"] { + for prefix in ["", "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/v/1.0.1"] { let db = Arc::new(db::connect(":memory:").await.unwrap()); let shared = build_relay_router( Arc::new(MemoryAssetStore::new()), @@ -86,7 +158,12 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { .status(), StatusCode::UNAUTHORIZED ); - for retired in ["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/api/rooms", "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/api/pair", "/api/auth/login/challenge"] { + for retired in [ + "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/api/rooms", + "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/api/pair", + "/api/auth/login/challenge", + "/ws", + ] { assert_eq!( request(&app, "POST", &route(retired), "", json!({})) .await @@ -113,29 +190,31 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { ); tokens.push(response["token"].as_str().unwrap().to_owned()); } + for retired in ["/api/devices/desktop/rpc", "/api/devices/mobile/messages"] { + assert_eq!( + request(&app, "POST", &route(retired), &tokens[1], json!({})) + .await + .status(), + StatusCode::NOT_FOUND + ); + } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server_app = app.clone(); let server = tokio::spawn(async move { axum::serve(listener, server_app).await.unwrap(); }); - let (mut socket, _) = - tokio_tungstenite::connect_async(format!("ws://{address}{prefix}/ws")) - .await - .unwrap(); - socket.send(Message::Text(json!({"type":"auth_connect","token":tokens[0],"device_name":"desktop","device_kind":"desktop"}).to_string().into())).await.unwrap(); - loop { - let msg = tokio::time::timeout(Duration::from_secs(3), socket.next()) - .await - .unwrap() - .unwrap() - .unwrap(); - let value: Value = serde_json::from_str(msg.to_text().unwrap()).unwrap(); - if value["type"] == "auth_ok" { - assert_eq!(value["user_id"], "123"); - break; - } - } + let mut socket = connect_scope(address, prefix, &tokens[0], "machine-scoped").await; + event( + &mut socket, + 1, + "rpc-register", + json!({"method":"desktop:remote-command"}), + ) + .await; + assert_eq!(acknowledgement(&mut socket, 1).await["ok"], true); + let mut controller = connect_scope(address, prefix, &tokens[1], "user-scoped").await; + let mut outsider = connect_scope(address, prefix, &tokens[2], "user-scoped").await; let devices = json_body(request(&app, "GET", &route("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/api/devices"), &tokens[1], json!(null)).await) .await; @@ -173,54 +252,86 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { .status(), StatusCode::NOT_FOUND ); + event(&mut outsider,2,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ=="},"timeoutMs":200})).await; + assert_eq!( + acknowledgement(&mut outsider, 2).await["ok"], + false, + "cross-account RPC must not reach the host" + ); + event(&mut controller,3,"rpc-call",json!({"method":"desktop:remote-command","params":{"encryptedData":"YQ==","nonce":"bg=="},"timeoutMs":3000})).await; + let (ack_id, incoming) = loop { + let message = packet(&mut socket).await; + if let Some(rest) = message.strip_prefix("42") { + let index = rest.find('[').unwrap(); + let data: Value = serde_json::from_str(&rest[index..]).unwrap(); + if data[0] == "rpc-request" { + break (rest[..index].to_string(), data[1].clone()); + } + } + }; + assert_eq!(incoming["sourceDeviceId"], "mobile"); + assert_eq!(incoming["params"]["encryptedData"], "YQ=="); + // ACK identity belongs to the target socket. A same-account caller + // or another account cannot spoof the target's response correlation. + let forged = format!("43{ack_id}{}", json!([{"forged":true}])); + outsider + .send(Message::Text(forged.clone().into())) + .await + .unwrap(); + controller.send(Message::Text(forged.into())).await.unwrap(); + assert!(tokio::time::timeout( + Duration::from_millis(50), + acknowledgement(&mut controller, 3) + ) + .await + .is_err()); + let payload = BASE64.encode(vec![7u8; 32 * 1024]); + socket + .send(Message::Text( + format!( + "43{ack_id}{}", + json!([{ "encryptedData":payload,"nonce":"bg=="}]) + ) + .into(), + )) + .await + .unwrap(); + let response = acknowledgement(&mut controller, 3).await; + assert_eq!(response["ok"], true); + assert_eq!(response["result"]["encryptedData"], payload); assert_eq!( request( &app, - "POST", - &route("/api/devices/desktop/rpc"), - &tokens[2], - json!({"encrypted_data":"YQ==","nonce":"bg=="}) + "DELETE", + &route("/api/devices/desktop"), + &tokens[1], + json!(null) ) .await .status(), - StatusCode::NOT_FOUND + StatusCode::NO_CONTENT ); - let rpc_app = app.clone(); - let rpc_token = tokens[1].clone(); - let rpc_path = route("/api/devices/desktop/rpc"); - let rpc = tokio::spawn(async move { - request( - &rpc_app, - "POST", - &rpc_path, - &rpc_token, - json!({"encrypted_data":"YQ==","nonce":"bg=="}), - ) - .await - }); - let incoming = loop { - let msg = tokio::time::timeout(Duration::from_secs(3), socket.next()) - .await - .unwrap() - .unwrap() - .unwrap(); - let value: Value = serde_json::from_str(msg.to_text().unwrap()).unwrap(); - if value["type"] == "incoming_device_message" { - break value; + loop { + let update = packet(&mut controller).await; + if let Some(data) = update.strip_prefix("42") { + let value: Value = serde_json::from_str(data).unwrap(); + if value[0] == "ephemeral" + && value[1]["type"] == "device-presence" + && !value[1]["devices"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["device_id"] == "desktop") + { + break; + } } - }; - assert_eq!(incoming["source_device_id"], "mobile"); - let payload = BASE64.encode(vec![7u8; 32 * 1024]); - let response = request(&app,"POST",&route("/api/devices/mobile/messages"),&tokens[0],json!({ - "correlation_id":incoming["correlation_id"], "encrypted_data":payload, "nonce":"bg==" - })).await; - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let response = tokio::time::timeout(Duration::from_secs(3), rpc) - .await - .unwrap() - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(json_body(response).await["encrypted_data"], payload); + } + assert_eq!( + packet(&mut socket).await, + "41", + "device removal disconnects its active namespace" + ); assert_eq!( request( &app, @@ -239,7 +350,20 @@ async fn official_and_local_layouts_share_authenticated_directory_and_rpc() { .status(), StatusCode::UNAUTHORIZED ); - socket.close(None).await.unwrap(); + // Revocation applies to already-open namespaces, not only future HTTP + // requests. The server's authorization monitor disconnects idle peers. + loop { + let revoked = packet(&mut controller).await; + if revoked == "41" { + break; + } + assert!( + revoked.starts_with("42"), + "unexpected packet after revocation" + ); + } + outsider.close(None).await.unwrap(); + let _ = socket.close(None).await; server.abort(); } authority_task.abort(); diff --git a/src/crates/services/relay-service/src/admission.rs b/src/crates/services/relay-service/src/admission.rs index e94c2c3482..a9c9c6a151 100644 --- a/src/crates/services/relay-service/src/admission.rs +++ b/src/crates/services/relay-service/src/admission.rs @@ -15,7 +15,7 @@ use tokio::sync::Semaphore; const BODY_MEMORY_BUDGET: usize = 512 * 1024 * 1024; const MAX_REQUESTS: usize = 2048; -const MAX_RPC_BODY: usize = 48 * 1024 * 1024 + 64 * 1024; +const MAX_API_BODY: usize = 48 * 1024 * 1024 + 64 * 1024; pub(crate) async fn admit( State(state): State, @@ -23,7 +23,10 @@ pub(crate) async fn admit( next: Next, ) -> Response { let path = request.uri().path(); - if !path.starts_with("/api/") { + let session_api = path == "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/v1/sessions" + || path.starts_with("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/v1/sessions/") + || path.starts_with("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/v3/sessions/"); + if !path.starts_with("/api/") && !session_api { return next.run(request).await; } let peer = request @@ -37,7 +40,7 @@ pub(crate) async fn admit( { return StatusCode::TOO_MANY_REQUESTS.into_response(); } - if path.starts_with("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/api/devices") { + if path.starts_with("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/api/devices") || session_api { let auth = match crate::routes::devices::validate_user(&state, request.headers()).await { Ok(auth) => auth, Err(status) => return status.into_response(), @@ -59,10 +62,12 @@ pub(crate) async fn admit( }; let mut memory_permit = None; if request.method() == Method::POST { - let maximum = if path.starts_with("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/api/auth/") { + let maximum = if session_api { + crate::realtime::store::MAX_BATCH_BYTES + } else if path.starts_with("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/api/auth/") { 16 * 1024 } else { - MAX_RPC_BODY + MAX_API_BODY }; let declared = request .headers() @@ -101,6 +106,15 @@ pub(crate) async fn admit( } *request.body_mut() = axum::body::Body::from(bytes); } + if session_api && request.method() == Method::GET { + memory_permit = + match Arc::clone(MEMORY.get_or_init(|| Arc::new(Semaphore::new(BODY_MEMORY_BUDGET)))) + .try_acquire_many_owned(1024 * 1024) + { + Ok(permit) => Some(permit), + Err(_) => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + } let result = tokio::time::timeout(Duration::from_secs(130), next.run(request)).await; let response = result.unwrap_or_else(|_| StatusCode::REQUEST_TIMEOUT.into_response()); let (parts, body) = response.into_parts(); @@ -134,20 +148,27 @@ mod tests { #[tokio::test] async fn unauthenticated_rpc_is_rejected_before_reading_unbounded_body() { - let body = Body::from_stream(futures_util::stream::pending::< - Result, - >()); - let request = Request::builder() - .method("POST") - .uri("/api/devices/desktop/rpc") - .body(body) - .unwrap(); - let response = - tokio::time::timeout(Duration::from_secs(1), router().await.oneshot(request)) - .await - .unwrap() + for path in [ + "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/v1/sessions", + "/v1/rpc/payloads", + "/v3/sessions/session/messages", + ] { + let body = Body::from_stream(futures_util::stream::pending::< + Result, + >()); + let request = Request::builder() + .method("POST") + .uri(path) + .body(body) .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let response = + tokio::time::timeout(Duration::from_secs(1), router().await.oneshot(request)) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + } } #[tokio::test] diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index dc38fa2ee6..023d46f933 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -163,6 +163,9 @@ async fn connect_with_presence_reset(db_path: &str, reset_presence: bool) -> Res .connect_with(options) .await?; sqlx::query(SCHEMA).execute(&pool).await?; + sqlx::query(crate::realtime::store::SCHEMA) + .execute(&pool) + .await?; // Older DBs created pages without deployed_version_id. let _ = sqlx::query(MIGRATE_PAGES_DEPLOYED_VERSION) .execute(&pool) diff --git a/src/crates/services/relay-service/src/lib.rs b/src/crates/services/relay-service/src/lib.rs index 0d8558cadd..8309ca418f 100644 --- a/src/crates/services/relay-service/src/lib.rs +++ b/src/crates/services/relay-service/src/lib.rs @@ -11,6 +11,7 @@ //! - Published Page assets are managed via `WebAssetStore` mod identity; +pub mod realtime; #[cfg(test)] mod account_transport_tests; @@ -56,7 +57,11 @@ pub async fn relay_security_headers( ) -> axum::response::Response { use axum::http::{HeaderName, HeaderValue}; - let no_store = request.uri().path() == "/health" || request.uri().path().starts_with("/api/"); + let path = request.uri().path(); + let no_store = path == "/health" + || path.starts_with("/api/") + || path.starts_with("/v1/") + || path.starts_with("/v3/"); let mut response = next.run(request).await; let headers = response.headers_mut(); for (name, value) in [ @@ -1181,9 +1186,9 @@ pub fn build_relay_router_with_page_data_origins_and_page_auth( "/api/auth/provision-device", post(routes::auth::provision_device), ) - .route("/ws", get(routes::websocket::websocket_handler)) .merge(routes::devices::device_router()) - .merge(routes::pages::pages_router()) + .merge(routes::pages::pages_router()); + let router = realtime::mount(router, state.clone()) .layer(axum::middleware::from_fn_with_state( state.clone(), admission::admit, diff --git a/src/crates/services/relay-service/src/realtime/device_lifecycle.rs b/src/crates/services/relay-service/src/realtime/device_lifecycle.rs new file mode 100644 index 0000000000..0cd8e59be2 --- /dev/null +++ b/src/crates/services/relay-service/src/realtime/device_lifecycle.rs @@ -0,0 +1,404 @@ +//! Durable directory activation and revocation races; independent of wire framing. +use crate::relay::transport::ConnId; + +async fn registered_device_token_is_current( + db: &crate::db::DbPool, + token: &str, + expected_user_id: &str, + expected_device_id: &str, +) -> anyhow::Result { + Ok(crate::db::AuthToken::find(db, token) + .await? + .is_some_and(|current| { + current.is_device_token() + && current.user_id == expected_user_id + && current.device_id == expected_device_id + })) +} + +async fn project_device_offline_if_unowned( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + user_id: &str, + device_id: &str, +) -> anyhow::Result<()> { + if !device_manager.is_device_online(user_id, device_id) { + crate::db::DeviceRow::set_online(db, user_id, device_id, false).await?; + } + Ok(()) +} + +async fn reconcile_device_after_token_disconnect( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + user_id: &str, + device_id: &str, +) -> anyhow::Result<()> { + project_device_offline_if_unowned(db, device_manager, user_id, device_id).await +} + +/// Complete the activation after the durable device projection has been +/// written. The caller must hold `presence_projection_gate`, keeping the +/// second token check, in-memory promotion, and any rollback atomic with +/// logout, device deletion, and socket cleanup. +async fn complete_pending_device_activation_if_authorized( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + token: &str, + expected_user_id: &str, + expected_device_id: &str, + conn_id: ConnId, +) -> anyhow::Result { + // The durable writes can wait on SQLite. Re-check wall-clock expiry + // immediately before the synchronous promotion so a token that expired + // during that wait never becomes routable or is announced as ready. + let still_current = + match registered_device_token_is_current(db, token, expected_user_id, expected_device_id) + .await + { + Ok(current) => current, + Err(error) => { + device_manager.disconnect_pending(conn_id); + let _ = project_device_offline_if_unowned( + db, + device_manager, + expected_user_id, + expected_device_id, + ) + .await; + return Err(error); + } + }; + if !still_current { + device_manager.disconnect_device_if_token(expected_user_id, expected_device_id, token); + reconcile_device_after_token_disconnect( + db, + device_manager, + expected_user_id, + expected_device_id, + ) + .await?; + return Ok(false); + } + + // Pending remains invisible until every durable write succeeds. Promotion + // is synchronous under the same lifecycle gate, so clients never route to + // a socket whose namespace activation may still fail on database projection. + if !device_manager.activate_pending(expected_user_id, expected_device_id, token, conn_id) { + project_device_offline_if_unowned(db, device_manager, expected_user_id, expected_device_id) + .await?; + return Ok(false); + } + Ok(true) +} + +pub(crate) async fn activate_pending_device_if_authorized( + db: &crate::db::DbPool, + device_manager: &crate::relay::DeviceManager, + token: &str, + expected_user_id: &str, + expected_device_id: &str, + device_name: &str, + device_kind: Option<&str>, + conn_id: ConnId, +) -> anyhow::Result { + // Serialize the final token lookup, durable device update, activation, and + // online projection with logout/delete/socket cleanup. No persistent side + // effect occurs before this lookup, so a deleted device cannot be revived + // by an namespace activation request that passed only the earlier lookup. + let _presence_projection_guard = device_manager.lock_presence_projection().await; + if !registered_device_token_is_current(db, token, expected_user_id, expected_device_id).await? { + device_manager.disconnect_device_if_token(expected_user_id, expected_device_id, token); + reconcile_device_after_token_disconnect( + db, + device_manager, + expected_user_id, + expected_device_id, + ) + .await?; + return Ok(false); + } + + if let Err(error) = crate::db::DeviceRow::upsert( + db, + expected_device_id, + expected_user_id, + device_name, + device_kind, + None, + ) + .await + { + device_manager.disconnect_pending(conn_id); + return Err(error); + } + if let Err(error) = + crate::db::DeviceRow::set_online(db, expected_user_id, expected_device_id, true).await + { + device_manager.disconnect_pending(conn_id); + return Err(error); + } + complete_pending_device_activation_if_authorized( + db, + device_manager, + token, + expected_user_id, + expected_device_id, + conn_id, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{connect, AuthToken, DeviceRow, UserRow}; + use crate::relay::DeviceManager; + use tokio::sync::{mpsc, watch}; + #[tokio::test] + async fn token_revoked_between_initial_validation_and_activation_is_rejected() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + + // This is the first namespace activation lookup. Pause the conceptual request + // after it, then let logout delete the row before registration's + // mandatory second lookup. + assert!(AuthToken::find(&db, &token).await.unwrap().is_some()); + sqlx::query("DELETE FROM auth_tokens WHERE token = ?") + .bind(&token) + .execute(&db) + .await + .unwrap(); + + let manager = DeviceManager::new(); + let (tx, _rx) = mpsc::channel(4); + let (close_tx, mut close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + assert!(manager.online_devices("owner").is_empty()); + assert!(manager.conn_mapping(1).is_none()); + + assert!( + !registered_device_token_is_current(&db, &token, "owner", "device-a") + .await + .unwrap() + ); + assert!(!activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", None, 1, + ) + .await + .unwrap()); + close_rx.changed().await.unwrap(); + assert!(*close_rx.borrow()); + assert!(manager.conn_mapping(1).is_none()); + } + + #[tokio::test] + async fn expired_token_disconnects_active_and_pending_without_ghost_online_projection() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) + .await + .unwrap(); + DeviceRow::set_online(&db, "owner", "device-a", true) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + + let manager = DeviceManager::new(); + let (active_tx, _active_rx) = mpsc::channel(4); + let (active_close_tx, mut active_close_rx) = watch::channel(false); + manager.register( + "owner", + "device-a", + &token, + "Device A", + 1, + active_tx, + active_close_tx, + ); + let (pending_tx, _pending_rx) = mpsc::channel(4); + let (pending_close_tx, mut pending_close_rx) = watch::channel(false); + manager.register_pending( + "owner", + "device-a", + &token, + "Device A", + 2, + pending_tx, + pending_close_tx, + ); + let (peer_tx, _peer_rx) = mpsc::channel(4); + let (peer_close_tx, _peer_close_rx) = watch::channel(false); + manager.register( + "owner", + "device-c", + "independent-token", + "Device C", + 3, + peer_tx, + peer_close_tx, + ); + + sqlx::query("UPDATE auth_tokens SET expires_at = ? WHERE token = ?") + .bind(chrono::Utc::now().timestamp()) + .bind(&token) + .execute(&db) + .await + .unwrap(); + assert!(!activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", None, 2, + ) + .await + .unwrap()); + + active_close_rx.changed().await.unwrap(); + pending_close_rx.changed().await.unwrap(); + assert!(*active_close_rx.borrow()); + assert!(*pending_close_rx.borrow()); + assert!(manager.conn_mapping(1).is_none()); + assert!(manager.conn_mapping(2).is_none()); + assert_eq!( + manager.conn_mapping(3), + Some(("owner".into(), "device-c".into())) + ); + assert!(!manager.route_message("owner", "device-a", "opaque")); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 0); + } + + #[tokio::test] + async fn token_expiring_during_durable_projection_never_receives_auth_ok() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + let manager = DeviceManager::new(); + let (tx, mut rx) = mpsc::channel(4); + let (close_tx, mut close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + + let _projection_guard = manager.lock_presence_projection().await; + assert!( + registered_device_token_is_current(&db, &token, "owner", "device-a") + .await + .unwrap() + ); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) + .await + .unwrap(); + DeviceRow::set_online(&db, "owner", "device-a", true) + .await + .unwrap(); + sqlx::query("UPDATE auth_tokens SET expires_at = ? WHERE token = ?") + .bind(chrono::Utc::now().timestamp()) + .bind(&token) + .execute(&db) + .await + .unwrap(); + + assert!(!complete_pending_device_activation_if_authorized( + &db, &manager, &token, "owner", "device-a", 1, + ) + .await + .unwrap()); + close_rx.changed().await.unwrap(); + assert!(*close_rx.borrow()); + assert!( + rx.try_recv().is_err(), + "expired activation must not emit control data" + ); + assert!(manager.conn_mapping(1).is_none()); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 0); + } + + #[tokio::test] + async fn stale_auth_connect_cannot_recreate_a_deleted_device() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + assert!(AuthToken::find(&db, &token).await.unwrap().is_some()); + + assert!(DeviceRow::delete_for_user(&db, "owner", "device-a") + .await + .unwrap()); + let manager = DeviceManager::new(); + let (tx, _rx) = mpsc::channel(4); + let (close_tx, mut close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + + assert!(!activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", None, 1, + ) + .await + .unwrap()); + close_rx.changed().await.unwrap(); + assert!(*close_rx.borrow()); + assert!(DeviceRow::list_by_user(&db, "owner") + .await + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn pending_device_becomes_routable_only_after_durable_projection() { + let db = connect(":memory:").await.unwrap(); + UserRow::create(&db, "owner", "alice").await.unwrap(); + DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "device-a") + .await + .unwrap() + .token; + let manager = DeviceManager::new(); + let (tx, mut rx) = mpsc::channel(4); + let (close_tx, _close_rx) = watch::channel(false); + manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); + + assert!(manager.online_devices("owner").is_empty()); + assert!(!manager.route_message("owner", "device-a", "opaque")); + assert!(activate_pending_device_if_authorized( + &db, &manager, &token, "owner", "device-a", "Device A", None, 1, + ) + .await + .unwrap()); + + assert_eq!( + manager.conn_mapping(1), + Some(("owner".into(), "device-a".into())) + ); + assert_eq!(manager.online_devices("owner").len(), 1); + let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].online, 1); + + assert!( + rx.try_recv().is_err(), + "directory activation must not enqueue a second wire protocol" + ); + } +} diff --git a/src/crates/services/relay-service/src/realtime/mod.rs b/src/crates/services/relay-service/src/realtime/mod.rs new file mode 100644 index 0000000000..b057bbfc64 --- /dev/null +++ b/src/crates/services/relay-service/src/realtime/mod.rs @@ -0,0 +1,698 @@ +//! Account / machine / session scoped realtime transport, following Happy's +//! Socket.IO update, ephemeral and acknowledged RPC contracts. +//! +//! Reference: slopus/happy @ 108a337e87a5653b604250a9ac3e3bd873dba551. +//! OpenBitFun keeps GitHub device credentials and host-owned execution; the +//! relay stores only opaque encrypted messages and versioned metadata. +mod device_lifecycle; +mod origin; +mod payloads; +mod presence; +pub(crate) mod store; + +use crate::{db::AuthToken, routes::api::AppState}; +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + routing::{any, get, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use socketioxide::{ + extract::{AckSender, SocketRef, TryData}, + handler::ConnectHandler, + SocketIo, +}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex, Weak}, + time::Duration, +}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +#[derive(Clone)] +struct Identity { + account: String, + device: String, + token: String, + scope: Scope, + session: Option, + account_calls: Arc, + server_calls: Arc, + _connection: Arc, +} +#[derive(Clone, Copy, Deserialize, PartialEq)] +enum Scope { + #[serde(rename = "user-scoped")] + User, + #[serde(rename = "machine-scoped")] + Machine, + #[serde(rename = "session-scoped")] + Session, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Handshake { + token: String, + client_type: Scope, + machine_id: Option, + session_id: Option, +} +#[derive(Deserialize)] +struct Method { + method: String, +} +#[derive(Deserialize)] +struct Call { + method: String, + params: Value, + #[serde(default, rename = "timeoutMs")] + timeout_ms: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MachineEvent { + target_device_id: String, + params: Value, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MetadataUpdate { + sid: String, + metadata: String, + expected_version: i64, +} + +fn account_room(account: &str) -> String { + format!("user:{account}") +} +fn rpc_room(account: &str, method: &str) -> String { + format!("rpc:{account}:{method}") +} +fn failure(error: &str) -> Value { + json!({"ok":false,"error":error}) +} + +async fn authorized(state: &AppState, identity: &Identity) -> bool { + matches!(AuthToken::find(&state.db,&identity.token).await, + Ok(Some(auth)) if auth.user_id==identity.account && auth.can_control_devices()) +} + +fn can_register(identity: &Identity, method: &str) -> bool { + if method.len() > 256 { + return false; + } + let Some((owner, name)) = method.split_once(':') else { + return false; + }; + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-:".contains(&b)) + { + return false; + } + match identity.scope { + Scope::Machine => owner == identity.device, + Scope::Session => identity.session.as_deref() == Some(owner), + Scope::User => false, + } +} + +/// Include JSON container/node overhead, strings and task/ack state. This is +/// deliberately conservative and independent from request count. +fn rpc_memory_cost(value: &Value) -> usize { + fn node(value: &Value) -> usize { + let nested = match value { + Value::String(text) => text.len().saturating_mul(4), + Value::Array(values) => values.iter().map(node).fold(0usize, usize::saturating_add), + Value::Object(values) => values + .iter() + .map(|(key, value)| key.len().saturating_mul(4).saturating_add(node(value))) + .fold(0usize, usize::saturating_add), + _ => 0, + }; + 128usize.saturating_add(nested) + } + 4096usize.saturating_add(node(value)) +} + +/// Construct once per Relay host. Socket.IO owns heartbeat, framing, rooms and +/// acknowledgement lifetime. Platform clients do not implement competing RPC +/// correlation registries in their UI or feature modules. +pub(crate) fn mount(router: Router, state: AppState) -> Router { + let (layer, io) = SocketIo::builder() + .req_path("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/v1/updates") + .ping_interval(Duration::from_secs(15)) + .ping_timeout(Duration::from_secs(45)) + .connect_timeout(Duration::from_secs(15)) + .ack_timeout(Duration::from_secs(30)) + .max_buffer_size(128) + .max_payload(256 * 1024) + .build_layer(); + let connections = Arc::new(Semaphore::new(4096)); + // Admission accounts for retained JSON and per-call state rather than an + // arbitrary number of requests. Bulk ciphertext travels through HTTP. + let server_calls = Arc::new(Semaphore::new(64 * 1024 * 1024)); + let account_budgets: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let auth_state = state.clone(); + let handler_state = state.clone(); + let handler_io = io.clone(); + io.ns( + "/", + (move |socket: SocketRef| { + let state = handler_state.clone(); + let io = handler_io.clone(); + async move { + install(socket, state, io).await; + } + }) + .with( + move |socket: SocketRef, TryData(data): TryData| { + let state = auth_state.clone(); + let connections = connections.clone(); + let server_calls = server_calls.clone(); + let account_budgets = account_budgets.clone(); + async move { + let data = data.map_err(|_| "invalid authentication payload")?; + if !origin::is_websocket_origin_allowed( + &socket.req_parts().headers, + &state.cors_allow_origins, + ) { + return Err("origin is not allowed"); + } + let permit = connections + .try_acquire_owned() + .map_err(|_| "connection capacity exceeded")?; + let auth = AuthToken::find(&state.db, &data.token) + .await + .map_err(|_| "authentication unavailable")? + .filter(|auth| auth.can_control_devices()) + .ok_or("invalid or expired token")?; + let device = auth + .routing_device_id(&state.db) + .await + .map_err(|_| "device identity unavailable")?; + match data.client_type { + Scope::Machine + if !auth.is_device_token() + || data.machine_id.as_deref().is_some_and(|id| id != device) => + { + return Err("machine identity mismatch") + } + Scope::Session => { + let id = data.session_id.as_deref().ok_or("sessionId required")?; + let session = store::get_session(&state.db, &auth.user_id, id) + .await + .map_err(|_| "session unavailable")? + .ok_or("session not found")?; + if !auth.is_device_token() || session.machine_id != device { + return Err("session owner mismatch"); + } + } + _ => {} + } + let account_calls = { + let mut budgets = account_budgets + .lock() + .map_err(|_| "account capacity unavailable")?; + budgets.retain(|_, budget| budget.strong_count() > 0); + if let Some(budget) = budgets.get(&auth.user_id).and_then(Weak::upgrade) { + budget + } else { + let budget = Arc::new(Semaphore::new(16 * 1024 * 1024)); + budgets.insert(auth.user_id.clone(), Arc::downgrade(&budget)); + budget + } + }; + socket.extensions.insert(Identity { + account: auth.user_id, + device, + token: data.token, + scope: data.client_type, + session: data.session_id, + account_calls, + server_calls, + _connection: Arc::new(permit), + }); + Ok(()) + } + }, + ), + ); + router + // Own the Engine.IO path explicitly. A host may replace its fallback + // with ServeDir after construction; Socket.IO must remain a route. + .route("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/v1/updates", any(|| async { StatusCode::NOT_FOUND })) + .route("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/v1/updates/", any(|| async { StatusCode::NOT_FOUND })) + .route( + "/v1/rpc/payloads", + post(payloads::upload).layer(axum::extract::DefaultBodyLimit::disable()), + ) + .route("/v1/rpc/payloads/{id}", get(payloads::download)) + .layer(axum::Extension(payloads::Payloads::new())) + .route("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/v1/sessions", post(open_session)) + .route("/v1/sessions/{id}", get(get_session)) + .route( + "/v3/sessions/{id}/messages", + get(read_messages) + .post(write_messages) + .layer(axum::extract::DefaultBodyLimit::max(store::MAX_BATCH_BYTES)), + ) + .layer(axum::Extension(io)) + .layer(layer) +} + +async fn install(socket: SocketRef, state: AppState, io: SocketIo) { + let Some(identity) = socket.extensions.get::() else { + let _ = socket.disconnect(); + return; + }; + socket.join(account_room(&identity.account)); + socket.join(format!( + "user:{}:device:{}", + identity.account, identity.device + )); + if identity.scope == Scope::User { + socket.join(format!("user:{}:user-scoped", identity.account)); + } + if identity.scope == Scope::Machine + && !presence::register(&socket, &state, &identity, &io).await + { + let _ = socket.disconnect(); + return; + } + + if let Some(session) = &identity.session { + socket.join(format!("user:{}:session:{session}", identity.account)); + } + let auth_socket = socket.clone(); + let auth_identity = identity.clone(); + let auth_state = state.clone(); + // Also applies to idle sockets. Revoked credentials cannot keep receiving + // another device's encrypted traffic indefinitely after logout. + tokio::spawn(async move { + let mut timer = tokio::time::interval(Duration::from_secs(5)); + loop { + timer.tick().await; + if !auth_socket.connected() { + break; + } + if !authorized(&auth_state, &auth_identity).await { + let _ = auth_socket.disconnect(); + break; + } + } + }); + + let register_state = state.clone(); + socket.on( + "rpc-register", + move |socket: SocketRef, TryData(data): TryData, ack: AckSender| { + let state = register_state.clone(); + async move { + let Some(identity) = socket.extensions.get::() else { + return; + }; + let Ok(data) = data else { + let _ = ack.send(&failure("invalid method")); + return; + }; + if !authorized(&state, &identity).await || !can_register(&identity, &data.method) { + let _ = ack.send(&failure("method registration forbidden")); + return; + } + socket.join(rpc_room(&identity.account, &data.method)); + let _ = socket.emit("rpc-registered", &json!({"method":data.method})); + let _ = ack.send(&json!({"ok":true})); + } + }, + ); + socket.on( + "rpc-unregister", + |socket: SocketRef, TryData(data): TryData, ack: AckSender| async move { + let Some(identity) = socket.extensions.get::() else { + return; + }; + if let Ok(data) = data { + socket.leave(rpc_room(&identity.account, &data.method)); + let _ = ack.send(&json!({"ok":true})); + } else { + let _ = ack.send(&failure("invalid method")); + } + }, + ); + let rpc_state = state.clone(); + let rpc_io = io.clone(); + socket.on("rpc-call",move |socket:SocketRef,TryData(data):TryData,ack:AckSender| { + let state=rpc_state.clone(); let io=rpc_io.clone(); + async move { + let Some(identity)=socket.extensions.get::() else { return; }; + let Ok(data)=data else { let _=ack.send(&failure("invalid RPC")); return; }; + let timeout_ms = data.timeout_ms.unwrap_or(120_000); + if timeout_ms == 0 || timeout_ms > i32::MAX as u64 { let _=ack.send(&failure("invalid RPC timeout")); return; } + let call_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let reservation = rpc_memory_cost(&data.params).saturating_add(data.method.len()).min(u32::MAX as usize) as u32; + let Ok(_account_permit) = identity.account_calls.clone().try_acquire_many_owned(reservation) else { let _=ack.send(&failure("account RPC memory budget busy; request was not submitted")); return; }; + let Ok(_server_permit) = identity.server_calls.clone().try_acquire_many_owned(reservation) else { let _=ack.send(&failure("server RPC memory budget busy; request was not submitted")); return; }; + if data.method.len()>256 || !authorized(&state,&identity).await { let _=ack.send(&failure("RPC forbidden")); return; } + let room=rpc_room(&identity.account,&data.method); + // Happy's reconnect grace belongs to target lookup, before dispatch. + // After dispatch, a missing acknowledgement is UNKNOWN, never a + // reason to execute the mutation again on a replacement socket. + let deadline=(tokio::time::Instant::now()+Duration::from_secs(15)).min(call_deadline); + let target=loop { + let targets=io.within(room.clone()).sockets(); + if targets.len()>1 { let _=ack.send(&failure("multiple RPC owners")); return; } + if let Some(target)=targets.into_iter().next() { break target; } + if tokio::time::Instant::now()>=deadline || !socket.connected() { + let _=ack.send(&failure("RPC target unavailable")); return; + } + tokio::time::sleep(Duration::from_millis(200)).await; + }; + if target.id==socket.id { let _=ack.send(&failure("RPC self-call forbidden")); return; } + let Some(target_identity)=target.extensions.get::() else { let _=ack.send(&failure("RPC target unavailable")); return; }; + if !authorized(&state,&identity).await || !authorized(&state,&target_identity).await { + let _=ack.send(&failure("RPC authorization expired")); return; + } + let remaining = call_deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { let _=ack.send(&failure("RPC deadline elapsed before dispatch")); return; } + let request=json!({"method":data.method,"params":data.params,"sourceDeviceId":identity.device,"timeoutMs":remaining.as_millis()}); + let response=match target.timeout(remaining).emit_with_ack::<_,Value>("rpc-request",&request) { + Ok(pending)=>match pending.await { + Ok(value)=>match value.get("$relayError").and_then(Value::as_str) { + Some(error) => failure(error), + None => json!({"ok":true,"result":value}), + }, + Err(_)=>failure("RPC acknowledgement lost; delivery outcome is unknown"), + }, + Err(_)=>failure("RPC target unavailable before dispatch"), + }; + let _=ack.send(&response); + } + }); + let event_state = state.clone(); + let event_io = io.clone(); + socket.on("machine-event",move |socket:SocketRef,TryData(data):TryData,ack:AckSender| { + let state=event_state.clone(); let io=event_io.clone(); + async move { + let Some(identity)=socket.extensions.get::() else{return;}; + let Ok(data)=data else {let _=ack.send(&failure("invalid event"));return;}; + if identity.scope!=Scope::Machine || !authorized(&state,&identity).await {let _=ack.send(&failure("event forbidden"));return;} + let room=format!("user:{}:device:{}",identity.account,data.target_device_id); + let targets=io.within(room).sockets(); + if targets.is_empty(){let _=ack.send(&failure("event target offline"));return;} + let event=json!({"type":"device-event","sourceDeviceId":identity.device,"params":data.params}); + let mut sent=false; + for target in targets { + if target.emit("ephemeral",&event).is_ok(){sent=true;}else{let _=target.disconnect();} + } + let _=ack.send(&json!({"ok":sent})); + } + }); + let metadata_state = state.clone(); + let metadata_io = io.clone(); + socket.on( + "update-metadata", + move |socket: SocketRef, TryData(data): TryData, ack: AckSender| { + let state = metadata_state.clone(); + let io = metadata_io.clone(); + async move { + let Some(identity) = socket.extensions.get::() else { + return; + }; + let Ok(data) = data else { + let _ = ack.send(&failure("invalid metadata update")); + return; + }; + if !authorized(&state, &identity).await { + let _ = ack.send(&failure("authorization expired")); + return; + } + match store::update_metadata( + &state.db, + &identity.account, + &data.sid, + data.expected_version, + &data.metadata, + ) + .await + { + Ok(mut result) => { + if let Some(update) = result + .as_object_mut() + .and_then(|result| result.remove("update")) + { + for target in io.within(account_room(&identity.account)).sockets() { + if target.emit("update", &update).is_err() { + let _ = target.disconnect(); + } + } + } + let _ = ack.send(&result); + } + Err(_) => { + let _ = ack.send(&failure("metadata update failed")); + } + } + } + }, + ); + let _ = socket.emit( + "auth-ok", + &json!({"userId":identity.account,"deviceId":identity.device}), + ); + presence::broadcast(&io, &state, &identity.account); +} + +async fn http_identity(state: &AppState, headers: &HeaderMap) -> Result { + let token = + crate::routes::auth::extract_bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?; + AuthToken::find(&state.db, &token) + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + .filter(|auth| auth.can_control_devices()) + .ok_or(StatusCode::UNAUTHORIZED) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenSession { + id: String, + metadata: String, +} +async fn open_session( + State(state): State, + headers: HeaderMap, + Json(data): Json, +) -> Result, StatusCode> { + let identity = http_identity(&state, &headers).await?; + if !identity.is_device_token() { + return Err(StatusCode::FORBIDDEN); + } + let session = store::open_session( + &state.db, + &identity.user_id, + &identity.device_id, + &data.id, + &data.metadata, + ) + .await + .map_err(|_| StatusCode::CONFLICT)?; + Ok(Json(json!({"session":session}))) +} +async fn get_session( + State(state): State, + Path(id): Path, + headers: HeaderMap, +) -> Result, StatusCode> { + let identity = http_identity(&state, &headers).await?; + let session = store::get_session(&state.db, &identity.user_id, &id) + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(json!({"session":session}))) +} +#[derive(Deserialize)] +struct ReadMessages { + after_seq: Option, + before_seq: Option, + limit: Option, +} + +#[cfg(test)] +mod history_route_tests { + use super::*; + use axum::{ + body::{to_bytes, Body}, + http::Request, + }; + use tower::ServiceExt; + + #[tokio::test] + async fn engine_handshake_survives_host_static_fallback() { + let app = crate::build_relay_router( + Arc::new(crate::MemoryAssetStore::new()), + std::time::Instant::now(), + Arc::new(crate::db::connect(":memory:").await.unwrap()), + "test", + ) + .fallback(|| async { "static page" }); + for path in ["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/v1/updates", "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/v1/updates/"] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("{path}?EIO=4&transport=polling")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 4096).await.unwrap(); + assert!(body.starts_with(b"0{"), "Engine.IO open packet expected"); + } + let response = app + .oneshot( + Request::builder() + .uri("/index.html") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + to_bytes(response.into_body(), 4096).await.unwrap(), + "static page" + ); + } + + // Happy's initial page uses a backward sentinel; subsequent pages keep + // separate oldest and newest cursors. Exercise the real HTTP contract. + #[tokio::test] + async fn latest_page_and_forward_recovery_share_the_same_messages() { + let db = Arc::new(crate::db::connect(":memory:").await.unwrap()); + crate::db::UserRow::create(&db, "owner", "owner") + .await + .unwrap(); + crate::db::DeviceRow::upsert(&db, "host", "owner", "host", None, None) + .await + .unwrap(); + let token = AuthToken::create(&db, "owner", "host").await.unwrap().token; + store::open_session(&db, "owner", "host", "history", "opaque") + .await + .unwrap(); + for batch in 0..3 { + let messages = (0..50) + .map(|index| store::NewMessage { + local_id: format!("message-{}", batch * 50 + index), + content: "opaque".into(), + }) + .collect::>(); + store::append(&db, "owner", "history", &messages) + .await + .unwrap(); + } + let app = crate::build_relay_router( + Arc::new(crate::MemoryAssetStore::new()), + std::time::Instant::now(), + db, + "test", + ); + let request = |query: &str| { + Request::builder() + .uri(format!("/v3/sessions/history/messages?{query}")) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() + }; + for (query, first, last, more) in [ + ("before_seq=9007199254740991&limit=100", 150, 51, true), + ("before_seq=51&limit=100", 50, 1, false), + ("after_seq=148&limit=100", 149, 150, false), + ] { + let response = app.clone().oneshot(request(query)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await.unwrap()) + .unwrap(); + let messages = value["messages"].as_array().unwrap(); + assert_eq!(messages.first().unwrap()["seq"], first); + assert_eq!(messages.last().unwrap()["seq"], last); + assert_eq!(value["hasMore"], more); + } + assert_eq!( + app.oneshot(request("after_seq=0&before_seq=100")) + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + } +} +async fn read_messages( + State(state): State, + Path(id): Path, + headers: HeaderMap, + Query(query): Query, +) -> Result, StatusCode> { + let identity = http_identity(&state, &headers).await?; + if store::get_session(&state.db, &identity.user_id, &id) + .await + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)? + .is_none() + { + return Err(StatusCode::NOT_FOUND); + } + if query.after_seq.is_some() && query.before_seq.is_some() { + return Err(StatusCode::BAD_REQUEST); + } + store::read( + &state.db, + &identity.user_id, + &id, + query.after_seq.unwrap_or(0), + query.before_seq, + query.limit.unwrap_or(100), + ) + .await + .map(Json) + .map_err(|_| StatusCode::BAD_REQUEST) +} +#[derive(Deserialize)] +struct WriteMessages { + messages: Vec, +} +async fn write_messages( + State(state): State, + axum::Extension(io): axum::Extension, + Path(id): Path, + headers: HeaderMap, + Json(data): Json, +) -> Result, StatusCode> { + let identity = http_identity(&state, &headers).await?; + let messages = store::append(&state.db, &identity.user_id, &id, &data.messages) + .await + .map_err(|_| StatusCode::CONFLICT)?; + for (message, user_seq) in &messages { + if let Some(seq) = user_seq { + // Commit precedes notification. A disconnect here loses only the + // hint; after_seq always recovers the committed message. + let update = json!({"id":message.id,"seq":seq,"createdAt":message.created_at,"body":{"t":"new-message","sid":id,"message":message}}); + let rooms = [ + format!("user:{}:user-scoped", identity.user_id), + format!("user:{}:session:{id}", identity.user_id), + ]; + for socket in io.within(rooms).sockets() { + if socket.emit("update", &update).is_err() { + let _ = socket.disconnect(); + } + } + } + } + Ok(Json( + json!({"messages":messages.into_iter().map(|(m,_)|m).collect::>()}), + )) +} diff --git a/src/crates/services/relay-service/src/realtime/origin.rs b/src/crates/services/relay-service/src/realtime/origin.rs new file mode 100644 index 0000000000..daabeee2f0 --- /dev/null +++ b/src/crates/services/relay-service/src/realtime/origin.rs @@ -0,0 +1,70 @@ +//! Browser origin policy shared by every realtime scope. +use axum::http::{header, HeaderMap}; + +pub(crate) fn is_websocket_origin_allowed(headers: &HeaderMap, allowed_origins: &[String]) -> bool { + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + else { + // Native clients do not send Origin and authenticate at the protocol + // layer. Origin is a browser boundary, not a replacement for auth. + return true; + }; + if origin.eq_ignore_ascii_case("null") { + return false; + } + let Some(origin) = crate::normalized_browser_origin(origin) else { + return false; + }; + if allowed_origins + .iter() + .any(|allowed| allowed == "*" || allowed.eq_ignore_ascii_case(&origin)) + { + return true; + } + + let Some(host) = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + else { + return false; + }; + let origin_authority = origin + .split_once("://") + .map(|(_, authority)| authority) + .and_then(|authority| authority.split('/').next()); + origin_authority.is_some_and(|authority| authority.eq_ignore_ascii_case(host)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn websocket_browser_origin_is_same_origin_or_explicitly_allowed() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "relay.example.com".parse().unwrap()); + headers.insert(header::ORIGIN, "https://relay.example.com".parse().unwrap()); + assert!(is_websocket_origin_allowed(&headers, &[])); + + headers.insert(header::ORIGIN, "https://app.example.com".parse().unwrap()); + assert!(!is_websocket_origin_allowed(&headers, &[])); + assert!(is_websocket_origin_allowed( + &headers, + &["https://app.example.com".to_string()] + )); + + headers.insert(header::ORIGIN, "null".parse().unwrap()); + assert!(!is_websocket_origin_allowed(&headers, &["*".to_string()])); + + for invalid in [ + "file://relay.example.com", + "https://relay.example.com/path", + "https://user@relay.example.com", + ] { + headers.insert(header::ORIGIN, invalid.parse().unwrap()); + assert!(!is_websocket_origin_allowed(&headers, &[])); + } + } +} diff --git a/src/crates/services/relay-service/src/realtime/payloads.rs b/src/crates/services/relay-service/src/realtime/payloads.rs new file mode 100644 index 0000000000..9f3e0716e8 --- /dev/null +++ b/src/crates/services/relay-service/src/realtime/payloads.rs @@ -0,0 +1,244 @@ +//! Short-lived opaque RPC payloads. HTTP streams bulk bytes while Socket.IO +//! carries a small reference; slow file transfers never occupy the event lane. +use super::*; +use axum::{ + body::Body, + extract::Request, + response::{IntoResponse, Response}, + Extension, +}; +use futures_util::StreamExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +pub(crate) const MAX_BYTES: usize = 64 * 1024 * 1024; +const DISK_BUDGET: usize = 512 * 1024 * 1024; +const TTL: Duration = Duration::from_secs(300); + +struct Payload { + account: String, + file: tempfile::TempPath, + size: usize, + expires: std::time::Instant, + _storage: Vec, +} + +pub(super) struct Payloads { + entries: Mutex>>, + storage: Arc, +} + +impl Payloads { + pub(super) fn new() -> Arc { + let store = Arc::new(Self { + entries: Mutex::new(HashMap::new()), + storage: Arc::new(Semaphore::new(DISK_BUDGET)), + }); + let weak = Arc::downgrade(&store); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + let Some(store) = weak.upgrade() else { + break; + }; + store + .entries + .lock() + .unwrap() + .retain(|_, entry| entry.expires > std::time::Instant::now()); + } + }); + store + } +} + +pub(super) async fn upload( + State(state): State, + Extension(store): Extension>, + request: Request, +) -> Result, StatusCode> { + let auth = crate::routes::devices::validate_user(&state, request.headers()).await?; + if request + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n > MAX_BYTES) + { + return Err(StatusCode::PAYLOAD_TOO_LARGE); + } + let overhead = store + .storage + .clone() + .try_acquire_many_owned(16 * 1024) + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; + // Tempfile uses private, create-new files and removes cancelled uploads. + let temporary = tempfile::NamedTempFile::new().map_err(|_| StatusCode::INSUFFICIENT_STORAGE)?; + let (file, path) = temporary.into_parts(); + let mut file = tokio::fs::File::from_std(file); + let mut stream = request.into_body().into_data_stream(); + let mut size = 0usize; + let mut storage = vec![overhead]; + while let Some(chunk) = tokio::time::timeout(Duration::from_secs(30), stream.next()) + .await + .map_err(|_| StatusCode::REQUEST_TIMEOUT)? + { + let chunk = chunk.map_err(|_| StatusCode::BAD_REQUEST)?; + size = size + .checked_add(chunk.len()) + .filter(|size| *size <= MAX_BYTES) + .ok_or(StatusCode::PAYLOAD_TOO_LARGE)?; + storage.push( + store + .storage + .clone() + .try_acquire_many_owned(chunk.len() as u32) + .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?, + ); + file.write_all(&chunk) + .await + .map_err(|_| StatusCode::INSUFFICIENT_STORAGE)?; + } + if size == 0 { + return Err(StatusCode::BAD_REQUEST); + } + file.flush() + .await + .map_err(|_| StatusCode::INSUFFICIENT_STORAGE)?; + drop(file); + let id = uuid::Uuid::new_v4().to_string(); + store.entries.lock().unwrap().insert( + id.clone(), + Arc::new(Payload { + account: auth.user_id, + file: path, + size, + expires: std::time::Instant::now() + TTL, + _storage: storage, + }), + ); + Ok(Json(json!({"$relayPayload": {"id":id,"bytes":size}}))) +} + +pub(super) async fn download( + State(state): State, + Extension(store): Extension>, + Path(id): Path, + headers: HeaderMap, +) -> Result { + let auth = crate::routes::devices::validate_user(&state, &headers).await?; + let entry = store + .entries + .lock() + .unwrap() + .get(&id) + .filter(|entry| entry.account == auth.user_id && entry.expires > std::time::Instant::now()) + .cloned() + .ok_or(StatusCode::NOT_FOUND)?; + let file = tokio::fs::File::open(&entry.file) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + let size = entry.size; + // Keep the file/storage lease alive until the reader completes or cancels. + let stream = futures_util::stream::try_unfold((file, entry), |(mut file, entry)| async move { + let mut buffer = vec![0u8; 64 * 1024]; + let read = file.read(&mut buffer).await?; + if read == 0 { + return Ok::<_, std::io::Error>(None); + } + buffer.truncate(read); + Ok(Some((buffer, (file, entry)))) + }); + Ok(( + [ + ("content-type", "application/octet-stream".to_string()), + ("content-length", size.to_string()), + ("cache-control", "no-store".to_string()), + ], + Body::from_stream(stream), + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt; + #[tokio::test] + async fn bulk_transfer_exceeds_ws_size_is_account_scoped_and_revocable() { + let db = Arc::new(crate::db::connect(":memory:").await.unwrap()); + let mut tokens = Vec::new(); + for account in ["owner", "other"] { + crate::db::UserRow::create(&db, account, account) + .await + .unwrap(); + crate::db::DeviceRow::upsert(&db, "device", account, "device", None, None) + .await + .unwrap(); + tokens.push( + AuthToken::create(&db, account, "device") + .await + .unwrap() + .token, + ); + } + let router = crate::build_relay_router( + Arc::new(crate::MemoryAssetStore::new()), + std::time::Instant::now(), + db.clone(), + "test", + ); + let bytes = vec![42; 3 * 1024 * 1024]; + let upload = Request::builder() + .method("POST") + .uri("/v1/rpc/payloads") + .header("authorization", format!("Bearer {}", tokens[0])) + .body(Body::from(bytes.clone())) + .unwrap(); + let response = router.clone().oneshot(upload).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let reference: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(reference["$relayPayload"]["bytes"], bytes.len()); + let path = format!( + "/v1/rpc/payloads/{}", + reference["$relayPayload"]["id"].as_str().unwrap() + ); + let get = |token: &str| { + Request::builder() + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() + }; + assert_eq!( + router + .clone() + .oneshot(get(&tokens[1])) + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + let response = router.clone().oneshot(get(&tokens[0])).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + axum::body::to_bytes(response.into_body(), MAX_BYTES) + .await + .unwrap() + .as_ref(), + bytes + ); + sqlx::query("DELETE FROM auth_tokens WHERE token=?") + .bind(&tokens[0]) + .execute(&*db) + .await + .unwrap(); + assert_eq!( + router.oneshot(get(&tokens[0])).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + } +} diff --git a/src/crates/services/relay-service/src/realtime/presence.rs b/src/crates/services/relay-service/src/realtime/presence.rs new file mode 100644 index 0000000000..bd5cbfa53c --- /dev/null +++ b/src/crates/services/relay-service/src/realtime/presence.rs @@ -0,0 +1,103 @@ +//! Bind Socket.IO lifetime to the account directory's revocation/presence owner. +use super::*; +use crate::relay::transport::OutboundMessage; +use tokio::sync::{mpsc, watch}; + +pub(super) async fn register( + socket: &SocketRef, + state: &AppState, + identity: &Identity, + io: &SocketIo, +) -> bool { + let conn_id = state.device_manager.next_connection_id(); + let (tx, mut rx) = mpsc::channel::(16); + let (close_tx, mut close_rx) = watch::channel(false); + let disconnect_tx = close_tx.clone(); + socket.on_disconnect(move || { + let tx = disconnect_tx.clone(); + async move { + let _ = tx.send(true); + } + }); + let row = match sqlx::query_as::<_, crate::db::DeviceRow>( + "SELECT * FROM devices WHERE user_id=? AND device_id=?", + ) + .bind(&identity.account) + .bind(&identity.device) + .fetch_optional(&*state.db) + .await + { + Ok(Some(row)) => row, + _ => return false, + }; + if !socket.connected() { + return false; + } + let name = row.device_name.as_deref().unwrap_or("OpenBitFun"); + state.device_manager.register_pending( + &identity.account, + &identity.device, + &identity.token, + name, + conn_id, + tx, + close_tx.clone(), + ); + let activated = super::device_lifecycle::activate_pending_device_if_authorized( + &state.db, + &state.device_manager, + &identity.token, + &identity.account, + &identity.device, + name, + row.device_kind.as_deref(), + conn_id, + ) + .await + .unwrap_or(false); + if !activated { + state.device_manager.unregister(conn_id); + return false; + } + let state = state.clone(); + let io = io.clone(); + let socket = socket.clone(); + let (account, device) = (identity.account.clone(), identity.device.clone()); + tokio::spawn(async move { + loop { + tokio::select! { + _=close_rx.changed()=>break, + message=rx.recv()=>{ + // Account directory messages are lifecycle notifications. + // Payload/RPC routing belongs exclusively to Socket.IO. + if message.is_none(){break;} + } + } + } + let _ = socket.disconnect(); + let _guard = state.device_manager.lock_presence_projection().await; + state.device_manager.unregister(conn_id); + if !state.device_manager.is_device_online(&account, &device) { + let _ = crate::db::DeviceRow::set_online(&state.db, &account, &device, false).await; + } + // Logout/delete may already remove the directory mapping before the + // socket observes cancellation. Broadcast the current owner state in + // either case, without marking a replacement connection offline. + broadcast(&io, &state, &account); + }); + true +} +pub(super) fn broadcast(io: &SocketIo, state: &AppState, account: &str) { + let devices: Vec<_> = state + .device_manager + .online_devices(account) + .into_iter() + .map(|(id, name)| json!({"device_id":id,"device_name":name})) + .collect(); + let event = json!({"type":"device-presence","devices":devices}); + for socket in io.within(account_room(account)).sockets() { + if socket.emit("ephemeral", &event).is_err() { + let _ = socket.disconnect(); + } + } +} diff --git a/src/crates/services/relay-service/src/realtime/store.rs b/src/crates/services/relay-service/src/realtime/store.rs new file mode 100644 index 0000000000..91c8befe53 --- /dev/null +++ b/src/crates/services/relay-service/src/realtime/store.rs @@ -0,0 +1,518 @@ +//! Opaque, ordered session messages. The protocol follows Happy's v3 message +//! log: stable localId for retry, independent receive seq, and bounded pages. +use crate::db::DbPool; +use anyhow::{anyhow, bail, Result}; +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; +use sqlx::Row; + +pub(crate) const MAX_BATCH: usize = 50; +pub(crate) const MAX_CONTENT_BYTES: usize = 128 * 1024; +pub(crate) const MAX_BATCH_BYTES: usize = MAX_BATCH * (MAX_CONTENT_BYTES + 256); + +pub(crate) const SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS realtime_sessions ( + account_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + id TEXT NOT NULL, + machine_id TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + metadata TEXT NOT NULL, + metadata_version INTEGER NOT NULL DEFAULT 1, + agent_state TEXT, + agent_state_version INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(account_id, id) +); +CREATE TABLE IF NOT EXISTS realtime_messages ( + account_id TEXT NOT NULL, + session_id TEXT NOT NULL, + id TEXT NOT NULL, + seq INTEGER NOT NULL, + local_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY(account_id, session_id, seq), + UNIQUE(account_id, session_id, local_id), + FOREIGN KEY(account_id, session_id) REFERENCES realtime_sessions(account_id, id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS realtime_account_sequence ( + account_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + seq INTEGER NOT NULL DEFAULT 0, + log_bytes INTEGER NOT NULL DEFAULT 0 +); +"#; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct NewMessage { + pub(crate) local_id: String, + pub(crate) content: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Message { + pub(crate) id: String, + pub(crate) seq: i64, + pub(crate) local_id: String, + pub(crate) content: EncryptedContent, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub(crate) struct EncryptedContent { + pub(crate) t: String, + pub(crate) c: String, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MessagePage { + pub(crate) messages: Vec, + pub(crate) has_more: bool, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Session { + pub(crate) id: String, + pub(crate) machine_id: String, + pub(crate) seq: i64, + pub(crate) metadata: String, + pub(crate) metadata_version: i64, + pub(crate) agent_state: Option, + pub(crate) agent_state_version: i64, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, +} + +pub(crate) fn valid_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) +} + +pub(crate) async fn open_session( + db: &DbPool, + account: &str, + machine: &str, + id: &str, + metadata: &str, +) -> Result { + if !valid_id(id) || metadata.len() > MAX_CONTENT_BYTES { + bail!("invalid session"); + } + let now = chrono::Utc::now().timestamp_millis(); + sqlx::query("INSERT INTO realtime_sessions(account_id,id,machine_id,metadata,created_at,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(account_id,id) DO NOTHING") + .bind(account).bind(id).bind(machine).bind(metadata).bind(now).bind(now).execute(db).await?; + let session = get_session(db, account, id) + .await? + .ok_or_else(|| anyhow!("session unavailable"))?; + if session.machine_id != machine { + bail!("session belongs to another machine"); + } + Ok(session) +} + +pub(crate) async fn get_session(db: &DbPool, account: &str, id: &str) -> Result> { + let row = sqlx::query("SELECT * FROM realtime_sessions WHERE account_id=? AND id=?") + .bind(account) + .bind(id) + .fetch_optional(db) + .await?; + Ok(row.map(|r| Session { + id: r.get("id"), + machine_id: r.get("machine_id"), + seq: r.get("seq"), + metadata: r.get("metadata"), + metadata_version: r.get("metadata_version"), + agent_state: r.get("agent_state"), + agent_state_version: r.get("agent_state_version"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + })) +} + +/// The transaction locks the account counter before reading either dedup keys +/// or session sequence. Concurrent writers cannot allocate the same sequence. +/// Reusing a localId with different content is an error, never silent success. +pub(crate) async fn append( + db: &DbPool, + account: &str, + session: &str, + messages: &[NewMessage], +) -> Result)>> { + if messages.is_empty() || messages.len() > MAX_BATCH { + bail!("invalid message batch size"); + } + for m in messages { + if !valid_id(&m.local_id) || m.content.is_empty() || m.content.len() > MAX_CONTENT_BYTES { + bail!("invalid message"); + } + } + let mut tx = db.begin().await?; + sqlx::query("INSERT INTO realtime_account_sequence(account_id) VALUES(?) ON CONFLICT(account_id) DO UPDATE SET seq=seq") + .bind(account).execute(&mut *tx).await?; + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM realtime_sessions WHERE account_id=? AND id=?)", + ) + .bind(account) + .bind(session) + .fetch_one(&mut *tx) + .await?; + if !exists { + bail!("session not found"); + } + let mut results = Vec::with_capacity(messages.len()); + for m in messages { + if let Some(row) = sqlx::query( + "SELECT * FROM realtime_messages WHERE account_id=? AND session_id=? AND local_id=?", + ) + .bind(account) + .bind(session) + .bind(&m.local_id) + .fetch_optional(&mut *tx) + .await? + { + let existing = message_from_row(row); + if existing.content.c != m.content { + bail!("localId content conflict"); + } + results.push((existing, None)); + continue; + } + let now = chrono::Utc::now().timestamp_millis(); + // Track storage usage without imposing a lifetime account ceiling. + // Per-request paging and admission bound memory independently of + // how long an account has been used. + let user_seq: i64 = sqlx::query_scalar("UPDATE realtime_account_sequence SET seq=seq+1,log_bytes=log_bytes+? WHERE account_id=? RETURNING seq") + .bind(m.content.len() as i64).bind(account) + .fetch_one(&mut *tx).await?; + let seq: i64 = sqlx::query_scalar("UPDATE realtime_sessions SET seq=seq+1,updated_at=? WHERE account_id=? AND id=? RETURNING seq") + .bind(now).bind(account).bind(session).fetch_one(&mut *tx).await?; + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO realtime_messages(account_id,session_id,id,seq,local_id,content,created_at) VALUES(?,?,?,?,?,?,?)") + .bind(account).bind(session).bind(&id).bind(seq).bind(&m.local_id).bind(&m.content).bind(now).execute(&mut *tx).await?; + results.push(( + Message { + id, + seq, + local_id: m.local_id.clone(), + content: EncryptedContent { + t: "encrypted".into(), + c: m.content.clone(), + }, + created_at: now, + updated_at: now, + }, + Some(user_seq), + )); + } + tx.commit().await?; + Ok(results) +} + +fn message_from_row(row: sqlx::sqlite::SqliteRow) -> Message { + Message { + id: row.get("id"), + seq: row.get("seq"), + local_id: row.get("local_id"), + content: EncryptedContent { + t: "encrypted".into(), + c: row.get("content"), + }, + created_at: row.get("created_at"), + updated_at: row.get("created_at"), + } +} + +pub(crate) async fn read( + db: &DbPool, + account: &str, + session: &str, + after: i64, + before: Option, + limit: usize, +) -> Result { + if after < 0 || before.is_some_and(|v| v <= 0) || !(1..=100).contains(&limit) { + bail!("invalid message cursor"); + } + if before.is_some() && after != 0 { + bail!("message cursors are mutually exclusive"); + } + // Stream rows so the page budget also bounds database materialization. + // Historical pages keep Happy's newest-first ordering; catch-up is ascending. + let (sql, cursor) = match before { + Some(cursor) => ("SELECT * FROM realtime_messages WHERE account_id=? AND session_id=? AND seq ("SELECT * FROM realtime_messages WHERE account_id=? AND session_id=? AND seq>? ORDER BY seq ASC LIMIT ?", after), + }; + let mut rows = sqlx::query(sql) + .bind(account) + .bind(session) + .bind(cursor) + .bind((limit + 1) as i64) + .fetch(db); + let mut messages = Vec::new(); + let mut bytes = 0; + let mut has_more = false; + while let Some(row) = rows.try_next().await? { + let message = message_from_row(row); + let size = serde_json::to_vec(&message)?.len(); + if messages.len() == limit || (!messages.is_empty() && bytes + size > 1024 * 1024) { + has_more = true; + break; + } + bytes += size; + messages.push(message); + } + Ok(MessagePage { messages, has_more }) +} + +/// Optimistic version checks use one UPDATE, so concurrent controllers cannot +/// overwrite each other after observing the same version. +pub(crate) async fn update_metadata( + db: &DbPool, + account: &str, + session: &str, + expected: i64, + metadata: &str, +) -> Result { + if expected < 0 || metadata.len() > MAX_CONTENT_BYTES { + bail!("invalid metadata"); + } + let mut tx = db.begin().await?; + sqlx::query("INSERT INTO realtime_account_sequence(account_id) VALUES(?) ON CONFLICT(account_id) DO UPDATE SET seq=seq") + .bind(account).execute(&mut *tx).await?; + let now = chrono::Utc::now().timestamp_millis(); + let version: Option = sqlx::query_scalar("UPDATE realtime_sessions SET metadata=?,metadata_version=metadata_version+1,updated_at=? WHERE account_id=? AND id=? AND metadata_version=? RETURNING metadata_version") + .bind(metadata).bind(now).bind(account).bind(session).bind(expected).fetch_optional(&mut *tx).await?; + if let Some(version) = version { + let seq: i64 = sqlx::query_scalar( + "UPDATE realtime_account_sequence SET seq=seq+1 WHERE account_id=? RETURNING seq", + ) + .bind(account) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + return Ok( + serde_json::json!({"result":"success","version":version,"metadata":metadata, + "update":{"id":uuid::Uuid::new_v4().to_string(),"seq":seq,"createdAt":now, + "body":{"t":"update-session","id":session,"metadata":{"value":metadata,"version":version}}}}), + ); + } + let current = sqlx::query( + "SELECT metadata,metadata_version FROM realtime_sessions WHERE account_id=? AND id=?", + ) + .bind(account) + .bind(session) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| anyhow!("session not found"))?; + let metadata: String = current.get("metadata"); + let version: i64 = current.get("metadata_version"); + tx.commit().await?; + Ok(serde_json::json!({"result":"version-mismatch","version":version,"metadata":metadata})) +} + +#[cfg(test)] +mod tests { + use super::*; + async fn database() -> DbPool { + let db = crate::db::connect(":memory:").await.unwrap(); + crate::db::UserRow::create(&db, "a", "a").await.unwrap(); + open_session(&db, "a", "host", "session", "opaque") + .await + .unwrap(); + db + } + #[tokio::test] + async fn continued_use_is_not_rejected_at_a_lifetime_storage_threshold() { + let db = database().await; + let previous_bytes: i64 = 256 * 1024 * 1024; + sqlx::query("INSERT INTO realtime_account_sequence(account_id, seq, log_bytes) VALUES('a', 0, ?) ON CONFLICT(account_id) DO UPDATE SET log_bytes=excluded.log_bytes") + .bind(previous_bytes).execute(&db).await.unwrap(); + let result = append( + &db, + "a", + "session", + &[NewMessage { + local_id: "continued".into(), + content: "ciphertext".into(), + }], + ) + .await + .unwrap(); + assert_eq!(result[0].0.seq, 1); + let bytes: i64 = sqlx::query_scalar( + "SELECT log_bytes FROM realtime_account_sequence WHERE account_id='a'", + ) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(bytes, previous_bytes + 10); + } + + #[tokio::test] + async fn retries_are_idempotent_and_conflicts_roll_back_the_entire_batch() { + let db = database().await; + let msg = NewMessage { + local_id: "one".into(), + content: "ciphertext".into(), + }; + let first = append(&db, "a", "session", &[msg.clone()]).await.unwrap(); + let retry = append(&db, "a", "session", &[msg.clone()]).await.unwrap(); + assert_eq!(first[0].0, retry[0].0); + assert_eq!(retry[0].1, None); + assert!(append( + &db, + "a", + "session", + &[ + NewMessage { + local_id: "two".into(), + content: "two".into() + }, + NewMessage { + content: "conflict".into(), + ..msg + } + ] + ) + .await + .is_err()); + assert_eq!( + read(&db, "a", "session", 0, None, 100) + .await + .unwrap() + .messages + .len(), + 1 + ); + } + #[tokio::test] + async fn concurrent_writers_order_without_skipping_other_senders() { + let db = database().await; + let first = [NewMessage { + local_id: "a".into(), + content: "a".into(), + }]; + let second = [NewMessage { + local_id: "b".into(), + content: "b".into(), + }]; + let (a, b) = tokio::join!( + append(&db, "a", "session", &first), + append(&db, "a", "session", &second) + ); + a.unwrap(); + b.unwrap(); + let page = read(&db, "a", "session", 0, None, 1).await.unwrap(); + assert_eq!(page.messages[0].seq, 1); + assert!(page.has_more); + let tail = read(&db, "a", "session", 1, None, 100).await.unwrap(); + assert_eq!(tail.messages[0].seq, 2); + assert!(read(&db, "other", "session", 0, None, 100) + .await + .unwrap() + .messages + .is_empty()); + } + #[tokio::test] + async fn history_pages_descend_and_catch_up_pages_ascend() { + let db = database().await; + let batch: Vec<_> = (1..=5) + .map(|n| NewMessage { + local_id: n.to_string(), + content: n.to_string(), + }) + .collect(); + append(&db, "a", "session", &batch).await.unwrap(); + let history = read(&db, "a", "session", 0, Some(6), 2).await.unwrap(); + assert_eq!( + history.messages.iter().map(|m| m.seq).collect::>(), + [5, 4] + ); + assert!(history.has_more); + let older = read(&db, "a", "session", 0, Some(4), 3).await.unwrap(); + assert_eq!( + older.messages.iter().map(|m| m.seq).collect::>(), + [3, 2, 1] + ); + assert!(!older.has_more); + assert!(read(&db, "a", "session", 1, Some(4), 3).await.is_err()); + } + #[tokio::test] + async fn byte_bounded_pages_recover_every_message_without_skips() { + let db = database().await; + let batch: Vec<_> = (0..20) + .map(|n| NewMessage { + local_id: n.to_string(), + content: "x".repeat(MAX_CONTENT_BYTES), + }) + .collect(); + append(&db, "a", "session", &batch).await.unwrap(); + let mut cursor = 0; + let mut pages = 0; + loop { + let page = read(&db, "a", "session", cursor, None, 100).await.unwrap(); + assert!(serde_json::to_vec(&page.messages).unwrap().len() < 1024 * 1024); + for message in page.messages { + assert_eq!(message.seq, cursor + 1); + cursor = message.seq; + } + pages += 1; + if !page.has_more { + break; + } + } + assert_eq!(cursor, 20); + assert!(pages > 1); + } + #[tokio::test] + async fn metadata_and_messages_share_account_sequence_but_not_receive_cursor() { + let db = database().await; + let update = update_metadata(&db, "a", "session", 1, "new") + .await + .unwrap(); + assert_eq!(update["update"]["seq"], 1); + assert!(update["update"]["id"].as_str().is_some()); + let message = append( + &db, + "a", + "session", + &[NewMessage { + local_id: "one".into(), + content: "one".into(), + }], + ) + .await + .unwrap(); + assert_eq!(message[0].1, Some(2)); + assert_eq!(message[0].0.seq, 1); + update_metadata(&db, "a", "session", 1, "stale") + .await + .unwrap(); + let sequence: i64 = + sqlx::query_scalar("SELECT seq FROM realtime_account_sequence WHERE account_id='a'") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(sequence, 2); + } + #[tokio::test] + async fn metadata_compare_and_swap_returns_current_version_on_conflict() { + let db = database().await; + assert_eq!( + update_metadata(&db, "a", "session", 1, "new") + .await + .unwrap()["result"], + "success" + ); + let conflict = update_metadata(&db, "a", "session", 1, "stale") + .await + .unwrap(); + assert_eq!(conflict["result"], "version-mismatch"); + assert_eq!(conflict["metadata"], "new"); + assert_eq!(conflict["version"], 2); + } +} diff --git a/src/crates/services/relay-service/src/relay/device_manager.rs b/src/crates/services/relay-service/src/relay/device_manager.rs index 9910cff21b..606f2f67fe 100644 --- a/src/crates/services/relay-service/src/relay/device_manager.rs +++ b/src/crates/services/relay-service/src/relay/device_manager.rs @@ -273,6 +273,17 @@ impl DeviceManager { /// Promote the exact pending connection to the single active owner for /// `(user_id, device_id)`. Returns false if logout/delete already removed /// it while the final database lookup was in flight. + pub fn activate_pending( + &self, + user_id: &str, + device_id: &str, + auth_token: &str, + conn_id: ConnId, + ) -> bool { + self.activate_pending_inner(user_id, device_id, auth_token, conn_id, None) + } + + #[cfg(test)] pub fn activate_pending_with_initial_message( &self, user_id: &str, @@ -280,6 +291,17 @@ impl DeviceManager { auth_token: &str, conn_id: ConnId, initial_text: &str, + ) -> bool { + self.activate_pending_inner(user_id, device_id, auth_token, conn_id, Some(initial_text)) + } + + fn activate_pending_inner( + &self, + user_id: &str, + device_id: &str, + auth_token: &str, + conn_id: ConnId, + initial_text: Option<&str>, ) -> bool { let _presence_guard = self.lock_presence(); let pending = self @@ -294,12 +316,11 @@ impl DeviceManager { return false; }; - // Queue AuthOk while the candidate is still invisible and while the - // same presence gate excludes snapshot broadcasts. Once membership is - // published below, every later DevicePresence is necessarily behind - // AuthOk in this socket's FIFO queue. - if !OutboundMessage::try_text(initial_text) - .is_some_and(|message| pending.tx.try_send(message).is_ok()) + if pending.tx.is_closed() + || initial_text.is_some_and(|text| { + !OutboundMessage::try_text(text) + .is_some_and(|message| pending.tx.try_send(message).is_ok()) + }) { let _ = pending.force_close_tx.send(true); return false; diff --git a/src/crates/services/relay-service/src/routes/auth.rs b/src/crates/services/relay-service/src/routes/auth.rs index ee300f90df..ee654de811 100644 --- a/src/crates/services/relay-service/src/routes/auth.rs +++ b/src/crates/services/relay-service/src/routes/auth.rs @@ -419,23 +419,6 @@ pub async fn logout(State(state): State, headers: HeaderMap) -> Status .await; } drop(_presence_projection_guard); - state - .device_manager - .broadcast_current_presence(&auth.user_id, |devices| { - let devices = devices - .iter() - .map(|(device_id, device_name)| { - crate::routes::websocket::DevicePresenceEntry { - device_id: device_id.clone(), - device_name: device_name.clone(), - } - }) - .collect(); - serde_json::to_string( - &crate::routes::websocket::OutboundProtocol::DevicePresence { devices }, - ) - .ok() - }); } else { drop(_presence_projection_guard); } diff --git a/src/crates/services/relay-service/src/routes/devices.rs b/src/crates/services/relay-service/src/routes/devices.rs index 1bad0db37a..2227ad11d0 100644 --- a/src/crates/services/relay-service/src/routes/devices.rs +++ b/src/crates/services/relay-service/src/routes/devices.rs @@ -1,41 +1,15 @@ -//! Device RPC endpoints — any authenticated client can route commands to -//! other same-account devices via HTTP (no WS needed). The relay acts as -//! a transparent router: it validates the account token, routes the opaque -//! encrypted payload to the target device's WS, waits for the response, -//! and returns it over HTTP. -//! -//! This enables mobile-web and desktop alike to browse other devices' -//! workspaces/sessions and dispatch tasks, without requiring a direct WS -//! connection or proxying through another desktop. - -use axum::extract::{DefaultBodyLimit, Path, State}; +//! Authenticated device directory and key lookup. Bidirectional RPC uses realtime. + +use axum::extract::{Path, State}; use axum::http::{header, HeaderMap, StatusCode}; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get}; use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use std::time::Duration; +use serde::Serialize; use crate::db::AuthToken; use crate::routes::api::AppState; -use crate::routes::websocket::OutboundProtocol; - -#[cfg(not(test))] -const RPC_TIMEOUT: Duration = Duration::from_secs(120); -#[cfg(test)] -const RPC_TIMEOUT: Duration = Duration::from_millis(100); const MAX_DEVICE_ID_BYTES: usize = 128; -const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 48 * 1024 * 1024; -const MAX_NONCE_BYTES: usize = 256; - -/// Body ceiling for `/api/devices/:id/rpc`. -/// -/// Without this the route inherits Axum's 2 MB default, which rejects the body -/// before `is_valid_encrypted_payload` ever runs — so a mobile client sending a -/// photo got a bare 413 while the payload validator above claimed to allow -/// 48 MB. Derived from that constant so the two cannot drift apart again; the -/// slack covers the JSON envelope and the base64 nonce. -const RPC_BODY_LIMIT_BYTES: usize = MAX_ENCRYPTED_PAYLOAD_BYTES + 64 * 1024; fn is_valid_device_id(value: &str) -> bool { !value.is_empty() @@ -45,17 +19,6 @@ fn is_valid_device_id(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } -fn is_valid_encrypted_payload(encrypted_data: &str, nonce: &str) -> bool { - !encrypted_data.is_empty() - && encrypted_data.len() <= MAX_ENCRYPTED_PAYLOAD_BYTES - && encrypted_data.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=' | b'-' | b'_') - }) - && !nonce.is_empty() - && nonce.len() <= MAX_NONCE_BYTES - && !nonce.chars().any(char::is_control) -} - /// Validate bearer token and return its account principal and capability kind. pub(crate) async fn validate_user( state: &AppState, @@ -83,14 +46,6 @@ pub fn device_router() -> Router { Router::new() .route("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/api/devices", get(list_devices)) .route("/api/devices/{target_device_id}/key", get(device_key)) - .route( - "/api/devices/{target_device_id}/rpc", - post(device_rpc).layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)), - ) - .route( - "/api/devices/{target_device_id}/messages", - post(device_message).layer(DefaultBodyLimit::max(RPC_BODY_LIMIT_BYTES)), - ) .route("/api/devices/{target_device_id}", delete(delete_device)) } @@ -202,151 +157,6 @@ async fn list_devices( // ── Device RPC ────────────────────────────────────────────────────────── -#[derive(Deserialize)] -pub struct DeviceRpcRequest { - /// Opaque ciphertext encrypted client-side with the device-pair key. - /// The relay never decrypts this — it only routes. - pub encrypted_data: String, - pub nonce: String, -} - -#[derive(Serialize)] -pub struct DeviceRpcResponse { - pub encrypted_data: String, - pub nonce: String, -} - -#[derive(Deserialize)] -struct DeviceMessageRequest { - correlation_id: String, - encrypted_data: String, - nonce: String, -} - -/// Device responses use the same authenticated, memory-admitted HTTP ingress -/// as requests. WebSocket ingress is reserved for small control messages. -async fn device_message( - State(state): State, - headers: HeaderMap, - Path(target_device_id): Path, - Json(body): Json, -) -> Result { - let auth = validate_user(&state, &headers).await?; - if !auth.is_device_token() { - return Err(StatusCode::FORBIDDEN); - } - if !is_valid_device_id(&target_device_id) - || !is_valid_device_id(&body.correlation_id) - || !is_valid_encrypted_payload(&body.encrypted_data, &body.nonce) - { - return Err(StatusCode::BAD_REQUEST); - } - let response = crate::relay::device_manager::RpcResponse::try_new( - body.encrypted_data.clone(), - body.nonce.clone(), - ) - .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; - if state.device_manager.resolve_rpc( - &body.correlation_id, - &auth.user_id, - &auth.device_id, - response, - ) { - return Ok(StatusCode::NO_CONTENT); - } - let message = OutboundProtocol::IncomingDeviceMessage { - source_device_id: auth.device_id, - correlation_id: body.correlation_id, - encrypted_data: body.encrypted_data, - nonce: body.nonce, - }; - let json = serde_json::to_string(&message).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - if !state - .device_manager - .route_message(&auth.user_id, &target_device_id, &json) - { - return Err(StatusCode::NOT_FOUND); - } - Ok(StatusCode::NO_CONTENT) -} - -/// `POST /api/devices/:target_device_id/rpc` -/// -/// Routes an encrypted command to the target device via WS, waits for the -/// encrypted response, and returns it. The relay stays zero-knowledge — it -/// only sees opaque ciphertext and routes by device_id within the account. -async fn device_rpc( - State(state): State, - headers: HeaderMap, - Path(target_device_id): Path, - Json(body): Json, -) -> Result { - let auth = validate_user(&state, &headers).await?; - let db = state.db.as_ref(); - let source_device_id = auth - .routing_device_id(db) - .await - .map_err(|_| StatusCode::FORBIDDEN)?; - let user_id = auth.user_id; - - if !is_valid_device_id(&target_device_id) - || !is_valid_encrypted_payload(&body.encrypted_data, &body.nonce) - { - return Err(StatusCode::BAD_REQUEST); - } - - // Check target device is online in this account - let online = state.device_manager.online_devices(&user_id); - if !online.iter().any(|(id, _)| id == &target_device_id) { - return Err(StatusCode::NOT_FOUND); - } - - // Generate a correlation_id for request-response matching - let correlation_id = uuid::Uuid::new_v4().to_string(); - - // Register a pending RPC response (the WS handler will resolve it when - // the target device sends back a DeviceMessage with the same correlation_id) - let rx = state - .device_manager - .register_rpc(&correlation_id, &user_id, &target_device_id) - .ok_or(StatusCode::TOO_MANY_REQUESTS)?; - - // Build the WS message to send to the target device. - // Retain the authenticated controller identity so the receiver can resolve - // its same-account public key. Correlation still resolves the HTTP response. - let out_msg = OutboundProtocol::IncomingDeviceMessage { - source_device_id, - correlation_id: correlation_id.clone(), - encrypted_data: body.encrypted_data, - nonce: body.nonce, - }; - let json = serde_json::to_string(&out_msg).unwrap_or_default(); - - if !state - .device_manager - .route_message(&user_id, &target_device_id, &json) - { - state.device_manager.cancel_rpc(&correlation_id); - return Err(StatusCode::SERVICE_UNAVAILABLE); - } - - // Wait for the response (the target device sends back a DeviceMessage - // via WS, which the WS handler resolves via resolve_rpc) - match tokio::time::timeout(RPC_TIMEOUT, rx).await { - Ok(Ok(resp)) => resp.into_http_response(), - _ => { - state.device_manager.cancel_rpc(&correlation_id); - Err(StatusCode::GATEWAY_TIMEOUT) - } - } -} - -// ── Delete device ─────────────────────────────────────────────────────── - -/// `DELETE /api/devices/:target_device_id` -/// -/// Removes a device from the account (DB row + any active WS session). -/// Deleting the caller's own device revokes its current token as well. async fn delete_device( State(state): State, headers: HeaderMap, @@ -399,21 +209,6 @@ async fn delete_device( .disconnect_device(&user_id, &target_device_id); drop(_presence_projection_guard); - state - .device_manager - .broadcast_current_presence(&user_id, |devices| { - let devices = devices - .iter() - .map( - |(device_id, device_name)| crate::routes::websocket::DevicePresenceEntry { - device_id: device_id.clone(), - device_name: device_name.clone(), - }, - ) - .collect(); - serde_json::to_string(&OutboundProtocol::DevicePresence { devices }).ok() - }); - tracing::info!("Device {target_device_id} removed from account {user_id}"); Ok(StatusCode::NO_CONTENT) } @@ -480,89 +275,6 @@ mod tests { assert!(second.routing_device_id(&ctx.db).await.is_err()); } - #[tokio::test] - async fn http_device_response_requires_expected_principal_and_accepts_large_payload() { - let ctx = setup_app().await; - let state = AppState { - start_time: std::time::Instant::now(), - asset_store: Arc::new(MemoryAssetStore::new()), - db: ctx.db.clone(), - page_data: None, - page_access_manager: Arc::new(crate::routes::pages::PageAccessManager::new()), - page_upload_manager: Arc::new(crate::routes::pages::PageUploadManager::new()), - page_execution_guard: Arc::new(crate::page_execution::PageExecutionGuard::new()), - login_rate_limiter: Arc::new(crate::routes::auth::LoginRateLimiter::new()), - device_manager: crate::relay::DeviceManager::new(), - cors_allow_origins: Arc::new(Vec::new()), - page_browser_auth: None, - }; - let app = device_router() - .layer(axum::middleware::from_fn_with_state( - state.clone(), - crate::admission::admit, - )) - .with_state(state.clone()); - let mut pending = state - .device_manager - .register_rpc("correlation", "owner", "target-device") - .unwrap(); - let ciphertext = "a".repeat(256 * 1024); - let body = serde_json::json!({ - "correlation_id": "correlation", "encrypted_data": ciphertext, "nonce": "nonce" - }) - .to_string(); - for (token, expected) in [ - (&ctx.owner_token, StatusCode::NOT_FOUND), - (&ctx.other_token, StatusCode::NOT_FOUND), - (&ctx.delegated_token, StatusCode::FORBIDDEN), - ] { - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/devices/owner-device/messages") - .header(header::AUTHORIZATION, format!("Bearer {token}")) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body.clone())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), expected); - assert!(pending.try_recv().is_err()); - } - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri("/api/devices/owner-device/messages") - .header( - header::AUTHORIZATION, - format!("Bearer {}", ctx.target_token), - ) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let response = pending.await.unwrap().into_http_response().unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(value["encrypted_data"], ciphertext); - assert_eq!(value["nonce"], "nonce"); - } - - #[test] - fn encrypted_payload_cannot_expand_json_with_escape_characters() { - assert!(!is_valid_encrypted_payload("\"\\", "nonce")); - assert!(is_valid_encrypted_payload("ab+/=_-09", "nonce")); - } - async fn setup_app() -> TestContext { let db = Arc::new(connect(":memory:").await.unwrap()); UserRow::create(&db, "owner", "alice").await.unwrap(); @@ -664,32 +376,6 @@ mod tests { .collect() } - async fn rpc( - app: &axum::Router, - token: &str, - device_id: &str, - payload_bytes: usize, - ) -> StatusCode { - let body = serde_json::json!({ - "encrypted_data": "A".repeat(payload_bytes), - "nonce": "AAAA", - }) - .to_string(); - app.clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("/api/devices/{device_id}/rpc")) - .header(header::AUTHORIZATION, format!("Bearer {token}")) - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap() - .status() - } - #[tokio::test] async fn device_list_hides_mobile_devices_and_keeps_unlabeled_rows() { let ctx = setup_app().await; @@ -740,33 +426,6 @@ mod tests { assert!(!ids.contains(&"phone".to_string())); } - #[tokio::test] - async fn device_rpc_accepts_payloads_above_the_default_body_limit() { - let ctx = setup_app().await; - - // A phone sending a photo lands here: base64 of the image, encrypted and - // base64ed again, clears Axum's 2 MB default by itself. NOT_FOUND means - // the body was read and the offline target was the only complaint. - let status = rpc(&ctx.app, &ctx.owner_token, "target-device", 3 * 1024 * 1024).await; - - assert_eq!(status, StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn device_rpc_still_rejects_payloads_past_the_route_limit() { - let ctx = setup_app().await; - - let status = rpc( - &ctx.app, - &ctx.owner_token, - "target-device", - RPC_BODY_LIMIT_BYTES + 1, - ) - .await; - - assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); - } - #[tokio::test] async fn deleting_owned_device_revokes_token_before_device_row() { let ctx = setup_app().await; diff --git a/src/crates/services/relay-service/src/routes/mod.rs b/src/crates/services/relay-service/src/routes/mod.rs index 03467fc831..4c7b57e80a 100644 --- a/src/crates/services/relay-service/src/routes/mod.rs +++ b/src/crates/services/relay-service/src/routes/mod.rs @@ -4,4 +4,3 @@ pub mod api; pub mod auth; pub mod devices; pub mod pages; -pub mod websocket; diff --git a/src/crates/services/relay-service/src/routes/websocket.rs b/src/crates/services/relay-service/src/routes/websocket.rs deleted file mode 100644 index ab8090dc41..0000000000 --- a/src/crates/services/relay-service/src/routes/websocket.rs +++ /dev/null @@ -1,1426 +0,0 @@ -//! WebSocket handler for the relay server. -//! -//! Only desktop clients connect via WebSocket. Mobile clients use HTTP. -//! The relay bridges HTTP requests to the desktop via WebSocket using -//! correlation IDs for request-response matching. - -use axum::{ - extract::{ - ws::{Message, WebSocket, WebSocketUpgrade}, - State, - }, - http::{header, HeaderMap, StatusCode}, - response::{IntoResponse, Response}, -}; -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashSet, - sync::Arc, - time::{Duration, Instant}, -}; -use tokio::sync::{ - mpsc::{self, error::TrySendError}, - watch, -}; -use tracing::{debug, error, info, warn}; - -use crate::relay::transport::{send_outbound_message, ConnId, OutboundMessage}; -use crate::routes::api::AppState; - -const OUTBOUND_QUEUE_CAPACITY: usize = 128; -// Authentication and heartbeats stay small. Device payloads are submitted over -// HTTP, where admission reserves process-wide memory before buffering a body. -// A per-socket 64 MiB allowance otherwise multiplies across idle connections. -const MAX_WS_MESSAGE_BYTES: usize = 16 * 1024; -const MAX_ENCRYPTED_PAYLOAD_BYTES: usize = 48 * 1024 * 1024; -const MAX_IDENTIFIER_BYTES: usize = 128; -const MAX_DEVICE_NAME_BYTES: usize = 256; -const MAX_NONCE_BYTES: usize = 256; -const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); -const MAX_MESSAGES_PER_WINDOW: u32 = 12_000; -const MAX_WEBSOCKET_CONNECTIONS: usize = 4096; -const AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(10); -const WRITE_TIMEOUT: Duration = Duration::from_secs(15); -const DEVICE_TOKEN_REVALIDATION_INTERVAL: Duration = Duration::from_secs(5); -const SOCKET_CLOSE_GRACE: Duration = Duration::from_secs(5); - -const MAX_WEBSOCKET_CONNECTIONS_PER_IP: usize = 128; - -#[derive(Default)] -struct IpConnectionSlots( - std::sync::Mutex>>, -); - -impl IpConnectionSlots { - fn acquire(&self, ip: &str) -> Option { - let mut slots = self.0.lock().unwrap_or_else(|error| error.into_inner()); - slots.retain(|_, permits| permits.strong_count() > 0); - let permits = slots - .get(ip) - .and_then(std::sync::Weak::upgrade) - .unwrap_or_else(|| { - let permits = Arc::new(tokio::sync::Semaphore::new( - MAX_WEBSOCKET_CONNECTIONS_PER_IP, - )); - slots.insert(ip.to_owned(), Arc::downgrade(&permits)); - permits - }); - permits.try_acquire_owned().ok() - } -} - -struct ConnectionRateLimiter { - window_started: Instant, - message_count: u32, -} - -impl ConnectionRateLimiter { - fn new() -> Self { - Self { - window_started: Instant::now(), - message_count: 0, - } - } - - fn allow(&mut self) -> bool { - if self.window_started.elapsed() >= RATE_LIMIT_WINDOW { - self.window_started = Instant::now(); - self.message_count = 0; - } - if self.message_count >= MAX_MESSAGES_PER_WINDOW { - return false; - } - self.message_count += 1; - true - } -} - -/// Messages received from the desktop via WebSocket. -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum InboundMessage { - Heartbeat, - /// Authenticate the socket before admitting any device traffic. - AuthConnect { - token: String, - device_name: String, - /// `desktop` | `mobile` | `watch`. Absent from clients that predate - /// the field, which leaves the stored kind untouched. - #[serde(default)] - device_kind: Option, - }, - /// Route an encrypted payload to another device in the same account. - DeviceMessage { - target_device_id: String, - correlation_id: String, - encrypted_data: String, - nonce: String, - }, -} - -/// Messages sent to the desktop via WebSocket. -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum OutboundProtocol { - HeartbeatAck, - Error { - message: String, - }, - /// Result of an `AuthConnect`: the validated user_id + this device's id. - AuthOk { - user_id: String, - device_id: String, - }, - AuthError { - message: String, - }, - /// A device-to-device message routed from another device in the account. - IncomingDeviceMessage { - source_device_id: String, - correlation_id: String, - encrypted_data: String, - nonce: String, - }, - /// Current online devices in the account (presence broadcast). - DevicePresence { - devices: Vec, - }, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DevicePresenceEntry { - pub device_id: String, - pub device_name: String, -} - -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State, - headers: HeaderMap, - peer: Option>>, -) -> Response { - if !is_websocket_origin_allowed(&headers, &state.cors_allow_origins) { - warn!("Rejected WebSocket connection from a disallowed browser origin"); - return StatusCode::FORBIDDEN.into_response(); - } - let ip = crate::routes::auth::client_ip( - &headers, - peer.map(|axum::Extension(axum::extract::ConnectInfo(addr))| addr), - ); - if !state - .login_rate_limiter - .check_and_record("websocket-connect", &ip, 120, None) - { - return StatusCode::TOO_MANY_REQUESTS.into_response(); - } - static CONNECTIONS: std::sync::OnceLock> = - std::sync::OnceLock::new(); - let permits = CONNECTIONS - .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(MAX_WEBSOCKET_CONNECTIONS))); - let Ok(permit) = Arc::clone(permits).try_acquire_owned() else { - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - static IP_CONNECTIONS: std::sync::OnceLock = std::sync::OnceLock::new(); - let Some(ip_permit) = IP_CONNECTIONS - .get_or_init(IpConnectionSlots::default) - .acquire(&ip) - else { - return StatusCode::TOO_MANY_REQUESTS.into_response(); - }; - ws.read_buffer_size(4 * 1024) - .write_buffer_size(0) - .max_message_size(MAX_WS_MESSAGE_BYTES) - .max_frame_size(MAX_WS_MESSAGE_BYTES) - .max_write_buffer_size(64 * 1024 * 1024) - .on_upgrade(move |socket| async move { - let _permit = permit; - let _ip_permit = ip_permit; - handle_socket(socket, state).await; - }) -} - -fn is_websocket_origin_allowed(headers: &HeaderMap, allowed_origins: &[String]) -> bool { - let Some(origin) = headers - .get(header::ORIGIN) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - else { - // Native clients do not send Origin and authenticate at the protocol - // layer. Origin is a browser boundary, not a replacement for auth. - return true; - }; - if origin.eq_ignore_ascii_case("null") { - return false; - } - let Some(origin) = crate::normalized_browser_origin(origin) else { - return false; - }; - if allowed_origins - .iter() - .any(|allowed| allowed == "*" || allowed.eq_ignore_ascii_case(&origin)) - { - return true; - } - - let Some(host) = headers - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - else { - return false; - }; - let origin_authority = origin - .split_once("://") - .map(|(_, authority)| authority) - .and_then(|authority| authority.split('/').next()); - origin_authority.is_some_and(|authority| authority.eq_ignore_ascii_case(host)) -} - -async fn handle_socket(socket: WebSocket, state: AppState) { - ensure_device_token_revalidator(&state); - let (mut ws_sender, mut ws_receiver) = socket.split(); - let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_QUEUE_CAPACITY); - let (force_close_tx, mut force_close_rx) = watch::channel(false); - - let conn_id = state.device_manager.next_connection_id(); - let mut rate_limiter = ConnectionRateLimiter::new(); - let authentication_deadline = tokio::time::Instant::now() + AUTHENTICATION_TIMEOUT; - let mut token_expiry_task: Option> = None; - info!("WebSocket connected: conn_id={conn_id}"); - - let mut writer_force_close_rx = force_close_rx.clone(); - let writer_failure_close_tx = force_close_tx.clone(); - let write_task = tokio::spawn(async move { - loop { - tokio::select! { - biased; - changed = writer_force_close_rx.changed() => { - if changed.is_ok() && *writer_force_close_rx.borrow() { - info!("Closing revoked WebSocket connection"); - } - break; - } - msg = out_rx.recv() => { - let Some(msg) = msg else { break }; - if msg.text.is_empty() { - continue; - } - let sent = tokio::select! { - biased; - _ = writer_force_close_rx.changed() => break, - result = tokio::time::timeout(WRITE_TIMEOUT, ws_sender.send(Message::Text(msg.text.into()))) => result, - }; - if !matches!(sent, Ok(Ok(()))) { - // The read half can remain open after a write-half - // failure. Wake the owner loop so it promptly removes - // routing/presence instead of leaving a half-open - // device online until token expiry. - let _ = writer_failure_close_tx.send(true); - break; - } - } - } - } - // Receiving Close queues Tungstenite's acknowledgement on the shared - // socket. The split writer must flush it before either half is dropped. - // This also sends Close when the server initiates disconnection. - let _ = tokio::time::timeout(SOCKET_CLOSE_GRACE, ws_sender.close()).await; - }); - - loop { - let msg_result = tokio::select! { - biased; - changed = force_close_rx.changed() => { - if changed.is_ok() && *force_close_rx.borrow() { - info!("Revoked WebSocket connection: conn_id={conn_id}"); - } - break; - } - _ = tokio::time::sleep_until(authentication_deadline), - if !state.device_manager.has_connection(conn_id) => { - warn!("WebSocket authentication deadline exceeded: conn_id={conn_id}"); - break; - } - msg = ws_receiver.next() => { - let Some(msg) = msg else { break }; - msg - } - }; - if !rate_limiter.allow() { - warn!("WebSocket rate limit exceeded: conn_id={conn_id}"); - let _ = send_json_best_effort( - &out_tx, - &OutboundProtocol::Error { - message: "message rate limit exceeded".into(), - }, - ); - break; - } - match msg_result { - Ok(Message::Text(text)) => { - if !handle_text_message( - &text, - conn_id, - &state, - &out_tx, - &force_close_tx, - &mut token_expiry_task, - ) - .await - { - break; - } - } - Ok(Message::Ping(_)) => {} - Ok(Message::Close(_)) => { - info!("WebSocket close from conn_id={conn_id}"); - break; - } - Ok(Message::Binary(_)) => { - warn!("Rejected binary WebSocket message: conn_id={conn_id}"); - let _ = send_json_best_effort( - &out_tx, - &OutboundProtocol::Error { - message: "binary messages are not supported".into(), - }, - ); - break; - } - Err(e) => { - error!("WebSocket error conn_id={conn_id}: {e}"); - break; - } - _ => {} - } - } - - if let Some(task) = token_expiry_task { - task.abort(); - } - - let _presence_projection_guard = state.device_manager.lock_presence_projection().await; - if let Some((user_id, device_id)) = state.device_manager.unregister(conn_id) { - // Best-effort: mark the device offline in the DB and notify peers. - if !state.device_manager.is_device_online(&user_id, &device_id) { - let _ = crate::db::DeviceRow::set_online(&state.db, &user_id, &device_id, false).await; - } - state - .device_manager - .broadcast_current_presence(&user_id, |devices| { - serde_json::to_string(&OutboundProtocol::DevicePresence { - devices: build_presence(devices), - }) - .ok() - }); - } - drop(_presence_projection_guard); - drop(out_tx); - finish_socket_writer(write_task).await; - info!("WebSocket disconnected: conn_id={conn_id}"); -} - -async fn finish_socket_writer(mut write_task: tokio::task::JoinHandle<()>) { - if tokio::time::timeout(SOCKET_CLOSE_GRACE, &mut write_task) - .await - .is_err() - { - // A peer that stopped reading must not retain a detached writer and - // its queued messages after routing and presence have been removed. - warn!("WebSocket writer exceeded close deadline"); - write_task.abort(); - let _ = write_task.await; - } -} - -fn ensure_device_token_revalidator(state: &AppState) { - let db = Arc::downgrade(&state.db); - if !state.device_manager.claim_token_revalidator_start() { - return; - } - let device_manager = Arc::downgrade(&state.device_manager); - tokio::spawn(async move { - let mut interval = tokio::time::interval(DEVICE_TOKEN_REVALIDATION_INTERVAL); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // Consume the immediate first tick. AuthConnect performs its own two - // checks; this worker is for later out-of-process revocation. - interval.tick().await; - loop { - interval.tick().await; - let (Some(db), Some(device_manager)) = (db.upgrade(), device_manager.upgrade()) else { - break; - }; - if let Err(error) = revalidate_active_device_tokens_once(&db, &device_manager).await { - warn!(%error, "Failed to revalidate active device tokens"); - } - } - }); -} - -async fn revalidate_active_device_tokens_once( - db: &crate::db::DbPool, - device_manager: &crate::relay::DeviceManager, -) -> anyhow::Result { - let active = device_manager.active_device_credentials(); - if active.is_empty() { - return Ok(0); - } - let tokens = active - .iter() - .map(|(_, _, _, token)| token.clone()) - .collect::>(); - let valid = crate::db::AuthToken::find_valid_device_tokens(db, &tokens) - .await? - .into_iter() - .map(|token| (token.user_id, token.device_id, token.token)) - .collect::>(); - let revoked = active - .into_iter() - .filter(|(_, user_id, device_id, token)| { - !valid.contains(&(user_id.clone(), device_id.clone(), token.clone())) - }) - .collect::>(); - if revoked.is_empty() { - return Ok(0); - } - - // A batch snapshot can race a same-process login/logout. Re-check each - // missing exact token under the lifecycle gate before removing its socket. - let _presence_projection_guard = device_manager.lock_presence_projection().await; - let mut affected_users = HashSet::new(); - let mut disconnected = 0; - for (_conn_id, user_id, device_id, token) in revoked { - match registered_device_token_is_current(db, &token, &user_id, &device_id).await { - Ok(true) => continue, - Err(error) => { - warn!(%error, %user_id, %device_id, "Failed exact token revalidation"); - continue; - } - Ok(false) => {} - } - if device_manager.disconnect_device_if_token(&user_id, &device_id, &token) { - disconnected += 1; - affected_users.insert(user_id.clone()); - if !device_manager.is_device_online(&user_id, &device_id) { - if let Err(error) = - crate::db::DeviceRow::set_online(db, &user_id, &device_id, false).await - { - warn!(%error, %user_id, %device_id, "Failed to project revoked device offline"); - } - } - } - } - for user_id in affected_users { - device_manager.broadcast_current_presence(&user_id, |devices| { - serde_json::to_string(&OutboundProtocol::DevicePresence { - devices: build_presence(devices), - }) - .ok() - }); - } - Ok(disconnected) -} - -async fn handle_text_message( - text: &str, - conn_id: ConnId, - state: &AppState, - out_tx: &mpsc::Sender, - force_close_tx: &watch::Sender, - token_expiry_task: &mut Option>, -) -> bool { - if text.len() > MAX_WS_MESSAGE_BYTES { - return reject_protocol(out_tx, "message is too large"); - } - let msg: InboundMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(e) => { - warn!("Invalid message from conn_id={conn_id}: {e}"); - return send_json_best_effort( - out_tx, - &OutboundProtocol::Error { - message: "invalid message format".into(), - }, - ); - } - }; - let message_type = match &msg { - InboundMessage::Heartbeat => "heartbeat", - InboundMessage::AuthConnect { .. } => "auth_connect", - InboundMessage::DeviceMessage { .. } => "device_message", - }; - debug!( - "Received WebSocket message: conn_id={conn_id} type={message_type} bytes={}", - text.len() - ); - - match msg { - InboundMessage::Heartbeat => { - if state.device_manager.conn_mapping(conn_id).is_some() { - send_json_best_effort(out_tx, &OutboundProtocol::HeartbeatAck) - } else { - send_json_best_effort( - out_tx, - &OutboundProtocol::Error { - message: "Device is not authenticated".into(), - }, - ) - } - } - - InboundMessage::AuthConnect { - token, - device_name, - device_kind, - } => { - if state.device_manager.has_connection(conn_id) { - return reject_protocol(out_tx, "connection is already authenticated"); - } - if !crate::db::is_valid_auth_token(&token) - || !is_valid_display_text(&device_name, MAX_DEVICE_NAME_BYTES) - || device_kind - .as_deref() - .is_some_and(|kind| !crate::db::is_valid_device_kind(kind)) - { - return reject_protocol(out_tx, "invalid authentication parameters"); - } - let db = state.db.as_ref(); - let auth = match crate::db::AuthToken::find(db, &token).await { - Ok(Some(a)) => a, - _ => { - return send_json_best_effort( - out_tx, - &OutboundProtocol::AuthError { - message: "invalid or expired token".into(), - }, - ) - } - }; - if !auth.is_device_token() { - return send_json_best_effort( - out_tx, - &OutboundProtocol::AuthError { - message: "token is not valid for a device connection".into(), - }, - ); - } - // Make the candidate visible to token-scoped logout without - // exposing it to presence/routing or replacing the current active - // socket until the mandatory final token check succeeds. - state.device_manager.register_pending( - &auth.user_id, - &auth.device_id, - &token, - &device_name, - conn_id, - out_tx.clone(), - force_close_tx.clone(), - ); - - let activated = match activate_pending_device_if_authorized( - db, - &state.device_manager, - &token, - &auth.user_id, - &auth.device_id, - &device_name, - device_kind.as_deref(), - conn_id, - ) - .await - { - Ok(activated) => activated, - Err(error) => { - warn!(%error, "Failed to activate authenticated device"); - false - } - }; - if !activated { - return false; - } - let expires_in = auth - .expires_at - .saturating_sub(chrono::Utc::now().timestamp()) - .max(0) as u64; - let expiry_close_tx = force_close_tx.clone(); - *token_expiry_task = Some(tokio::spawn(async move { - tokio::time::sleep(Duration::from_secs(expires_in)).await; - let _ = expiry_close_tx.send(true); - })); - // Full presence (including self) so clients can treat the snapshot - // as authoritative rather than an incremental patch. Snapshot and - // enqueue are serialized with membership mutations. - state - .device_manager - .broadcast_current_presence(&auth.user_id, |devices| { - serde_json::to_string(&OutboundProtocol::DevicePresence { - devices: build_presence(devices), - }) - .ok() - }); - true - } - - InboundMessage::DeviceMessage { - target_device_id, - correlation_id, - encrypted_data, - nonce, - } => { - if !is_valid_identifier(&target_device_id) - || !is_valid_identifier(&correlation_id) - || !is_valid_encrypted_payload(&encrypted_data, &nonce) - { - return reject_protocol(out_tx, "invalid device message"); - } - // Look up the sender's (user_id, device_id) from the conn map. - let sender = state.device_manager.conn_mapping(conn_id); - let Some((user_id, source_device_id)) = sender else { - return send_json_best_effort( - out_tx, - &OutboundProtocol::Error { - message: "not authenticated (send AuthConnect first)".into(), - }, - ); - }; - - // First check: is this a response to a pending HTTP RPC? - // If so, resolve the pending future and don't forward via WS. - let Some(rpc_response) = crate::relay::device_manager::RpcResponse::try_new( - encrypted_data.clone(), - nonce.clone(), - ) else { - return reject_protocol(out_tx, "response memory budget exhausted"); - }; - if state.device_manager.resolve_rpc( - &correlation_id, - &user_id, - &source_device_id, - rpc_response, - ) { - // HTTP RPC resolved — the HTTP caller gets the response. - return true; - } - - // Normal WS-to-WS device routing - let out_msg = OutboundProtocol::IncomingDeviceMessage { - source_device_id, - correlation_id, - encrypted_data, - nonce, - }; - let json = serde_json::to_string(&out_msg).unwrap_or_default(); - if !state - .device_manager - .route_message(&user_id, &target_device_id, &json) - { - return send_json_best_effort( - out_tx, - &OutboundProtocol::Error { - message: format!("target device {target_device_id} offline"), - }, - ); - } - true - } - } -} - -fn is_valid_identifier(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_IDENTIFIER_BYTES - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) -} - -fn is_valid_display_text(value: &str, max_bytes: usize) -> bool { - !value.trim().is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control) -} - -fn is_valid_encrypted_payload(encrypted_data: &str, nonce: &str) -> bool { - !encrypted_data.is_empty() - && encrypted_data.len() <= MAX_ENCRYPTED_PAYLOAD_BYTES - && !nonce.is_empty() - && nonce.len() <= MAX_NONCE_BYTES - && !nonce.chars().any(char::is_control) -} - -fn reject_protocol(tx: &mpsc::Sender, message: &str) -> bool { - let _ = send_json_best_effort( - tx, - &OutboundProtocol::Error { - message: message.to_string(), - }, - ); - false -} - -fn build_presence(devices: &[(String, String)]) -> Vec { - devices - .iter() - .map(|(id, name)| DevicePresenceEntry { - device_id: id.clone(), - device_name: name.clone(), - }) - .collect() -} - -async fn registered_device_token_is_current( - db: &crate::db::DbPool, - token: &str, - expected_user_id: &str, - expected_device_id: &str, -) -> anyhow::Result { - Ok(crate::db::AuthToken::find(db, token) - .await? - .is_some_and(|current| { - current.is_device_token() - && current.user_id == expected_user_id - && current.device_id == expected_device_id - })) -} - -async fn project_device_offline_if_unowned( - db: &crate::db::DbPool, - device_manager: &crate::relay::DeviceManager, - user_id: &str, - device_id: &str, -) -> anyhow::Result<()> { - if !device_manager.is_device_online(user_id, device_id) { - crate::db::DeviceRow::set_online(db, user_id, device_id, false).await?; - } - Ok(()) -} - -async fn reconcile_device_after_token_disconnect( - db: &crate::db::DbPool, - device_manager: &crate::relay::DeviceManager, - user_id: &str, - device_id: &str, -) -> anyhow::Result<()> { - let projection_result = - project_device_offline_if_unowned(db, device_manager, user_id, device_id).await; - device_manager.broadcast_current_presence(user_id, |devices| { - serde_json::to_string(&OutboundProtocol::DevicePresence { - devices: build_presence(devices), - }) - .ok() - }); - projection_result -} - -/// Complete the activation after the durable device projection has been -/// written. The caller must hold `presence_projection_gate`, keeping the -/// second token check, in-memory promotion, and any rollback atomic with -/// logout, device deletion, and socket cleanup. -async fn complete_pending_device_activation_if_authorized( - db: &crate::db::DbPool, - device_manager: &crate::relay::DeviceManager, - token: &str, - expected_user_id: &str, - expected_device_id: &str, - conn_id: ConnId, -) -> anyhow::Result { - // The durable writes can wait on SQLite. Re-check wall-clock expiry - // immediately before the synchronous promotion so a token that expired - // during that wait never becomes routable or receives AuthOk. - let still_current = - match registered_device_token_is_current(db, token, expected_user_id, expected_device_id) - .await - { - Ok(current) => current, - Err(error) => { - device_manager.disconnect_pending(conn_id); - let _ = project_device_offline_if_unowned( - db, - device_manager, - expected_user_id, - expected_device_id, - ) - .await; - return Err(error); - } - }; - if !still_current { - device_manager.disconnect_device_if_token(expected_user_id, expected_device_id, token); - reconcile_device_after_token_disconnect( - db, - device_manager, - expected_user_id, - expected_device_id, - ) - .await?; - return Ok(false); - } - - // Pending remains invisible until every durable write succeeds. Promotion - // is synchronous under the same lifecycle gate, so clients never route to - // a socket whose AuthConnect may still fail on database projection. - let auth_ok = serde_json::to_string(&OutboundProtocol::AuthOk { - user_id: expected_user_id.to_string(), - device_id: expected_device_id.to_string(), - })?; - if !device_manager.activate_pending_with_initial_message( - expected_user_id, - expected_device_id, - token, - conn_id, - &auth_ok, - ) { - project_device_offline_if_unowned(db, device_manager, expected_user_id, expected_device_id) - .await?; - return Ok(false); - } - Ok(true) -} - -async fn activate_pending_device_if_authorized( - db: &crate::db::DbPool, - device_manager: &crate::relay::DeviceManager, - token: &str, - expected_user_id: &str, - expected_device_id: &str, - device_name: &str, - device_kind: Option<&str>, - conn_id: ConnId, -) -> anyhow::Result { - // Serialize the final token lookup, durable device update, activation, and - // online projection with logout/delete/socket cleanup. No persistent side - // effect occurs before this lookup, so a deleted device cannot be revived - // by an AuthConnect request that passed only the earlier lookup. - let _presence_projection_guard = device_manager.lock_presence_projection().await; - if !registered_device_token_is_current(db, token, expected_user_id, expected_device_id).await? { - device_manager.disconnect_device_if_token(expected_user_id, expected_device_id, token); - reconcile_device_after_token_disconnect( - db, - device_manager, - expected_user_id, - expected_device_id, - ) - .await?; - return Ok(false); - } - - if let Err(error) = crate::db::DeviceRow::upsert( - db, - expected_device_id, - expected_user_id, - device_name, - device_kind, - None, - ) - .await - { - device_manager.disconnect_pending(conn_id); - return Err(error); - } - if let Err(error) = - crate::db::DeviceRow::set_online(db, expected_user_id, expected_device_id, true).await - { - device_manager.disconnect_pending(conn_id); - return Err(error); - } - complete_pending_device_activation_if_authorized( - db, - device_manager, - token, - expected_user_id, - expected_device_id, - conn_id, - ) - .await -} - -async fn send_json(tx: &mpsc::Sender, msg: &T) -> bool { - match serde_json::to_string(msg) { - Ok(json) => match OutboundMessage::try_text(&json) { - Some(message) => send_outbound_message(tx, message).await, - None => false, - }, - Err(e) => { - warn!("Failed to serialize outbound websocket message: {e}"); - false - } - } -} - -fn send_json_best_effort(tx: &mpsc::Sender, msg: &T) -> bool { - match serde_json::to_string(msg) { - Ok(json) => { - let Some(message) = OutboundMessage::try_text(&json) else { - return false; - }; - match tx.try_send(message) { - Ok(()) => true, - Err(TrySendError::Full(_)) => { - warn!("Outbound websocket queue is full; closing slow connection"); - false - } - Err(TrySendError::Closed(_)) => false, - } - } - Err(e) => { - warn!("Failed to serialize outbound websocket message: {e}"); - false - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn connection_slots_limit_one_ip_and_release_after_disconnect() { - let slots = super::IpConnectionSlots::default(); - let mut permits: Vec<_> = (0..super::MAX_WEBSOCKET_CONNECTIONS_PER_IP) - .map(|_| slots.acquire("one").unwrap()) - .collect(); - assert!(slots.acquire("one").is_none()); - assert!(slots.acquire("two").is_some()); - permits.pop(); - assert!(slots.acquire("one").is_some()); - drop(permits); - let _other = slots.acquire("three").unwrap(); - assert_eq!(slots.0.lock().unwrap().len(), 1); - } - - use super::{ - activate_pending_device_if_authorized, complete_pending_device_activation_if_authorized, - is_valid_display_text, is_valid_encrypted_payload, is_valid_identifier, - is_websocket_origin_allowed, registered_device_token_is_current, - revalidate_active_device_tokens_once, send_json_best_effort, ConnectionRateLimiter, - OutboundProtocol, MAX_MESSAGES_PER_WINDOW, - }; - use crate::db::{connect, AuthToken, DeviceRow, UserRow}; - use crate::relay::DeviceManager; - use axum::http::{header, HeaderMap}; - use tokio::sync::{mpsc, watch}; - - #[tokio::test] - async fn disconnect_releases_a_stalled_writer() { - let (started_tx, started_rx) = tokio::sync::oneshot::channel(); - let (released_tx, released_rx) = tokio::sync::oneshot::channel::<()>(); - let writer = tokio::spawn(async move { - let _resource = released_tx; - let _ = started_tx.send(()); - std::future::pending::<()>().await; - }); - started_rx.await.unwrap(); - tokio::time::timeout( - super::SOCKET_CLOSE_GRACE + std::time::Duration::from_secs(1), - super::finish_socket_writer(writer), - ) - .await - .expect("stalled writer must be cancelled and joined"); - assert!( - released_rx.await.is_err(), - "writer resources must be dropped" - ); - } - - #[tokio::test] - async fn client_close_receives_ack_without_leaking_socket() { - use futures_util::{SinkExt, StreamExt}; - use tokio_tungstenite::tungstenite::{ - protocol::{frame::coding::CloseCode, CloseFrame}, - Message, - }; - - let app = crate::build_relay_router( - std::sync::Arc::new(crate::MemoryAssetStore::new()), - std::time::Instant::now(), - std::sync::Arc::new(crate::db::connect(":memory:").await.unwrap()), - "test", - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let (mut client, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws")) - .await - .unwrap(); - let close = CloseFrame { - code: CloseCode::Normal, - reason: "completed".into(), - }; - client - .send(Message::Close(Some(close.clone()))) - .await - .unwrap(); - let response = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) - .await - .expect("close acknowledgement deadline") - .expect("close acknowledgement frame") - .expect("clean WebSocket close"); - assert_eq!(response, Message::Close(Some(close))); - server.abort(); - let _ = server.await; - } - - #[test] - fn best_effort_control_response_does_not_block_on_full_queue() { - let (tx, _rx) = mpsc::channel(1); - assert!(send_json_best_effort(&tx, &OutboundProtocol::HeartbeatAck)); - - assert!( - !send_json_best_effort(&tx, &OutboundProtocol::HeartbeatAck), - "full queue must close a slow reader without blocking" - ); - } - - #[test] - fn protocol_fields_are_bounded() { - assert!(is_valid_identifier("device-1")); - assert!(!is_valid_identifier("../device")); - assert!(!is_valid_identifier(&"x".repeat(129))); - assert!(is_valid_display_text("MacBook Pro", 256)); - assert!(!is_valid_display_text("bad\nname", 256)); - assert!(is_valid_encrypted_payload("ciphertext", "nonce")); - assert!(!is_valid_encrypted_payload("", "nonce")); - } - - #[test] - fn websocket_message_rate_rejects_over_budget_and_recovers() { - let mut limiter = ConnectionRateLimiter::new(); - for _ in 0..MAX_MESSAGES_PER_WINDOW { - assert!(limiter.allow()); - } - assert!(!limiter.allow()); - limiter.window_started -= super::RATE_LIMIT_WINDOW; - assert!(limiter.allow()); - } - - #[test] - fn websocket_browser_origin_is_same_origin_or_explicitly_allowed() { - let mut headers = HeaderMap::new(); - headers.insert(header::HOST, "relay.example.com".parse().unwrap()); - headers.insert(header::ORIGIN, "https://relay.example.com".parse().unwrap()); - assert!(is_websocket_origin_allowed(&headers, &[])); - - headers.insert(header::ORIGIN, "https://app.example.com".parse().unwrap()); - assert!(!is_websocket_origin_allowed(&headers, &[])); - assert!(is_websocket_origin_allowed( - &headers, - &["https://app.example.com".to_string()] - )); - - headers.insert(header::ORIGIN, "null".parse().unwrap()); - assert!(!is_websocket_origin_allowed(&headers, &["*".to_string()])); - - for invalid in [ - "file://relay.example.com", - "https://relay.example.com/path", - "https://user@relay.example.com", - ] { - headers.insert(header::ORIGIN, invalid.parse().unwrap()); - assert!(!is_websocket_origin_allowed(&headers, &[])); - } - } - - #[tokio::test] - async fn token_revoked_between_initial_validation_and_activation_is_rejected() { - let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice").await.unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - let token = AuthToken::create(&db, "owner", "device-a") - .await - .unwrap() - .token; - - // This is the first AuthConnect lookup. Pause the conceptual request - // after it, then let logout delete the row before registration's - // mandatory second lookup. - assert!(AuthToken::find(&db, &token).await.unwrap().is_some()); - sqlx::query("DELETE FROM auth_tokens WHERE token = ?") - .bind(&token) - .execute(&db) - .await - .unwrap(); - - let manager = DeviceManager::new(); - let (tx, _rx) = mpsc::channel(4); - let (close_tx, mut close_rx) = watch::channel(false); - manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); - assert!(manager.online_devices("owner").is_empty()); - assert!(manager.conn_mapping(1).is_none()); - - assert!( - !registered_device_token_is_current(&db, &token, "owner", "device-a") - .await - .unwrap() - ); - assert!(!activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", None, 1, - ) - .await - .unwrap()); - close_rx.changed().await.unwrap(); - assert!(*close_rx.borrow()); - assert!(manager.conn_mapping(1).is_none()); - } - - #[tokio::test] - async fn expired_token_disconnects_active_and_pending_without_ghost_online_projection() { - let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice").await.unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - DeviceRow::set_online(&db, "owner", "device-a", true) - .await - .unwrap(); - let token = AuthToken::create(&db, "owner", "device-a") - .await - .unwrap() - .token; - - let manager = DeviceManager::new(); - let (active_tx, _active_rx) = mpsc::channel(4); - let (active_close_tx, mut active_close_rx) = watch::channel(false); - manager.register( - "owner", - "device-a", - &token, - "Device A", - 1, - active_tx, - active_close_tx, - ); - let (pending_tx, _pending_rx) = mpsc::channel(4); - let (pending_close_tx, mut pending_close_rx) = watch::channel(false); - manager.register_pending( - "owner", - "device-a", - &token, - "Device A", - 2, - pending_tx, - pending_close_tx, - ); - let (peer_tx, mut peer_rx) = mpsc::channel(4); - let (peer_close_tx, _peer_close_rx) = watch::channel(false); - manager.register( - "owner", - "device-c", - "independent-token", - "Device C", - 3, - peer_tx, - peer_close_tx, - ); - - sqlx::query("UPDATE auth_tokens SET expires_at = ? WHERE token = ?") - .bind(chrono::Utc::now().timestamp()) - .bind(&token) - .execute(&db) - .await - .unwrap(); - assert!(!activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", None, 2, - ) - .await - .unwrap()); - - active_close_rx.changed().await.unwrap(); - pending_close_rx.changed().await.unwrap(); - assert!(*active_close_rx.borrow()); - assert!(*pending_close_rx.borrow()); - assert!(manager.conn_mapping(1).is_none()); - assert!(manager.conn_mapping(2).is_none()); - assert_eq!( - manager.conn_mapping(3), - Some(("owner".into(), "device-c".into())) - ); - assert!(!manager.route_message("owner", "device-a", "opaque")); - let presence: serde_json::Value = - serde_json::from_str(&peer_rx.recv().await.unwrap().text).unwrap(); - assert_eq!(presence["type"], "device_presence"); - assert_eq!(presence["devices"].as_array().unwrap().len(), 1); - assert_eq!(presence["devices"][0]["device_id"], "device-c"); - let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].online, 0); - } - - #[tokio::test] - async fn external_token_revocation_reaper_disconnects_idle_device_and_updates_presence() { - let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice").await.unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - DeviceRow::upsert(&db, "device-c", "owner", "Device C", None, None) - .await - .unwrap(); - DeviceRow::set_online(&db, "owner", "device-a", true) - .await - .unwrap(); - DeviceRow::set_online(&db, "owner", "device-c", true) - .await - .unwrap(); - let revoked_token = AuthToken::create(&db, "owner", "device-a") - .await - .unwrap() - .token; - let peer_token = AuthToken::create(&db, "owner", "device-c") - .await - .unwrap() - .token; - - let manager = DeviceManager::new(); - let (revoked_tx, _revoked_rx) = mpsc::channel(4); - let (revoked_close_tx, mut revoked_close_rx) = watch::channel(false); - manager.register( - "owner", - "device-a", - &revoked_token, - "Device A", - 1, - revoked_tx, - revoked_close_tx, - ); - let (peer_tx, mut peer_rx) = mpsc::channel(4); - let (peer_close_tx, _peer_close_rx) = watch::channel(false); - manager.register( - "owner", - "device-c", - &peer_token, - "Device C", - 2, - peer_tx, - peer_close_tx, - ); - - // Simulate reset-password/delete-user tooling mutating the shared DB - // from outside the running relay process while both sockets are idle. - sqlx::query("DELETE FROM auth_tokens WHERE token = ?") - .bind(&revoked_token) - .execute(&db) - .await - .unwrap(); - assert_eq!( - revalidate_active_device_tokens_once(&db, &manager) - .await - .unwrap(), - 1 - ); - - revoked_close_rx.changed().await.unwrap(); - assert!(*revoked_close_rx.borrow()); - assert!(manager.conn_mapping(1).is_none()); - assert_eq!( - manager.conn_mapping(2), - Some(("owner".into(), "device-c".into())) - ); - let presence: serde_json::Value = - serde_json::from_str(&peer_rx.recv().await.unwrap().text).unwrap(); - assert_eq!(presence["devices"].as_array().unwrap().len(), 1); - assert_eq!(presence["devices"][0]["device_id"], "device-c"); - let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); - assert_eq!( - rows.iter() - .find(|row| row.device_id == "device-a") - .unwrap() - .online, - 0 - ); - assert_eq!( - rows.iter() - .find(|row| row.device_id == "device-c") - .unwrap() - .online, - 1 - ); - } - - #[tokio::test] - async fn token_expiring_during_durable_projection_never_receives_auth_ok() { - let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice").await.unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - let token = AuthToken::create(&db, "owner", "device-a") - .await - .unwrap() - .token; - let manager = DeviceManager::new(); - let (tx, mut rx) = mpsc::channel(4); - let (close_tx, mut close_rx) = watch::channel(false); - manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); - - let _projection_guard = manager.lock_presence_projection().await; - assert!( - registered_device_token_is_current(&db, &token, "owner", "device-a") - .await - .unwrap() - ); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - DeviceRow::set_online(&db, "owner", "device-a", true) - .await - .unwrap(); - sqlx::query("UPDATE auth_tokens SET expires_at = ? WHERE token = ?") - .bind(chrono::Utc::now().timestamp()) - .bind(&token) - .execute(&db) - .await - .unwrap(); - - assert!(!complete_pending_device_activation_if_authorized( - &db, &manager, &token, "owner", "device-a", 1, - ) - .await - .unwrap()); - close_rx.changed().await.unwrap(); - assert!(*close_rx.borrow()); - assert!(rx.try_recv().is_err(), "AuthOk must not be queued"); - assert!(manager.conn_mapping(1).is_none()); - let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].online, 0); - } - - #[tokio::test] - async fn stale_auth_connect_cannot_recreate_a_deleted_device() { - let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice").await.unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - let token = AuthToken::create(&db, "owner", "device-a") - .await - .unwrap() - .token; - assert!(AuthToken::find(&db, &token).await.unwrap().is_some()); - - assert!(DeviceRow::delete_for_user(&db, "owner", "device-a") - .await - .unwrap()); - let manager = DeviceManager::new(); - let (tx, _rx) = mpsc::channel(4); - let (close_tx, mut close_rx) = watch::channel(false); - manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); - - assert!(!activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", None, 1, - ) - .await - .unwrap()); - close_rx.changed().await.unwrap(); - assert!(*close_rx.borrow()); - assert!(DeviceRow::list_by_user(&db, "owner") - .await - .unwrap() - .is_empty()); - } - - #[tokio::test] - async fn pending_device_becomes_routable_only_after_durable_projection() { - let db = connect(":memory:").await.unwrap(); - UserRow::create(&db, "owner", "alice").await.unwrap(); - DeviceRow::upsert(&db, "device-a", "owner", "Device A", None, None) - .await - .unwrap(); - let token = AuthToken::create(&db, "owner", "device-a") - .await - .unwrap() - .token; - let manager = DeviceManager::new(); - let (tx, mut rx) = mpsc::channel(4); - let (close_tx, _close_rx) = watch::channel(false); - manager.register_pending("owner", "device-a", &token, "Device A", 1, tx, close_tx); - - assert!(manager.online_devices("owner").is_empty()); - assert!(!manager.route_message("owner", "device-a", "opaque")); - assert!(activate_pending_device_if_authorized( - &db, &manager, &token, "owner", "device-a", "Device A", None, 1, - ) - .await - .unwrap()); - - assert_eq!( - manager.conn_mapping(1), - Some(("owner".into(), "device-a".into())) - ); - assert_eq!(manager.online_devices("owner").len(), 1); - let rows = DeviceRow::list_by_user(&db, "owner").await.unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].online, 1); - - manager.broadcast_current_presence("owner", |devices| { - serde_json::to_string(&OutboundProtocol::DevicePresence { - devices: super::build_presence(devices), - }) - .ok() - }); - let auth_ok: serde_json::Value = - serde_json::from_str(&rx.recv().await.unwrap().text).unwrap(); - let presence: serde_json::Value = - serde_json::from_str(&rx.recv().await.unwrap().text).unwrap(); - assert_eq!(auth_ok["type"], "auth_ok"); - assert_eq!(presence["type"], "device_presence"); - } -} diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index a4f9be619b..6c967b0f4d 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -86,6 +86,7 @@ cargo check -p openbitfun-services-core --no-default-features cargo test -p openbitfun-services-core --no-default-features --features process-runtime --lib system::info::tests cargo test -p openbitfun-services-core --no-default-features --features credential-vault --lib credential_vault::tests:: cargo check -p openbitfun-services-core --no-default-features --features filesystem +cargo test -p openbitfun-services-core --no-default-features --features filesystem --lib filesystem::tree::tests::large_files_remain_visible_in_metadata_listings cargo test -p openbitfun-services-core --no-default-features --features diagnostics --lib diagnostics::contract_tests:: cargo test -p openbitfun-services-core --no-default-features --features diff --lib diff::contract_tests:: cargo test -p openbitfun-services-core --no-default-features --features workspace-text-runtime --lib workspace_text::tests:: @@ -116,3 +117,9 @@ remain in Core through runtime extension traits on the shared records. ```bash cargo test -p openbitfun-services-core --no-default-features --features workspace-persistence,coordination-store,session-event-format --lib ``` + +For bounded workspace upload ownership, offset recovery, conflict detection, and account retirement: + +```bash +cargo test -p openbitfun-services-core --no-default-features --features workspace-transfer,workspace-runtime --lib workspace_transfer::tests +``` diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 9150ba8f2e..c8f42c12cd 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -69,6 +69,7 @@ credential-vault = [ diagnostics = ["dep:regex"] diff = ["dep:similar", "dep:tokio", "tokio/rt", "tokio/time"] filesystem = [ + "tokio/sync", "dep:base64", "dep:chrono", "dep:ignore", @@ -136,6 +137,17 @@ workspace-instructions = [ "tokio/rt", ] markdown = ["dep:regex", "dep:serde_yaml"] +workspace-transfer = [ + "dep:anyhow", + "dep:openbitfun-runtime-ports", + "openbitfun-runtime-ports/workspace-ports", + "dep:sha2", + "dep:tokio", + "tokio/fs", + "tokio/io-util", + "tokio/rt", + "tokio/sync", +] workspace-runtime = [ "dep:anyhow", "dep:async-trait", diff --git a/src/crates/services/services-core/src/file_write_lock.rs b/src/crates/services/services-core/src/file_write_lock.rs new file mode 100644 index 0000000000..b83c17733f --- /dev/null +++ b/src/crates/services/services-core/src/file_write_lock.rs @@ -0,0 +1,21 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex as StdMutex, OnceLock, Weak}, +}; +use tokio::sync::Mutex; + +/// Shared with ordinary optimistic file edits; held through hash comparison and rename. +pub fn write_guard(key: String) -> Arc> { + static LOCKS: OnceLock>>>> = OnceLock::new(); + let mut locks = LOCKS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()); + locks.retain(|_, value| value.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(Mutex::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock +} diff --git a/src/crates/services/services-core/src/filesystem/mod.rs b/src/crates/services/services-core/src/filesystem/mod.rs index db3fabf353..0515fc4973 100644 --- a/src/crates/services/services-core/src/filesystem/mod.rs +++ b/src/crates/services/services-core/src/filesystem/mod.rs @@ -11,6 +11,7 @@ mod factory; mod listing; mod operations; mod service; +mod sorting; mod tree; mod types; @@ -28,6 +29,7 @@ pub use operations::{ FileReadResult, FileWriteResult, }; pub use service::FileSystemService; +pub use sorting::sort_directory_nodes; pub use tree::{ BatchedFileSearchProgressSink, FileContentSearchOptions, FileNameSearchOptions, FileSearchOutcome, FileSearchProgressSink, FileSearchResult, FileSearchResultGroup, diff --git a/src/crates/services/services-core/src/filesystem/sorting.rs b/src/crates/services/services-core/src/filesystem/sorting.rs new file mode 100644 index 0000000000..47c6a717a8 --- /dev/null +++ b/src/crates/services/services-core/src/filesystem/sorting.rs @@ -0,0 +1,106 @@ +//! Stable directory ordering shared by local and remote host adapters, before pagination. +use super::FileTreeNode; +use std::cmp::Ordering; + +pub fn sort_directory_nodes( + nodes: &mut [FileTreeNode], + sort_by: Option<&str>, + sort_order: Option<&str>, +) -> Result<(), String> { + let Some(sort_by) = sort_by else { + return Ok(()); + }; + if !matches!(sort_by, "name" | "modified") { + return Err(format!("Unsupported directory sort field: {sort_by}")); + } + let descending = match sort_order.unwrap_or("asc") { + "asc" => false, + "desc" => true, + order => return Err(format!("Unsupported directory sort order: {order}")), + }; + fn modified(value: &Option) -> Option { + let value = value.as_deref()?; + chrono::DateTime::parse_from_rfc3339(value) + .map(|time| time.timestamp()) + .ok() + .or_else(|| { + chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S") + .ok() + .map(|time| time.and_utc().timestamp()) + }) + } + nodes.sort_by(|a, b| { + let kind = b.is_directory.cmp(&a.is_directory); + if kind != Ordering::Equal { + return kind; + } + let order = if sort_by == "modified" { + match (modified(&a.last_modified), modified(&b.last_modified)) { + (Some(a), Some(b)) => { + if descending { + b.cmp(&a) + } else { + a.cmp(&b) + } + } + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } + } else { + let order = a.name.to_lowercase().cmp(&b.name.to_lowercase()); + if descending { + order.reverse() + } else { + order + } + }; + order + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn entry(name: &str, directory: bool, modified: &str) -> FileTreeNode { + FileTreeNode::new(name.into(), name.into(), name.into(), directory) + .with_metadata(None, Some(modified.into())) + } + #[test] + fn ordering_precedes_pages_and_keeps_directories_first() { + let mut nodes = vec![ + entry("z", false, "2026-01-01 00:00:00"), + entry("B", true, "2025-01-01 00:00:00"), + entry("a", false, "2026-01-02T00:00:00Z"), + ]; + sort_directory_nodes(&mut nodes, Some("name"), Some("asc")).unwrap(); + assert_eq!( + nodes + .iter() + .map(|node| node.name.as_str()) + .collect::>(), + ["B", "a", "z"] + ); + sort_directory_nodes(&mut nodes, Some("modified"), Some("desc")).unwrap(); + assert_eq!( + nodes + .iter() + .skip(1) + .take(1) + .map(|node| node.name.as_str()) + .collect::>(), + ["a"] + ); + sort_directory_nodes(&mut nodes, Some("name"), Some("desc")).unwrap(); + assert_eq!( + nodes + .iter() + .map(|node| node.name.as_str()) + .collect::>(), + ["B", "z", "a"] + ); + } +} diff --git a/src/crates/services/services-core/src/filesystem/tree.rs b/src/crates/services/services-core/src/filesystem/tree.rs index 80176687a6..6768e8407b 100644 --- a/src/crates/services/services-core/src/filesystem/tree.rs +++ b/src/crates/services/services-core/src/filesystem/tree.rs @@ -101,6 +101,8 @@ pub struct FileTreeOptions { pub include_git_info: bool, pub include_mime_types: bool, pub skip_patterns: Vec, + /// Retained for source compatibility. Metadata listings never hide files by size; + /// content readers own their separate memory/streaming budgets. pub max_file_size_mb: Option, pub follow_symlinks: bool, } @@ -119,7 +121,7 @@ impl Default for FileTreeOptions { ".DS_Store".to_string(), "Thumbs.db".to_string(), ], - max_file_size_mb: Some(100), + max_file_size_mb: None, follow_symlinks: false, } } @@ -508,12 +510,6 @@ impl FileTreeService { metadata.as_ref().map(|m| m.len()) }; - if let (Some(size_bytes), Some(max_mb)) = (size, self.options.max_file_size_mb) { - if size_bytes > max_mb * 1024 * 1024 { - continue; - } - } - let last_modified = metadata.as_ref().and_then(|m| { m.modified().ok().map(|t| { let datetime: chrono::DateTime = t.into(); @@ -691,12 +687,6 @@ impl FileTreeService { } } - if let (Some(size_bytes), Some(max_mb)) = (size, self.options.max_file_size_mb) { - if size_bytes > max_mb * 1024 * 1024 { - continue; - } - } - if !is_directory { if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) { *stats.file_type_counts.entry(ext.to_string()).or_insert(0) += 1; @@ -836,14 +826,36 @@ impl FileTreeService { } let entry_path = entry.path(); + let metadata = entry.metadata().await.ok(); let is_directory = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); + let size = if is_directory { + None + } else { + metadata.as_ref().map(|value| value.len()) + }; + let modified = metadata + .as_ref() + .and_then(|value| value.modified().ok()) + .map(|value| { + let time: chrono::DateTime = value.into(); + time.format("%Y-%m-%d %H:%M:%S").to_string() + }); + let extension = if is_directory { + None + } else { + entry_path + .extension() + .map(|value| value.to_string_lossy().to_string()) + }; let node = FileTreeNode::new( entry_path.to_string_lossy().to_string(), file_name_str.to_string(), entry_path.to_string_lossy().to_string(), is_directory, - ); + ) + .with_metadata(size, modified) + .with_extension(extension); nodes.push(node); } @@ -1528,6 +1540,31 @@ pub enum SearchMatchType { mod tests { use super::*; + #[tokio::test] + async fn large_files_remain_visible_in_metadata_listings() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("large.bin"); + // Sparse metadata fixture: no 101 MiB buffer or content write is needed. + let size = 101 * 1024 * 1024; + std::fs::File::create(&path).unwrap().set_len(size).unwrap(); + let service = FileTreeService::new(FileTreeOptions { + max_file_size_mb: Some(1), + ..Default::default() + }); + let root = root.path().to_str().unwrap(); + let nodes = service.build_tree(root).await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].name, "large.bin"); + assert_eq!(nodes[0].size, Some(size)); + let (nodes, stats) = service.build_tree_with_stats(root).await.unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(stats.total_files, 1); + assert_eq!(stats.total_size_bytes, size); + let page = service.get_directory_contents(root).await.unwrap(); + assert_eq!(page.len(), 1); + assert_eq!(page[0].size, Some(size)); + } + #[test] fn default_tree_visibility_matches_vscode_baseline() { let service = FileTreeService::default(); diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index b82e6db27a..2d50dc54d1 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -17,6 +17,8 @@ pub mod dispatch_contract; pub mod dispatch_workspace; #[cfg(any(feature = "local-storage", feature = "runtime-ownership"))] mod file_lock; +#[cfg(any(feature = "filesystem", feature = "workspace-transfer"))] +pub mod file_write_lock; #[cfg(feature = "filesystem")] pub mod filesystem; #[cfg(any(feature = "markdown", feature = "workspace-instructions"))] @@ -77,3 +79,5 @@ pub mod workspace_identity; #[cfg(feature = "workspace-instructions")] pub mod workspace_instructions; pub mod workspace_text; +#[cfg(feature = "workspace-transfer")] +pub mod workspace_transfer; diff --git a/src/crates/services/services-core/src/session/metadata_store.rs b/src/crates/services/services-core/src/session/metadata_store.rs index d7f74a94ad..9bff102a2c 100644 --- a/src/crates/services/services-core/src/session/metadata_store.rs +++ b/src/crates/services/services-core/src/session/metadata_store.rs @@ -91,7 +91,22 @@ pub struct SessionMetadataStore { json_store: JsonFileStore, } +static SESSION_CATALOG_REVISION: OnceLock> = OnceLock::new(); + impl SessionMetadataStore { + /// Host-local invalidation only. Persisted metadata remains authoritative. + pub fn subscribe_catalog_changes() -> tokio::sync::watch::Receiver { + SESSION_CATALOG_REVISION + .get_or_init(|| tokio::sync::watch::channel(0).0) + .subscribe() + } + + pub fn notify_catalog_changed() { + SESSION_CATALOG_REVISION + .get_or_init(|| tokio::sync::watch::channel(0).0) + .send_modify(|revision| *revision = revision.wrapping_add(1)); + } + pub fn new(sessions_root: impl Into) -> Self { Self { layout: SessionStorageLayout::new(sessions_root), @@ -470,7 +485,9 @@ impl SessionMetadataStore { if metadata_file_created { 1 } else { 0 }, ) .await - } + }?; + Self::notify_catalog_changed(); + Ok(()) } async fn assign_workspace_session_number_locked( @@ -555,7 +572,9 @@ impl SessionMetadataStore { } self.remove_index_entry_locked(session_id, if metadata_file_removed { -1 } else { 0 }) - .await + .await?; + Self::notify_catalog_changed(); + Ok(()) } async fn ensure_session_dir( @@ -582,6 +601,41 @@ mod tests { use super::*; use crate::session::{SessionStatus, StoredSessionIndexFile}; + #[tokio::test] + async fn catalog_watch_tracks_committed_create_rename_and_delete() { + let root = tempfile::tempdir().unwrap(); + let store = SessionMetadataStore::new(root.path()); + let mut changes = SessionMetadataStore::subscribe_catalog_changes(); + let mut record = metadata("catalog-watch", 1); + let before = *changes.borrow_and_update(); + store.save_metadata(&record).await.unwrap(); + assert!(*changes.borrow_and_update() > before); + let before = *changes.borrow_and_update(); + record.session_name = "Renamed catalog entry".into(); + store.save_metadata(&record).await.unwrap(); + assert!(*changes.borrow_and_update() > before); + assert_eq!( + store + .load_metadata("catalog-watch") + .await + .unwrap() + .unwrap() + .session_name, + "Renamed catalog entry" + ); + let before = *changes.borrow_and_update(); + store + .delete_session_dir_and_index("catalog-watch") + .await + .unwrap(); + assert!(*changes.borrow_and_update() > before); + assert!(store + .load_metadata("catalog-watch") + .await + .unwrap() + .is_none()); + } + fn default_title_metadata(id: &str, created_at: u64) -> SessionMetadata { let mut result = metadata(id, created_at); result.session_name = "New Session".into(); diff --git a/src/crates/services/services-core/src/workspace.rs b/src/crates/services/services-core/src/workspace.rs index df8a42ebed..61a04d9101 100644 --- a/src/crates/services/services-core/src/workspace.rs +++ b/src/crates/services/services-core/src/workspace.rs @@ -19,6 +19,25 @@ pub struct LocalWorkspaceFs; #[async_trait] impl WorkspaceFileSystem for LocalWorkspaceFs { + async fn open_write_new( + &self, + path: &str, + ) -> anyhow::Result { + let file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .await?; + Ok(Box::new(file)) + } + async fn atomic_replace(&self, from: &str, to: &str) -> anyhow::Result<()> { + if Path::new(from).parent() != Path::new(to).parent() { + anyhow::bail!("Atomic replacement requires a same-directory staged file"); + } + tokio::fs::rename(from, to).await?; + Ok(()) + } + async fn open_read(&self, path: &str) -> anyhow::Result { let file = tokio::fs::File::open(path).await?; if !file.metadata().await?.is_file() { diff --git a/src/crates/services/services-core/src/workspace_transfer.rs b/src/crates/services/services-core/src/workspace_transfer.rs new file mode 100644 index 0000000000..5c56a83c75 --- /dev/null +++ b/src/crates/services/services-core/src/workspace_transfer.rs @@ -0,0 +1,654 @@ +//! Runtime-owned, bounded uploads. Relay transports only encrypted chunk envelopes. +use crate::file_write_lock::write_guard; +use anyhow::{anyhow, bail, Result}; +use openbitfun_runtime_ports::{WorkspaceFileSystem, WorkspacePathKind, WorkspaceWriter}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{collections::HashMap, sync::Arc}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::Mutex, +}; + +pub const MAX_UPLOAD_CHUNK: usize = 3 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransferScope { + pub account_id: String, + /// Runtime-selected connection identity; never a controller supplied hostname. + pub target_id: String, + pub workspace_root: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UploadStatus { + pub transfer_id: String, + pub next_offset: u64, + pub total_bytes: u64, + pub completed: bool, +} + +pub struct UploadTarget { + pub fs: Arc, + pub path: String, + pub remote: bool, +} +struct Upload { + epoch: u64, + scope: TransferScope, + target: UploadTarget, + staged: String, + writer: Option, + status: UploadStatus, + hash: Sha256, + expected_hash: Option, + final_hash: String, + last_chunk: Option<(u64, usize, String)>, + failed: bool, + publishing: bool, +} +#[derive(Clone, Default)] +pub struct UploadTransfers { + epochs: Arc>>, + entries: Arc>>>>, +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} +fn valid_hash(hash: &str) -> bool { + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +impl UploadTransfers { + /// Accepted filesystem mutations finish under the runtime owner even if the RPC observer disconnects. + pub async fn begin( + &self, + id: String, + scope: TransferScope, + target: UploadTarget, + total_bytes: u64, + final_hash: String, + expected_hash: Option, + ) -> Result { + let epoch = *self + .epochs + .lock() + .await + .entry(scope.account_id.clone()) + .or_default(); + let owner = self.clone(); + tokio::spawn(async move { + owner + .begin_inner( + id, + scope, + target, + total_bytes, + final_hash, + expected_hash, + epoch, + ) + .await + }) + .await? + } + pub async fn append( + &self, + scope: &TransferScope, + id: &str, + offset: u64, + bytes: &[u8], + ) -> Result { + if bytes.is_empty() || bytes.len() > MAX_UPLOAD_CHUNK { + bail!("Upload chunk must contain 1..={MAX_UPLOAD_CHUNK} bytes"); + } + let owner = self.clone(); + let scope = scope.clone(); + let id = id.to_owned(); + let bytes = bytes.to_vec(); + tokio::spawn(async move { owner.append_inner(&scope, &id, offset, &bytes).await }).await? + } + pub async fn finish(&self, scope: &TransferScope, id: &str) -> Result { + let owner = self.clone(); + let scope = scope.clone(); + let id = id.to_owned(); + tokio::spawn(async move { owner.finish_inner(&scope, &id).await }).await? + } + async fn begin_inner( + &self, + id: String, + scope: TransferScope, + mut target: UploadTarget, + total_bytes: u64, + final_hash: String, + expected_hash: Option, + epoch: u64, + ) -> Result { + if scope.account_id.is_empty() || scope.target_id.is_empty() { + bail!("Upload requires an authenticated runtime and target scope"); + } + if !valid_hash(&final_hash) + || expected_hash + .as_ref() + .is_some_and(|hash| !hash.is_empty() && !valid_hash(hash)) + { + bail!("Upload digest must be SHA-256 hexadecimal"); + } + if !valid_hash(&id) { + bail!("Upload transfer id must contain 32 random bytes encoded as hexadecimal"); + } + let parent = checked_parent(&scope, &target).await?; + if !target.remote { + let name = std::path::Path::new(&target.path) + .file_name() + .ok_or_else(|| anyhow!("Upload destination has no file name"))?; + target.path = std::path::Path::new(&parent) + .join(name) + .to_string_lossy() + .into_owned(); + } + let begin_lock = write_guard(format!("begin:{id}")); + let _begin_guard = begin_lock.lock().await; + if let Ok(entry) = self.entry(&id).await { + let existing = entry.lock().await; + existing.check_scope(&scope)?; + if existing.epoch != epoch { + bail!("Upload belongs to a retired account session"); + } + if existing.target.path != target.path + || existing.status.total_bytes != total_bytes + || existing.final_hash != final_hash.to_lowercase() + || existing.expected_hash != expected_hash + { + bail!("Upload transfer id was already used for a different request"); + } + return Ok(existing.status.clone()); + } + let staged = target + .fs + .join_path(&parent, &[&format!(".openbitfun-upload-{id}.tmp")]); + let writer = target.fs.open_write_new(&staged).await?; + let status = UploadStatus { + transfer_id: id.clone(), + next_offset: 0, + total_bytes, + completed: false, + }; + let epochs = self.epochs.lock().await; + if epochs.get(&scope.account_id).copied().unwrap_or_default() != epoch { + drop(epochs); + let mut writer = writer; + let _ = writer.shutdown().await; + target.fs.remove_file(&staged).await?; + bail!("Upload account retired while the transfer was being created"); + } + self.entries.lock().await.insert( + id, + Arc::new(Mutex::new(Upload { + epoch, + scope, + target, + staged, + writer: Some(writer), + status: status.clone(), + hash: Sha256::new(), + expected_hash, + final_hash: final_hash.to_lowercase(), + last_chunk: None, + failed: false, + publishing: false, + })), + ); + Ok(status) + } + async fn entry(&self, id: &str) -> Result>> { + self.entries.lock().await.get(id).cloned().ok_or_else(|| { + anyhow!("Upload transfer is unavailable; the owning runtime may have restarted") + }) + } + async fn ensure_active(&self, account_id: String, epoch: u64) -> Result<()> { + if self + .epochs + .lock() + .await + .get(&account_id) + .copied() + .unwrap_or_default() + != epoch + { + bail!("Upload belongs to a retired account session"); + } + Ok(()) + } + pub async fn status(&self, scope: &TransferScope, id: &str) -> Result { + let entry = self.entry(id).await?; + let upload = entry.lock().await; + upload.check_scope(scope)?; + self.ensure_active(upload.scope.account_id.clone(), upload.epoch) + .await?; + Ok(upload.status.clone()) + } + async fn append_inner( + &self, + scope: &TransferScope, + id: &str, + offset: u64, + bytes: &[u8], + ) -> Result { + if bytes.is_empty() || bytes.len() > MAX_UPLOAD_CHUNK { + bail!("Upload chunk must contain 1..={MAX_UPLOAD_CHUNK} bytes"); + } + let entry = self.entry(id).await?; + let mut upload = entry.lock().await; + upload.check_scope(scope)?; + self.ensure_active(upload.scope.account_id.clone(), upload.epoch) + .await?; + let chunk_hash = hex(&Sha256::digest(bytes)); + if upload + .last_chunk + .as_ref() + .is_some_and(|last| last.0 == offset && last.1 == bytes.len() && last.2 == chunk_hash) + { + return Ok(upload.status.clone()); + } + if upload.status.completed { + bail!("Upload is already completed"); + } + if offset != upload.status.next_offset { + bail!( + "Upload offset mismatch: expected {}", + upload.status.next_offset + ); + } + let next = offset + .checked_add(bytes.len() as u64) + .ok_or_else(|| anyhow!("Upload offset overflow"))?; + if next > upload.status.total_bytes { + bail!("Upload exceeds its declared length"); + } + // A failed write may be partial. Retire this stream rather than invent an acknowledged offset. + if let Err(error) = upload + .writer + .as_mut() + .ok_or_else(|| anyhow!("Upload stream failed; restart this transfer"))? + .write_all(bytes) + .await + { + upload.writer.take(); + upload.failed = true; + return Err(error.into()); + } + upload.hash.update(bytes); + upload.status.next_offset = next; + upload.last_chunk = Some((offset, bytes.len(), chunk_hash)); + Ok(upload.status.clone()) + } + async fn finish_inner(&self, scope: &TransferScope, id: &str) -> Result { + let entry = self.entry(id).await?; + let mut upload = entry.lock().await; + upload.check_scope(scope)?; + self.ensure_active(upload.scope.account_id.clone(), upload.epoch) + .await?; + if upload.status.completed { + return Ok(upload.status.clone()); + } + if upload.failed { + bail!("Upload stream failed; restart this transfer"); + } + if upload.status.next_offset != upload.status.total_bytes { + bail!("Upload is incomplete"); + } + if hex(&upload.hash.clone().finalize()) != upload.final_hash { + bail!("Upload SHA-256 mismatch"); + } + if let Some(mut writer) = upload.writer.take() { + upload.failed = true; + writer.flush().await?; + writer.shutdown().await?; + upload.failed = false; + } + let lock = write_guard(format!( + "{}\0{}", + upload.scope.target_id, upload.target.path + )); + let _guard = lock.lock().await; + checked_parent(&upload.scope, &upload.target).await?; + if upload.publishing + && upload + .target + .fs + .metadata(&upload.staged, false) + .await? + .is_none() + { + if file_hash(upload.target.fs.as_ref(), &upload.target.path) + .await? + .as_deref() + == Some(upload.final_hash.as_str()) + { + upload.status.completed = true; + return Ok(upload.status.clone()); + } + bail!("Upload publication outcome is uncertain and destination content differs"); + } + if let Some(expected) = &upload.expected_hash { + let current = file_hash(upload.target.fs.as_ref(), &upload.target.path).await?; + if (expected.is_empty() && current.is_some()) + || (!expected.is_empty() + && current.as_deref() != Some(expected.to_lowercase().as_str())) + { + bail!("FILE_CONFLICT: destination changed during upload"); + } + } + upload.publishing = true; + upload + .target + .fs + .atomic_replace(&upload.staged, &upload.target.path) + .await?; + upload.status.completed = true; + Ok(upload.status.clone()) + } + pub async fn cancel(&self, scope: &TransferScope, id: &str) -> Result<()> { + let entry = self.entry(id).await?; + let mut upload = entry.lock().await; + upload.check_scope(scope)?; + if let Some(mut writer) = upload.writer.take() { + let _ = writer.shutdown().await; + } + if !upload.status.completed { + upload.target.fs.remove_file(&upload.staged).await?; + } + self.entries.lock().await.remove(id); + Ok(()) + } + /// Explicit account retirement releases only that account's staged transfers. + pub async fn retire_account(&self, account_id: &str) -> Result<()> { + { + let mut epochs = self.epochs.lock().await; + let epoch = epochs.entry(account_id.to_string()).or_default(); + *epoch = epoch.wrapping_add(1); + } + let entries: Vec<_> = self + .entries + .lock() + .await + .iter() + .map(|(id, entry)| (id.clone(), entry.clone())) + .collect(); + for (id, entry) in entries { + let scope = entry.lock().await.scope.clone(); + if scope.account_id == account_id { + self.cancel(&scope, &id).await?; + } + } + Ok(()) + } +} +impl Upload { + fn check_scope(&self, scope: &TransferScope) -> Result<()> { + if &self.scope != scope { + bail!("Upload belongs to a different account or workspace target"); + } + Ok(()) + } +} +async fn file_hash(fs: &dyn WorkspaceFileSystem, path: &str) -> Result> { + if fs.metadata(path, false).await?.is_none() { + return Ok(None); + } + let mut reader = fs.open_read(path).await?; + let mut hash = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let count = reader.read(&mut buffer).await?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + Ok(Some(hex(&hash.finalize()))) +} +async fn checked_parent(scope: &TransferScope, target: &UploadTarget) -> Result { + let parent = if target.remote { + if !target.path.starts_with('/') + || target + .path + .split('/') + .any(|part| part == ".." || part == ".") + { + bail!("Upload remote path must be normalized absolute POSIX"); + } + let root = scope.workspace_root.trim_end_matches('/'); + if !root.is_empty() && !target.path.starts_with(&format!("{root}/")) { + bail!("Upload is outside its workspace"); + } + let parent = target + .path + .rsplit_once('/') + .ok_or_else(|| anyhow!("Upload destination has no parent"))? + .0; + let mut ancestor = String::new(); + for part in parent.split('/').filter(|part| !part.is_empty()) { + ancestor.push('/'); + ancestor.push_str(part); + if target.fs.path_kind_no_follow(&ancestor).await? != Some(WorkspacePathKind::Directory) + { + bail!("Upload parent is not a directory or traverses a symlink"); + } + } + if parent.is_empty() { + "/".to_string() + } else { + parent.to_string() + } + } else { + let root = tokio::fs::canonicalize(&scope.workspace_root).await?; + let parent = tokio::fs::canonicalize( + std::path::Path::new(&target.path) + .parent() + .ok_or_else(|| anyhow!("Upload destination has no parent"))?, + ) + .await?; + if !parent.starts_with(root) { + bail!("Upload is outside its workspace"); + } + parent.to_string_lossy().into_owned() + }; + if target + .fs + .path_kind_no_follow(&target.path) + .await? + .is_some_and(|kind| kind != WorkspacePathKind::File) + { + bail!("Upload destination is not a regular file"); + } + Ok(parent) +} + +#[cfg(all(test, feature = "workspace-runtime"))] +mod tests { + use super::*; + fn scope(root: &std::path::Path) -> TransferScope { + TransferScope { + account_id: "account-a".into(), + target_id: "local".into(), + workspace_root: root.to_string_lossy().into_owned(), + } + } + fn target(path: &std::path::Path) -> UploadTarget { + UploadTarget { + fs: Arc::new(crate::workspace::LocalWorkspaceFs), + path: path.to_string_lossy().into_owned(), + remote: false, + } + } + #[tokio::test] + async fn upload_resume_duplicate_scope_and_atomic_publication() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("file.bin"); + tokio::fs::write(&path, b"old").await.unwrap(); + let transfers = UploadTransfers::default(); + let owner = scope(root.path()); + let content = vec![37u8; MAX_UPLOAD_CHUNK + 19]; + let hash = hex(&Sha256::digest(&content)); + let id = "a".repeat(64); + transfers + .begin( + id.clone(), + owner.clone(), + target(&path), + content.len() as u64, + hash.clone(), + Some(hex(&Sha256::digest(b"old"))), + ) + .await + .unwrap(); + assert_eq!( + transfers + .begin( + id.clone(), + owner.clone(), + target(&path), + content.len() as u64, + hash, + Some(hex(&Sha256::digest(b"old"))) + ) + .await + .unwrap() + .next_offset, + 0 + ); + let first = &content[..MAX_UPLOAD_CHUNK]; + transfers.append(&owner, &id, 0, first).await.unwrap(); + assert_eq!( + transfers + .append(&owner, &id, 0, first) + .await + .unwrap() + .next_offset, + MAX_UPLOAD_CHUNK as u64 + ); + assert!(transfers + .append(&owner, &id, 0, b"different") + .await + .is_err()); + let mut other = owner.clone(); + other.account_id = "account-b".into(); + assert!(transfers.status(&other, &id).await.is_err()); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"old"); + assert!(transfers.finish(&owner, &id).await.is_err()); + let next = transfers.status(&owner, &id).await.unwrap().next_offset; + transfers + .append(&owner, &id, next, &content[MAX_UPLOAD_CHUNK..]) + .await + .unwrap(); + assert!(transfers.finish(&owner, &id).await.unwrap().completed); + assert!(transfers.finish(&owner, &id).await.unwrap().completed); + assert_eq!(tokio::fs::read(&path).await.unwrap(), content); + } + #[tokio::test] + async fn upload_observer_cancellation_does_not_interrupt_accepted_chunk() { + let root = tempfile::tempdir().unwrap(); + let owner = scope(root.path()); + let transfers = UploadTransfers::default(); + let id = "c".repeat(64); + let (writer, mut receiver) = tokio::io::duplex(8); + transfers.entries.lock().await.insert( + id.clone(), + Arc::new(Mutex::new(Upload { + epoch: 0, + scope: owner.clone(), + target: target(&root.path().join("file")), + staged: String::new(), + writer: Some(Box::new(writer)), + status: UploadStatus { + transfer_id: id.clone(), + next_offset: 0, + total_bytes: 32, + completed: false, + }, + hash: Sha256::new(), + expected_hash: None, + final_hash: hex(&Sha256::digest([7u8; 32])), + last_chunk: None, + failed: false, + publishing: false, + })), + ); + let called = transfers.clone(); + let called_scope = owner.clone(); + let called_id = id.clone(); + let observer = tokio::spawn(async move { + called + .append(&called_scope, &called_id, 0, &[7u8; 32]) + .await + }); + let mut first = [0u8; 8]; + receiver.read_exact(&mut first).await.unwrap(); + observer.abort(); + let _ = observer.await; + let mut remaining = [0u8; 24]; + receiver.read_exact(&mut remaining).await.unwrap(); + let status = transfers.status(&owner, &id).await.unwrap(); + assert_eq!(status.next_offset, 32); + assert_eq!(remaining, [7u8; 24]); + } + + #[tokio::test] + async fn upload_late_begin_cannot_reappear_after_account_retirement() { + let root = tempfile::tempdir().unwrap(); + let owner = scope(root.path()); + let transfers = UploadTransfers::default(); + transfers.retire_account(&owner.account_id).await.unwrap(); + let id = "d".repeat(64); + assert!(transfers + .begin_inner( + id.clone(), + owner.clone(), + target(&root.path().join("file")), + 0, + hex(&Sha256::digest([])), + None, + 0 + ) + .await + .unwrap_err() + .to_string() + .contains("retired")); + assert!(transfers.status(&owner, &id).await.is_err()); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); + } + + #[tokio::test] + async fn upload_conflict_keeps_original_and_retirement_removes_only_staging() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("file"); + let owner = scope(root.path()); + let transfers = UploadTransfers::default(); + let id = "b".repeat(64); + transfers + .begin( + id.clone(), + owner.clone(), + target(&path), + 3, + hex(&Sha256::digest(b"new")), + Some(String::new()), + ) + .await + .unwrap(); + transfers.append(&owner, &id, 0, b"new").await.unwrap(); + tokio::fs::write(&path, b"other editor").await.unwrap(); + assert!(transfers + .finish(&owner, &id) + .await + .unwrap_err() + .to_string() + .contains("FILE_CONFLICT")); + transfers.retire_account(&owner.account_id).await.unwrap(); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"other editor"); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1); + } +} diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 4eef21e403..aa1cf75d85 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -125,6 +125,8 @@ cargo test -p openbitfun-services-integrations --no-default-features --features cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::wsl::tests:: cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::relay_deploy::tests:: cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::relay_client::tests:: +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::session_log::tests:: +cargo test --locked -p openbitfun-services-integrations --no-default-features --features remote-connect --lib remote_connect::session_subscriber::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts cargo test --locked -p openbitfun-services-integrations --no-default-features --features workspace-search --test workspace_search_contracts cargo test --locked -p openbitfun-services-integrations --no-default-features --features deep-research --lib deep_research::tests:: diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 1eb083f072..cc5db4520b 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -63,6 +63,7 @@ thiserror = { workspace = true, optional = true } tar = { workspace = true, optional = true } tokio-util = { workspace = true, optional = true } tokio-tungstenite = { workspace = true, optional = true } +sioc = { workspace = true, optional = true } urlencoding = { workspace = true, optional = true } uuid = { workspace = true, optional = true } url = { workspace = true, optional = true } @@ -268,6 +269,7 @@ hook-import = [ "uuid", ] remote-connect = [ + "dep:sioc", "dep:pulldown-cmark", "fs2", "dep:openbitfun-product-domains", @@ -558,3 +560,7 @@ required-features = ["workspace-search"] [lints] workspace = true + +[[example]] +name = "relay_realtime_probe" +required-features = ["remote-connect"] diff --git a/src/crates/services/services-integrations/examples/relay_realtime_probe.rs b/src/crates/services/services-integrations/examples/relay_realtime_probe.rs new file mode 100644 index 0000000000..ee6623dcc5 --- /dev/null +++ b/src/crates/services/services-integrations/examples/relay_realtime_probe.rs @@ -0,0 +1,643 @@ +//! Source-built transport probe used by the cross-machine Relay experiment. +//! Credentials are read from the environment and never printed. +use anyhow::{anyhow, Context, Result}; +use openbitfun_services_integrations::remote_connect::realtime_client::{ + Incoming, RealtimeConnection, +}; +use serde_json::json; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let role = std::env::args() + .nth(1) + .ok_or_else(|| anyhow!("Expected host or controller"))?; + let url = std::env::var("RELAY_LAB_URL")?; + let token = std::env::var("RELAY_LAB_TOKEN")?; + #[cfg(feature = "remote-ssh-concrete")] + if role == "seed-saved-ssh" { + use openbitfun_services_integrations::remote_ssh::{ + SSHConnectionConfig, SSHConnectionManager, + }; + let data = std::path::PathBuf::from(std::env::var("RELAY_LAB_SSH_DATA")?); + let fixture = std::env::var("RELAY_LAB_SSH_FIXTURE")?; + let manager = SSHConnectionManager::new(data); + manager.load_saved_connections().await?; + let config: SSHConnectionConfig = serde_json::from_value( + json!({"id":"relay-lab-saved-ssh","name":"Isolated relay SSH fixture","host":"127.0.0.1","port":19703,"username":"root","auth":{"type":"PrivateKey","keyPath":format!("{fixture}/client_key"),"passphrase":null},"defaultWorkspace":std::env::var("RELAY_LAB_WORKSPACE")?}), + )?; + manager.save_connection(&config).await?; + let public = std::fs::read_to_string(format!("{fixture}/host_key.pub"))?; + let key = russh_keys::parse_public_key_base64( + public + .split_whitespace() + .nth(1) + .ok_or_else(|| anyhow!("Missing lab public key"))?, + )?; + manager + .add_known_host(config.host, config.port, &key) + .await?; + println!("Saved isolated SSH profile via the owning SSHConnectionManager"); + return Ok(()); + } + if role == "seed-test-host" { + use openbitfun_services_integrations::remote_connect::{device_crypto, session_store}; + let home = std::path::PathBuf::from(std::env::var("RELAY_LAB_HOME")?); + if home.exists() && std::fs::read_dir(&home)?.next().is_some() { + return Err(anyhow!("Lab seed requires an empty, isolated home")); + } + session_store::set_session_store_directory_for_test(home); + // Deliberately public fixture key, NEVER an application credential. + session_store::save_session_with_device( + &token, + "realtime-lab", + &[7; 32], + &url, + Some(&std::env::var("RELAY_LAB_TARGET")?), + )?; + println!("{}", device_crypto::public_key_base64(&[7; 32])); + println!("{}", device_crypto::public_key_base64(&[11; 32])); + return Ok(()); + } + if role == "product-session-start" || role == "product-session-recover" { + use openbitfun_services_integrations::remote_connect::{ + account::{AccountClient, AccountSession}, + session_subscriber::{SessionEvent, SessionSubscriber}, + }; + use std::sync::{Arc, Mutex}; + let account = AccountSession::new(token, "realtime-lab".into(), [11; 32]); + let client = AccountClient::new(); + let target = std::env::var("RELAY_LAB_TARGET")?; + let session_id = std::env::var("RELAY_LAB_SESSION")?; + let workspace = std::env::var("RELAY_LAB_WORKSPACE")?; + let invoke = |command: &'static str, request: serde_json::Value| { + let (client, account, url, target) = (&client, &account, &url, &target); + async move { + let response:serde_json::Value=serde_json::from_str(&client.device_rpc(url,account,target,&json!({"cmd":"host_invoke","command":command,"args":{"request":request}}).to_string()).await?)?; + if response["ok"] != true { + return Err(anyhow!("{command} failed: {}", response["error"])); + } + Ok(response["value"].clone()) + } + }; + if role == "product-session-start" { + invoke("open_workspace", json!({"path":workspace})).await?; + invoke("create_session",json!({"sessionId":session_id,"sessionName":"Isolated relay record runtime experiment","agentType":"Standard","workspacePath":workspace,"config":{"modelName":"relay-record-fixture"}})).await?; + } + let records = Arc::new(Mutex::new(Vec::::new())); + let output = records.clone(); + let errors = Arc::new(Mutex::new(Vec::::new())); + let failure = errors.clone(); + let subscriber = SessionSubscriber::start( + account.clone(), + url.clone(), + target.clone(), + session_id.clone(), + Arc::new(move |event: SessionEvent| { + if event.event == "session-record" { + output.lock().unwrap().push(event.payload); + } + Ok(()) + }), + Arc::new(move |error| { + failure.lock().unwrap().push(error); + }), + ) + .await?; + if role == "product-session-start" { + invoke("start_dialog_turn",json!({"sessionId":session_id,"turnId":format!("{session_id}-turn"),"agentType":"Standard","workspacePath":workspace,"userInput":"Relay record lab: read fixture.txt, then confirm completion. This is an isolated protocol experiment."})).await?; + } + let start = std::time::Instant::now(); + loop { + let values = records.lock().unwrap().clone(); + let tool = values.iter().any(|value| { + value["item"]["type"] == "tool" && value["item"]["data"]["toolResult"].is_object() + }); + let final_text = values.iter().any(|value| { + value["item"]["type"] == "text" + && value["item"]["data"]["content"] + .as_str() + .is_some_and(|text| text.contains("RELAY_RECORD_FINAL")) + }); + let completed = values + .iter() + .any(|value| value["turn"]["status"] == "completed"); + if tool && (role == "product-session-start" || final_text && completed) { + println!("Runtime session records verified: phase={role} records={} tool_completed={tool} final_text={final_text} turn_completed={completed} elapsed_ms={}",values.len(),start.elapsed().as_millis()); + println!("Lab session: {session_id}"); + subscriber.close(); + return Ok(()); + } + if start.elapsed() > std::time::Duration::from_secs(90) { + return Err(anyhow!("Runtime journal verification timed out: records={}, errors={:?}, tool={tool}, final={final_text}, complete={completed}",values.len(),errors.lock().unwrap())); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + if role == "product-controller" { + use openbitfun_services_integrations::remote_connect::account::{ + AccountClient, AccountSession, + }; + let session = AccountSession::new(token, "realtime-lab".into(), [11; 32]); + let client = AccountClient::new(); + for _ in 0..20 { + let response = client + .device_rpc( + &url, + &session, + &std::env::var("RELAY_LAB_TARGET")?, + r#"{"cmd":"ping"}"#, + ) + .await?; + let response: serde_json::Value = serde_json::from_str(&response)?; + if response["resp"] != "pong" { + return Err(anyhow!("Product Ping returned unexpected response")); + } + } + println!("20 encrypted product Ping calls verified through the real host dispatcher"); + return Ok(()); + } + if role == "product-features" + || role == "product-upload" + || role == "product-ssh-features" + || role == "product-catalog" + || role == "product-workspace-scopes" + { + use openbitfun_services_integrations::remote_connect::account::{ + AccountClient, AccountSession, + }; + let session = AccountSession::new(token, "realtime-lab".into(), [11; 32]); + let client = AccountClient::new(); + let target = std::env::var("RELAY_LAB_TARGET")?; + let root = std::env::var("RELAY_LAB_WORKSPACE")?; + let invoke = |command: &'static str, request: serde_json::Value| { + let client = &client; + let session = &session; + let url = &url; + let target = ⌖ + async move { + let message = + json!({"cmd":"host_invoke","command":command,"args":{"request":request}}) + .to_string(); + let response: serde_json::Value = serde_json::from_str( + &client + .device_rpc(url, session, target, &message) + .await + .with_context(|| format!("Lab HostInvoke {command} failed"))?, + )?; + if response["ok"] != true { + return Err(anyhow!( + "Host operation {command} failed: {}", + response["error"] + )); + } + Ok::<_, anyhow::Error>(response["value"].clone()) + } + }; + if role == "product-workspace-scopes" { + let before = invoke("get_current_workspace", json!({})).await?; + let listing = invoke("get_directory_children_paginated", json!({"path":"/","workspacePath":root,"remoteConnectionId":"relay-lab-saved-ssh","offset":0,"limit":100})).await?; + if !listing["children"] + .as_array() + .is_some_and(|rows| !rows.is_empty()) + { + return Err(anyhow!("Saved SSH root listing empty")); + } + let after = invoke("get_current_workspace", json!({})).await?; + if before != after { + return Err(anyhow!("Saved SSH browse changed active workspace")); + } + let mut created = Vec::new(); + for name in ["scope-a", "scope-b"] { + let workspace = format!("{root}/{name}"); + invoke( + "create_directory", + json!({"path":workspace,"workspacePath":root,"remoteConnectionId":""}), + ) + .await?; + invoke("write_file_content", json!({"filePath":format!("{workspace}/binding.txt"),"workspacePath":workspace,"remoteConnectionId":"","content":"binding"})).await?; + let command = json!({"cmd":"create_session","agent_type":"Claw","session_name":"Explicit workspace probe","workspace_path":workspace}).to_string(); + let reply: serde_json::Value = serde_json::from_str( + &client.device_rpc(&url, &session, &target, &command).await?, + )?; + if reply["resp"] != "session_created" || reply["workspace_path"] != workspace { + return Err(anyhow!("Explicit Claw create wrong workspace: {reply}")); + } + let id = reply["session_id"] + .as_str() + .ok_or_else(|| anyhow!("Missing session id"))? + .to_owned(); + let rows = invoke( + "list_persisted_sessions", + json!({"workspacePath":workspace,"remoteConnectionId":""}), + ) + .await?; + if !rows + .as_array() + .is_some_and(|rows| rows.iter().any(|row| row["sessionId"] == id)) + { + return Err(anyhow!( + "Created session was not persisted in selected workspace" + )); + } + let read = + json!({"cmd":"get_file_info","path":"binding.txt","session_id":id}).to_string(); + let file_reply: serde_json::Value = serde_json::from_str( + &client.device_rpc(&url, &session, &target, &read).await?, + )?; + if file_reply["resp"] != "file_info" || file_reply["size"] != 7 { + return Err(anyhow!("Fresh session file binding failed: {file_reply}")); + } + created.push((workspace, id)); + } + println!("Workspace scopes PASS: saved SSH root browse preserved active workspace; two Claw sessions persisted at their selected roots with resolvable file bindings"); + for (workspace, id) in created { + invoke( + "delete_session", + json!({"workspacePath":workspace,"sessionId":id}), + ) + .await?; + } + return Ok(()); + } + if role == "product-catalog" { + use openbitfun_services_integrations::remote_connect::{ + session_log::HOST_CATALOG_ID, + session_subscriber::{SessionEvent, SessionSubscriber}, + }; + use std::sync::{Arc, Mutex}; + let events = Arc::new(Mutex::new(Vec::::new())); + let output = events.clone(); + let errors = Arc::new(Mutex::new(Vec::::new())); + let failure = errors.clone(); + let subscriber = SessionSubscriber::start( + session.clone(), + url.clone(), + target.clone(), + HOST_CATALOG_ID.into(), + Arc::new(move |event: SessionEvent| { + if event.event == "host-catalog-changed" { + output.lock().unwrap().push(event.payload); + } + Ok(()) + }), + Arc::new(move |error| failure.lock().unwrap().push(error)), + ) + .await?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + while events.lock().unwrap().is_empty() { + if tokio::time::Instant::now() > deadline { + return Err(anyhow!( + "Initial catalog stream missing: {:?}", + errors.lock().unwrap() + )); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let before = events.lock().unwrap().last().unwrap()["workspacesRevision"] + .as_u64() + .unwrap(); + invoke("open_workspace", json!({"path":root})).await?; + wait_catalog_revision(&events, "workspacesRevision", before).await?; + let id = uuid::Uuid::new_v4().to_string(); + for (command, request) in [ + ( + "create_session", + json!({"sessionId":id,"sessionName":"Catalog mutation fixture","agentType":"Standard","workspacePath":root}), + ), + ( + "rename_session", + json!({"sessionId":id,"sessionName":"Renamed catalog fixture","workspacePath":root}), + ), + ( + "delete_session", + json!({"sessionId":id,"workspacePath":root}), + ), + ] { + let before = events.lock().unwrap().last().unwrap()["sessionsRevision"] + .as_u64() + .unwrap(); + invoke(command, request).await?; + wait_catalog_revision(&events, "sessionsRevision", before).await?; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + let count = events.lock().unwrap().len(); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + if events.lock().unwrap().len() != count { + return Err(anyhow!("Idle catalog emitted redundant invalidations")); + } + subscriber.close(); + if !errors.lock().unwrap().is_empty() { + return Err(anyhow!( + "Catalog subscription errors: {:?}", + errors.lock().unwrap() + )); + } + println!("Runtime catalog stream PASS: workspace open, session create/rename/delete notifications, idle silence; events={count}"); + return Ok(()); + } + if role == "product-ssh-features" { + let profiles = invoke("ssh_list_saved_connections", json!({})).await?; + if !profiles.as_array().is_some_and(|items| { + items + .iter() + .any(|profile| profile["id"] == "relay-lab-saved-ssh") + }) { + return Err(anyhow!("Runtime saved SSH profile was not listed")); + } + let workspace = invoke( + "open_remote_workspace", + json!({"remotePath":root,"connectionId":"relay-lab-saved-ssh"}), + ) + .await?; + println!( + "Remote workspace opened from runtime saved profile: {}", + workspace["id"] + ); + } else { + invoke("create_directory", json!({"path":root})).await?; + } + if role == "product-upload" { + use base64::Engine; + use sha2::{Digest, Sha256}; + invoke("open_workspace", json!({"path":root})).await?; + let total = 101 * 1024 * 1024usize; + let chunk_size = 3 * 1024 * 1024usize; + let chunk = (0..chunk_size) + .map(|index| (index % 251) as u8) + .collect::>(); + let mut hash = Sha256::new(); + for offset in (0..total).step_by(chunk_size) { + hash.update(&chunk[..(total - offset).min(chunk_size)]); + } + let digest = format!("{:x}", hash.finalize()); + let transfer = (0..32) + .map(|_| format!("{:02x}", rand::random::())) + .collect::(); + let path = format!("{root}/upload-101m-{transfer}.bin"); + let request = |action: &str| json!({"action":action,"transferId":transfer,"path":path,"workspacePath":root}); + let mut begin = request("begin"); + begin["totalBytes"] = json!(total); + begin["sha256"] = json!(digest); + invoke("workspace_file_upload", begin).await?; + let start = std::time::Instant::now(); + for offset in (0..total).step_by(chunk_size) { + let size = (total - offset).min(chunk_size); + let mut append = request("append"); + append["offset"] = json!(offset); + append["contentBase64"] = + json!(base64::engine::general_purpose::STANDARD.encode(&chunk[..size])); + let result = invoke("workspace_file_upload", append.clone()).await?; + if result["nextOffset"] != offset + size { + return Err(anyhow!("Upload cursor mismatch")); + } + if offset == 0 { + let duplicate = invoke("workspace_file_upload", append).await?; + if duplicate["nextOffset"] != size { + return Err(anyhow!("Duplicate chunk was not idempotent")); + } + } + if offset % (chunk_size * 7) == 0 { + let status = invoke("workspace_file_upload", request("status")).await?; + if status["nextOffset"] != offset + size { + return Err(anyhow!("Upload status mismatch")); + } + } + } + let finished = invoke("workspace_file_upload", request("finish")).await?; + if finished["completed"] != true || finished["nextOffset"] != total { + return Err(anyhow!("Upload not completed")); + } + println!( + "{}", + json!({"bytes":total,"sha256":digest,"path":path,"elapsedMs":start.elapsed().as_millis(),"verified":"runtime hash checked; duplicate append and status verified"}) + ); + return Ok(()); + } + let file = format!("{root}/bulk-fixture.txt"); + let renamed = format!("{root}/renamed-fixture.txt"); + let content = "fixture-content\n".repeat(200_000); + if role == "product-ssh-features" { + let transfer_work = async { + use base64::Engine; + use sha2::{Digest, Sha256}; + let transfer = (0..32) + .map(|_| format!("{:02x}", rand::random::())) + .collect::(); + let digest = format!("{:x}", Sha256::digest(content.as_bytes())); + let request = |action: &str| json!({"action":action,"transferId":transfer,"path":file,"workspacePath":root,"remoteConnectionId":"relay-lab-saved-ssh"}); + let mut begin = request("begin"); + begin["totalBytes"] = json!(content.len()); + begin["sha256"] = json!(digest); + invoke("workspace_file_upload", begin).await?; + for (index, bytes) in content.as_bytes().chunks(3 * 1024 * 1024).enumerate() { + let mut chunk = request("append"); + chunk["offset"] = json!(index * 3 * 1024 * 1024); + chunk["contentBase64"] = + json!(base64::engine::general_purpose::STANDARD.encode(bytes)); + invoke("workspace_file_upload", chunk).await?; + } + if invoke("workspace_file_upload", request("finish")).await?["completed"] != true { + return Err(anyhow!("Remote upload did not complete")); + } + let mut offset = 0usize; + let mut downloaded = Sha256::new(); + let mut revision = None; + while offset < content.len() { + let command=json!({"cmd":"read_file_chunk","path":file,"workspace_path":root,"remote_connection_id":"relay-lab-saved-ssh","offset":offset,"limit":3*1024*1024}).to_string(); + let result: serde_json::Value = serde_json::from_str( + &client.device_rpc(&url, &session, &target, &command).await?, + )?; + if result["total_size"] != content.len() || result["offset"] != offset { + return Err(anyhow!("Remote download size/cursor mismatch: {result}")); + } + let current = result["revision"].clone(); + if revision.as_ref().is_some_and(|old| old != ¤t) { + return Err(anyhow!("Remote download revision changed")); + } + revision = Some(current); + let bytes = base64::engine::general_purpose::STANDARD.decode( + result["chunk_base64"] + .as_str() + .ok_or_else(|| anyhow!("Missing remote file chunk"))?, + )?; + if bytes.is_empty() { + return Err(anyhow!("Remote download stalled")); + } + offset += bytes.len(); + downloaded.update(bytes); + } + if format!("{:x}", downloaded.finalize()) != digest { + return Err(anyhow!("Remote download hash mismatch")); + } + Ok::<_, anyhow::Error>(()) + }; + let control_work = async { + let mut times = Vec::new(); + for _ in 0..20 { + let start = std::time::Instant::now(); + let result: serde_json::Value = serde_json::from_str( + &client + .device_rpc(&url, &session, &target, r#"{"cmd":"ping"}"#) + .await?, + )?; + if result["resp"] != "pong" { + return Err(anyhow!("Concurrent control Ping failed")); + } + times.push(start.elapsed().as_millis()); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + times.sort_unstable(); + Ok::<_, anyhow::Error>( + json!({"samples":times.len(),"p50Ms":times[times.len()/2],"maxMs":times.last()}), + ) + }; + let (_, timings) = tokio::try_join!(transfer_work, control_work)?; + println!("Concurrent control latency during SSH transfer: {timings}"); + } else { + invoke( + "write_file_content", + json!({"filePath":file,"content":content}), + ) + .await?; + } + let read = invoke("read_file_content", json!({"filePath":file})).await?; + if read.as_str() != Some(content.as_str()) { + return Err(anyhow!("File round-trip mismatch")); + } + invoke("rename_file", json!({"oldPath":file,"newPath":renamed})).await?; + let children = invoke( + "get_directory_children_paginated", + json!({"path":root,"limit":100}), + ) + .await?; + if !children["children"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["name"] == "renamed-fixture.txt") + }) { + return Err(anyhow!("Renamed file missing from listing")); + } + invoke("delete_file", json!({"path":renamed})).await?; + let profiles = invoke("ssh_list_saved_connections", json!({})).await?; + if !profiles.is_array() { + return Err(anyhow!("Saved connections response malformed")); + } + let terminal = format!("lab-{}", uuid::Uuid::new_v4()); + invoke( + "terminal_create", + json!({"sessionId":terminal,"workingDirectory":root,"cols":80,"rows":24}), + ) + .await?; + let check = async { + let marker = format!("VERIFIED_{}", uuid::Uuid::new_v4()); + let encoded = marker + .bytes() + .map(|byte| format!("\\{:03o}", byte)) + .collect::(); + invoke( + "terminal_write", + json!({"sessionId":terminal,"data":format!("printf '{}\\n'\n", encoded)}), + ) + .await?; + for _ in 0..30 { + let history = invoke( + "terminal_get_history", + json!({"sessionId":terminal,"afterOffset":0}), + ) + .await?; + if history["data"] + .as_str() + .is_some_and(|data| data.contains(&marker)) + { + return Ok::<_, anyhow::Error>(()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Err(anyhow!("Terminal output missing")) + } + .await; + let close = invoke("terminal_close", json!({"sessionId":terminal})).await; + check?; + close?; + println!("{role}: large file write/read/rename/list/delete, saved connections, terminal create/write/history/close passed"); + return Ok(()); + } + if role == "bulk-controller" { + let connection = RealtimeConnection::connect(&url, &token, false).await?; + let sender = connection.sender(); + let target = std::env::var("RELAY_LAB_TARGET")?; + let content = "x".repeat(3 * 1024 * 1024); + let bulk = sender.call(&target, json!({"content":content})); + let controls = async { + for i in 0..20 { + let result = sender.call(&target, json!({"i":i})).await?; + if result["echo"]["i"] != i { + return Err(anyhow!("Control correlation mismatch")); + } + } + Ok::<_, anyhow::Error>(()) + }; + let (bulk, _) = tokio::try_join!(bulk, controls)?; + if bulk["echo"]["content"].as_str() != Some(content.as_str()) { + return Err(anyhow!("Bulk payload mismatch")); + } + println!("3 MiB round-trip and 20 concurrent control calls verified"); + drop(sender); + connection.close().await; + return Ok(()); + } + let mut connection = RealtimeConnection::connect(&url, &token, role == "host").await?; + let sender = connection.sender(); + if role == "host" { + println!("Source-built host registered"); + while let Ok(event) = connection.receive().await { + if let Incoming::RpcRequest(request) = event { + sender + .respond(request.id, json!({"echo":request.payload.0["params"]})) + .await?; + } + } + } else { + let target = std::env::var("RELAY_LAB_TARGET")?; + let mut calls = Vec::new(); + for i in 0..20 { + let sender = sender.clone(); + let target = target.clone(); + calls.push(async move { + let result = sender.call(&target, json!({"i":i})).await?; + if result["echo"]["i"] != i { + return Err(anyhow!("RPC correlation mismatch")); + } + Ok::<_, anyhow::Error>(()) + }); + } + futures::future::try_join_all(calls).await?; + println!("20 concurrent RPC calls verified"); + } + drop(sender); + connection.close().await; + Ok(()) +} + +async fn wait_catalog_revision( + events: &std::sync::Arc>>, + field: &str, + before: u64, +) -> Result<()> { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + if events + .lock() + .unwrap() + .last() + .and_then(|event| event[field].as_u64()) + .is_some_and(|revision| revision > before) + { + return Ok(()); + } + if tokio::time::Instant::now() > deadline { + return Err(anyhow!("Catalog {field} did not advance")); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index b5f08b92cc..cc5c2cda68 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -18,9 +18,14 @@ mod lan; mod page_upload; pub mod pairing; pub mod qr_generator; +pub mod realtime_client; +mod realtime_payload; pub mod relay_client; mod relay_http; +pub mod session_log; +pub mod session_records; pub mod session_store; +pub mod session_subscriber; pub use chat_projection::{ agent_input_attachment_from_remote_image_context, project_remote_chat_user, @@ -59,9 +64,7 @@ pub use page_upload::{ }; pub use pairing::PairingState; pub use qr_generator::QrGenerator; -pub use relay_client::{ - ensure_rustls_crypto_provider, ConnectionState, RelayClient, RelayEvent, RelayMessage, -}; +pub use relay_client::{ensure_rustls_crypto_provider, ConnectionState, RelayClient, RelayEvent}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -857,15 +860,29 @@ where RemoteCommand::ReadFileChunk { path, session_id, + workspace_path, + remote_connection_id, offset, limit, } => { match host - .read_remote_file_chunk(path, session_id.as_deref(), *offset, *limit) + .read_remote_file_chunk( + path, + session_id.as_deref(), + workspace_path.as_deref(), + remote_connection_id.as_deref(), + *offset, + *limit, + ) .await { Ok(Some(file)) => return remote_file_chunk_response(Ok(file)), Err(error) => return remote_file_chunk_response(Err(error)), + Ok(None) if workspace_path.is_some() || remote_connection_id.is_some() => { + return RemoteResponse::Error { + message: "This host cannot resolve an explicit file workspace".into(), + } + } Ok(None) => {} } let workspace_root = host @@ -876,10 +893,28 @@ where .await, ) } - RemoteCommand::GetFileInfo { path, session_id } => { - match host.remote_file_info(path, session_id.as_deref()).await { + RemoteCommand::GetFileInfo { + path, + session_id, + workspace_path, + remote_connection_id, + } => { + match host + .remote_file_info( + path, + session_id.as_deref(), + workspace_path.as_deref(), + remote_connection_id.as_deref(), + ) + .await + { Ok(Some(file)) => return remote_file_info_response(Ok(file)), Err(error) => return remote_file_info_response(Err(error)), + Ok(None) if workspace_path.is_some() || remote_connection_id.is_some() => { + return RemoteResponse::Error { + message: "This host cannot resolve an explicit file workspace".into(), + } + } Ok(None) => {} } let workspace_root = host @@ -1241,6 +1276,8 @@ where pub fn remote_session_created_response(session_id: impl Into) -> RemoteResponse { RemoteResponse::SessionCreated { session_id: session_id.into(), + workspace_path: None, + remote_connection_id: None, } } @@ -1392,17 +1429,20 @@ where _ => "Remote Code Session", }); - let binding_workspace = if is_claw { + let explicit_workspace = workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty() && *path != "/"); + let use_default_assistant = is_claw && explicit_workspace.is_none(); + let binding_workspace = if let Some(path) = explicit_workspace { + Some(path.to_owned()) + } else if use_default_assistant { match host.resolve_default_assistant_workspace_path().await { Ok(path) => Some(path), Err(message) => return RemoteResponse::Error { message }, } } else { - workspace_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty() && *path != "/") - .map(ToOwned::to_owned) + None }; let Some(binding_workspace) = binding_workspace else { @@ -1418,15 +1458,31 @@ where let request = build_remote_session_create_request( session_name, agent, - Some(binding_workspace), + Some(binding_workspace.clone()), RemoteSessionWorkspaceIdentity::new( - remote_connection_id.clone(), - remote_ssh_host.clone(), + if use_default_assistant { + None + } else { + remote_connection_id.clone() + }, + if use_default_assistant { + None + } else { + remote_ssh_host.clone() + }, ), RemoteConnectSubmissionSource::Relay, ); match host.create_session(request).await { - Ok(session_id) => remote_session_created_response(session_id), + Ok(session_id) => RemoteResponse::SessionCreated { + session_id, + workspace_path: Some(binding_workspace), + remote_connection_id: if use_default_assistant { + None + } else { + remote_connection_id.clone().filter(|id| !id.is_empty()) + }, + }, Err(message) => RemoteResponse::Error { message }, } } @@ -1608,7 +1664,11 @@ where #[async_trait::async_trait] pub trait RemoteInteractionRuntimeHost: Send + Sync { async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; - async fn confirm_tool(&self, tool_id: &str) -> Result<(), String>; + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<(), String>; async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; async fn get_permission_mode(&self) -> Result; async fn set_permission_mode( @@ -1629,10 +1689,13 @@ where H: RemoteInteractionRuntimeHost + ?Sized, { match command { - RemoteCommand::ConfirmTool { tool_id } => remote_interaction_accepted_response( + RemoteCommand::ConfirmTool { + tool_id, + updated_input, + } => remote_interaction_accepted_response( "confirm_tool", tool_id.clone(), - host.confirm_tool(tool_id).await, + host.confirm_tool(tool_id, updated_input.clone()).await, ), RemoteCommand::RejectTool { tool_id, reason } => remote_interaction_accepted_response( "reject_tool", @@ -2329,6 +2392,10 @@ pub struct RemoteControlClient { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum RemoteCommand { + /// Granted only over authenticated, pairwise-encrypted account routing. + GetSessionKey { + session_id: String, + }, GetWorkspaceInfo, ListRecentWorkspaces, SetWorkspace { @@ -2426,6 +2493,8 @@ pub enum RemoteCommand { }, ConfirmTool { tool_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + updated_input: Option, }, RejectTool { tool_id: String, @@ -2456,12 +2525,20 @@ pub enum RemoteCommand { ReadFileChunk { path: String, session_id: Option, + #[serde(default)] + workspace_path: Option, + #[serde(default)] + remote_connection_id: Option, offset: u64, limit: u64, }, GetFileInfo { path: String, session_id: Option, + #[serde(default)] + workspace_path: Option, + #[serde(default)] + remote_connection_id: Option, }, /// Provision a separate device through this authenticated controller. /// The target host owns token issuance and idempotent request handling. @@ -2479,7 +2556,7 @@ pub enum RemoteCommand { // ── Device-to-device distributed control ────────────────────────────── // // These variants are carried *inside* an encrypted device-to-device - // payload (see `RelayMessage::DeviceMessage`). The relay never sees them + // payload (see the Socket.IO RPC envelope). The relay never sees them // in cleartext; the receiving device decrypts the outer envelope with the // account master_key, then deserializes the inner JSON into `RemoteCommand`. // @@ -2525,6 +2602,11 @@ pub enum RemoteCommand { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "resp", rename_all = "snake_case")] pub enum RemoteResponse { + SessionKey { + session_id: String, + relay_session_id: String, + key: String, + }, WorkspaceInfo { has_workspace: bool, path: Option, @@ -2573,6 +2655,10 @@ pub enum RemoteResponse { }, SessionCreated { session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + remote_connection_id: Option, }, ModelCatalog { catalog: RemoteModelCatalog, @@ -2914,9 +3000,11 @@ where ), // The authenticated host owns credential provisioning. - RemoteCommand::ProvisionPeerDevice { .. } => RemoteResponse::Error { - message: "Device provisioning is not available on this host".to_string(), - }, + RemoteCommand::ProvisionPeerDevice { .. } | RemoteCommand::GetSessionKey { .. } => { + RemoteResponse::Error { + message: "Device provisioning is not available on this host".to_string(), + } + } RemoteCommand::SendSessionToDevice { .. } | RemoteCommand::ExecuteOnDevice { .. } @@ -4397,6 +4485,8 @@ mod tests { created, RemoteResponse::SessionCreated { session_id: "created-session".to_string(), + workspace_path: Some("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/workspace/project".to_string()), + remote_connection_id: Some("conn-1".to_string()), } ); let created_requests = host.created_requests.lock().unwrap(); @@ -4416,6 +4506,55 @@ mod tests { ); } + #[tokio::test] + async fn claw_creation_preserves_explicit_workspace_and_scopes_default_to_local() { + let host = FakeSessionHost::default(); + for explicit in [Some("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/workspace/selected-assistant"), None] { + let response = handle_remote_session_command( + &host, + &RemoteCommand::CreateSession { + agent_type: Some("Claw".into()), + session_name: None, + workspace_path: explicit.map(str::to_string), + remote_connection_id: Some("other-workspace-ssh".into()), + remote_ssh_host: Some("other-host".into()), + }, + ) + .await; + let RemoteResponse::SessionCreated { + workspace_path, + remote_connection_id, + .. + } = response + else { + panic!("creation failed"); + }; + assert_eq!( + workspace_path.as_deref(), + Some(explicit.unwrap_or("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/workspace/assistant")) + ); + assert_eq!( + remote_connection_id.as_deref(), + explicit.map(|_| "other-workspace-ssh") + ); + } + let requests = host.created_requests.lock().unwrap(); + assert_eq!( + requests[0].workspace_path.as_deref(), + Some("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/workspace/selected-assistant") + ); + assert_eq!( + requests[0].remote_connection_id.as_deref(), + Some("other-workspace-ssh") + ); + assert_eq!( + requests[1].workspace_path.as_deref(), + Some("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/workspace/assistant") + ); + assert!(requests[1].remote_connection_id.is_none()); + assert!(requests[1].remote_ssh_host.is_none()); + } + #[tokio::test] async fn remote_session_handler_forwards_and_returns_reasoning_preset() { let host = FakeSessionHost::default(); @@ -4951,7 +5090,11 @@ mod tests { #[async_trait::async_trait] impl RemoteInteractionRuntimeHost for FakeInteractionHost { - async fn confirm_tool(&self, _tool_id: &str) -> Result<(), String> { + async fn confirm_tool( + &self, + _tool_id: &str, + _updated_input: Option, + ) -> Result<(), String> { Ok(()) } diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index b8c446f534..d51d792d4c 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -2,10 +2,9 @@ use super::device_crypto; use anyhow::{anyhow, Result}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, sync::Arc}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, OnceCell}; use crate::remote_connect::device::DeviceIdentity; use crate::remote_connect::relay_http::{ @@ -14,13 +13,30 @@ use crate::remote_connect::relay_http::{ pub const MASTER_KEY_LEN: usize = 32; +/// A retired official deployment has its own credential database. Authenticate +/// with the new deployment instead of replaying its token or deleting the record. +pub fn is_retired_official_relay(value: &str) -> bool { + reqwest::Url::parse(value).is_ok_and(|url| { + url.scheme() == "https" + && url.host_str() == Some("remote.openbitfun.com") + && url.username().is_empty() + && url.password().is_none() + && url.port().is_none() + && url.path().trim_end_matches('/') == "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/v/1.0.0" + && url.query().is_none() + && url.fragment().is_none() + }) +} + /// Device-scoped relay credentials and a locally owned X25519 private key. #[derive(Clone)] pub struct AccountSession { pub token: String, + updates: tokio::sync::broadcast::Sender>, pub user_id: String, pub master_key: [u8; MASTER_KEY_LEN], - peer_keys: Arc>>, + peer_keys: Arc>>>>, + transports: Arc>>>>>, } impl std::fmt::Debug for AccountSession { @@ -35,12 +51,21 @@ impl AccountSession { pub fn new(token: String, user_id: String, device_secret: [u8; 32]) -> Self { Self { token, + updates: tokio::sync::broadcast::channel(64).0, user_id, master_key: device_secret, peer_keys: Arc::new(Mutex::new(HashMap::new())), + transports: Arc::new(Mutex::new(HashMap::new())), } } + /// Notifications are hints; receiver lag means catch up all subscribed logs. + pub fn session_updates( + &self, + ) -> tokio::sync::broadcast::Receiver> { + self.updates.subscribe() + } + pub async fn clear_peer_keys(&self) { self.peer_keys.lock().await.clear(); } @@ -51,31 +76,37 @@ impl AccountSession { &format!("/api/devices/{}/key", urlencoding::encode(device_id)), )?; let cache_id = endpoint.to_string(); - let mut keys = self.peer_keys.lock().await; - if let Some(key) = keys.get(&cache_id) { - return Ok(*key); - } - let response = relay_http_client() - .get(endpoint) - .bearer_auth(&self.token) - .send() - .await?; - if !response.status().is_success() { - return Err(AccountClient::into_error(response).await); - } - #[derive(Deserialize)] - struct PeerKey { - device_id: String, - public_key: String, - } - let peer: PeerKey = response.json().await?; - if peer.device_id != device_id { - return Err(anyhow!("relay returned a different device identity")); - } - let public = super::encryption::parse_public_key(&peer.public_key)?; - let key = device_crypto::derive_message_key(&self.master_key, &public)?; - keys.insert(cache_id, key); - Ok(key) + let cell = { + let mut keys = self.peer_keys.lock().await; + keys.entry(cache_id).or_default().clone() + }; + // Only callers for this peer share initialization. No account-wide + // lock survives network IO. Failed/cancelled lookups can be retried; + // invalidation removes the cell so an older lookup cannot refill it. + cell.get_or_try_init(|| async { + let response = relay_http_client() + .get(endpoint) + .bearer_auth(&self.token) + .send() + .await?; + if !response.status().is_success() { + return Err(AccountClient::into_error(response).await); + } + #[derive(Deserialize)] + struct PeerKey { + device_id: String, + public_key: String, + } + let peer: PeerKey = response.json().await?; + if peer.device_id != device_id { + return Err(anyhow!("relay returned a different device identity")); + } + let public = super::encryption::parse_public_key(&peer.public_key)?; + let key = device_crypto::derive_message_key(&self.master_key, &public)?; + Ok(key) + }) + .await + .copied() } pub async fn encrypt_for_peer( @@ -184,23 +215,6 @@ pub fn validate_relay_base_url(relay_url: &str) -> Result { Ok(url) } -/// Build the Relay WebSocket endpoint from the same validated base URL used by -/// account HTTP requests. This preserves reverse-proxy prefixes while avoiding -/// `//ws` when a user- or config-supplied base URL has a trailing slash. -pub fn build_relay_websocket_url(relay_url: &str) -> Result { - let mut url = validate_relay_base_url(relay_url)?; - let websocket_scheme = match url.scheme() { - "https" => "wss", - "http" => "ws", - _ => unreachable!("validate_relay_base_url accepts only http(s) schemes"), - }; - url.set_scheme(websocket_scheme) - .map_err(|_| anyhow!("failed to convert relay URL to WebSocket scheme"))?; - let base_path = url.path().trim_end_matches('/').to_string(); - url.set_path(&format!("{base_path}/ws")); - Ok(url.to_string()) -} - impl Default for AccountClient { fn default() -> Self { Self::new() @@ -464,11 +478,10 @@ impl AccountClient { Ok(()) } - /// Send an encrypted RemoteCommand to a target device via HTTP RPC. - /// The relay routes the opaque ciphertext to the target device's WS, - /// waits for the response, and returns the encrypted response. - /// The caller is responsible for encrypting the command and decrypting - /// the response with the account master_key. + /// Send an encrypted RemoteCommand over the shared account connection. + /// The relay routes opaque ciphertext and returns the encrypted acknowledgement. + /// Pairwise device keys protect both directions; the shared account + /// Socket.IO connection owns acknowledgement and reconnect lifetimes. pub async fn device_rpc( &self, relay_url: &str, @@ -476,36 +489,55 @@ impl AccountClient { target_device_id: &str, plaintext_command: &str, ) -> Result { - // Encrypt the command with the master key + // Encrypt for the authenticated target device let (data, nonce) = session .encrypt_for_peer(relay_url, target_device_id, plaintext_command) .await?; - let body = serde_json::json!({ - "encrypted_data": data, - "nonce": nonce, - }); - let resp = self - .http - .post(Self::endpoint( - relay_url, - &format!("/api/devices/{}/rpc", urlencoding::encode(target_device_id)), - )?) - .header("Authorization", Self::auth_header(session)) - .json(&body) - .send() + let cell = { + let mut transports = session.transports.lock().await; + transports.entry(relay_url.to_string()).or_default().clone() + }; + let transport = cell + .get_or_try_init(|| async { + let (transport, mut events) = super::relay_client::RelayClient::new_controller(); + transport.connect(relay_url).await?; + transport + .connect_authenticated(&session.token, "Controller") + .await?; + let updates = session.updates.clone(); + let peer_keys = session.peer_keys.clone(); + tokio::spawn(async move { + while let Some(event) = events.recv().await { + use super::relay_client::RelayEvent; + match event { + RelayEvent::SessionUpdated { + relay_session_id, + message, + } => { + let _ = updates.send(Some((relay_session_id, message))); + } + RelayEvent::Connected + | RelayEvent::Reconnected + | RelayEvent::AuthOk { .. } => { + peer_keys.lock().await.clear(); + let _ = updates.send(None); + } + RelayEvent::DevicePresence { .. } => { + peer_keys.lock().await.clear(); + } + _ => {} + } + } + }); + Ok::<_, anyhow::Error>(Arc::new(transport)) + }) .await?; - if !resp.status().is_success() { - return Err(Self::into_error(resp).await); - } - let entry: RpcResponseEntry = resp.json().await?; - // Decrypt the response with the master key + let (encrypted_data, nonce) = transport + .request_device(target_device_id, &data, &nonce) + .await?; + // Decrypt using the authenticated target device key session - .decrypt_from_peer( - relay_url, - target_device_id, - &entry.encrypted_data, - &entry.nonce, - ) + .decrypt_from_peer(relay_url, target_device_id, &encrypted_data, &nonce) .await } } @@ -531,16 +563,65 @@ struct DeviceListEntry { last_seen_at: Option, } -#[derive(Deserialize)] -struct RpcResponseEntry { - encrypted_data: String, - nonce: String, -} - #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn slow_peer_lookup_does_not_block_another_device_or_invalidation() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::time::{timeout, Duration}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut slow, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + slow.read(&mut request).await.unwrap(); + started_tx.send(()).unwrap(); + let (mut fast, _) = listener.accept().await.unwrap(); + fast.read(&mut request).await.unwrap(); + let reply = |device: &str| { + let body = serde_json::json!({ + "device_id": device, + "public_key": super::super::encryption::KeyPair::generate().public_key_base64() + }) + .to_string(); + format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body) + }; + fast.write_all(reply("fast").as_bytes()).await.unwrap(); + release_rx.await.unwrap(); + slow.write_all(reply("slow").as_bytes()).await.unwrap(); + }); + let session = AccountSession::new("fixture".into(), "fixture".into(), [7; 32]); + let slow_session = session.clone(); + let slow_url = url.clone(); + let slow = + tokio::spawn(async move { slow_session.peer_message_key(&slow_url, "slow").await }); + timeout(Duration::from_secs(5), started_rx) + .await + .unwrap() + .unwrap(); + timeout( + Duration::from_secs(5), + session.peer_message_key(&url, "fast"), + ) + .await + .unwrap() + .unwrap(); + timeout(Duration::from_secs(5), session.clear_peer_keys()) + .await + .unwrap(); + release_tx.send(()).unwrap(); + slow.await.unwrap().unwrap(); + server.await.unwrap(); + assert!( + session.peer_keys.lock().await.is_empty(), + "invalidation must survive an older lookup completing" + ); + } + #[test] fn relay_endpoint_accepts_http_servers_and_rejects_ambiguous_urls() { let endpoint = @@ -561,18 +642,20 @@ mod tests { } #[test] - fn relay_websocket_endpoint_normalizes_root_and_prefixed_urls() { - assert_eq!( - build_relay_websocket_url("https://relay.example.com/").unwrap(), - "wss://relay.example.com/ws" - ); - assert_eq!( - build_relay_websocket_url("https://relay.example.com/prefix/").unwrap(), - "wss://relay.example.com/prefix/ws" - ); - assert_eq!( - build_relay_websocket_url("http://127.0.0.1:3000/relay").unwrap(), - "ws://127.0.0.1:3000/relay/ws" - ); + fn retired_official_endpoint_does_not_capture_custom_relays() { + let old = ["https://remote.openbitfun.com", "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/v/1.0.0"].concat(); + assert!(is_retired_official_relay(&old)); + assert!(is_retired_official_relay(&format!("{old}/"))); + for endpoint in [ + "https://remote.openbitfun.com/v/1.0.1", + "https://custom.example/v/1.0.0", + "http://127.0.0.1:9700", + "https://remote.openbitfun.com/relay", + "https://user@remote.openbitfun.com/v/1.0.0", + "https://remote.openbitfun.com:444/v/1.0.0", + ] { + assert!(!is_retired_official_relay(endpoint)); + } + assert!(!is_retired_official_relay(&format!("{old}?other=1"))); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs index 3cfdad61f4..6eb3ec2997 100644 --- a/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs +++ b/src/crates/services/services-integrations/src/remote_connect/qr_generator.rs @@ -60,13 +60,13 @@ mod account_device_tests { #[test] fn invitation_uses_only_the_authenticated_device_target() { assert_eq!( - QrGenerator::build_device_url("https://remote.openbitfun.com/v/1.0.0", "host-1") + QrGenerator::build_device_url("https://remote.openbitfun.com/v/1.0.1", "host-1") .unwrap(), - "https://remote.openbitfun.com/v/1.0.0/#/pair?did=host-1" + "https://remote.openbitfun.com/v/1.0.1/#/pair?did=host-1" ); for id in ["", "host&relay=evil", "../host", "host/other"] { assert!( - QrGenerator::build_device_url("https://remote.openbitfun.com/v/1.0.0", id).is_err() + QrGenerator::build_device_url("https://remote.openbitfun.com/v/1.0.1", id).is_err() ); } } diff --git a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs new file mode 100644 index 0000000000..1d8e45d0a0 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs @@ -0,0 +1,247 @@ +//! Shared Happy-style Socket.IO RPC adapter for product hosts and controllers. +//! IO, acknowledgement ownership and admission stay below platform adapters. +use anyhow::{anyhow, bail, Result}; +use futures::{stream::FuturesUnordered, StreamExt}; +use serde_json::{json, Value}; +use sioc::prelude::*; +use std::time::Duration; + +/// Authentication payload shared by all Rust hosts and controllers. +pub fn account_auth_payload(token: &str, machine: bool) -> Value { + json!({"token": token, "clientType": if machine { "machine-scoped" } else { "user-scoped" }}) +} + +#[derive(Debug, AckType, SerializePayload, DeserializePayload)] +pub struct JsonAck(pub Value); + +macro_rules! json_event { + ($name:ident, $wire:literal, $ack:ty) => { + #[derive(Debug, SerializePayload, DeserializePayload)] + pub struct $name(pub Value); + impl EventType for $name { + const NAME: &'static str = $wire; + type Ack = $ack; + type Binary = NoBinary; + } + }; +} +json_event!(RpcCall, "rpc-call", HasAck); +json_event!(RpcRegister, "rpc-register", HasAck); +json_event!(RpcRequest, "rpc-request", HasAck); +json_event!(AuthOk, "auth-ok", NoAck); +json_event!(Update, "update", NoAck); +json_event!(Ephemeral, "ephemeral", NoAck); +json_event!(MachineEvent, "machine-event", HasAck); +json_event!(Registered, "rpc-registered", NoAck); + +#[derive(Debug, EventRouter)] +pub enum Incoming { + AuthOk(Event), + RpcRequest(Event), + Update(Event), + Ephemeral(Event), + Registered(Event), +} + +/// One connection epoch. The owner replaces this entire object on reconnect; +/// outstanding calls never migrate to a new socket or retry automatically. +pub struct RealtimeConnection { + sender: SocketSender, + receiver: SocketReceiver, + downloads: FuturesUnordered>>>, + client: Client, + payloads: super::realtime_payload::PayloadClient, + pub user_id: String, + pub device_id: String, +} + +#[derive(Clone)] +pub struct RealtimeSender { + sender: SocketSender, + payloads: super::realtime_payload::PayloadClient, +} + +impl RealtimeConnection { + pub async fn connect(relay_url: &str, token: &str, machine: bool) -> Result { + super::relay_client::ensure_rustls_crypto_provider(); + let mut base = reqwest::Url::parse(relay_url)?; + if !matches!(base.scheme(), "http" | "https") + || !base.username().is_empty() + || base.password().is_some() + || base.query().is_some() + || base.fragment().is_some() + { + bail!("Invalid Relay URL"); + } + // url::join treats a final segment as a file unless it ends in '/'. + // Preserve the official versioned Relay mount as well as local roots. + base.set_path(&format!("{}/", base.path().trim_end_matches('/'))); + let payloads = super::realtime_payload::PayloadClient::new(&base, token)?; + let client = ClientBuilder::new(base) + .path("v1/updates/") + .transport(TransportStrategy::WebSocket) + .channels(64usize) + .http_client(super::relay_http::relay_http_client().clone()) + .open()?; + let auth = account_auth_payload(token, machine).to_string(); + let (sender, mut receiver) = client.connect_with("/", auth).await?; + let identity = tokio::time::timeout(Duration::from_secs(15), async { + loop { + match receiver + .recv() + .await + .ok_or_else(|| anyhow!("Relay connection closed"))? + { + Signal::Event(event) => { + if let Incoming::AuthOk(event) = Incoming::try_from(event)? { + return Ok::<_, anyhow::Error>(event.payload.0); + } + } + Signal::Connect(_) => {} + Signal::ConnectError(_) => bail!("Relay authentication rejected"), + Signal::Disconnect => { + bail!("Relay disconnected before authentication completed") + } + } + } + }) + .await + .map_err(|_| anyhow!("Relay authentication timed out"))??; + let connection = Self { + sender, + receiver, + downloads: FuturesUnordered::new(), + client, + payloads, + user_id: identity["userId"] + .as_str() + .ok_or_else(|| anyhow!("Missing account identity"))? + .into(), + device_id: identity["deviceId"] + .as_str() + .ok_or_else(|| anyhow!("Missing device identity"))? + .into(), + }; + if machine { + let ack = connection + .sender + .emit(RpcRegister( + json!({"method":format!("{}:invoke",connection.device_id)}), + )) + .await? + .timeout(Duration::from_secs(10)) + .await?; + if ack.payload.0["ok"] != true { + bail!("Relay RPC registration rejected"); + } + } + Ok(connection) + } + pub fn sender(&self) -> RealtimeSender { + RealtimeSender { + sender: self.sender.clone(), + payloads: self.payloads.clone(), + } + } + pub async fn receive(&mut self) -> Result { + loop { + let signal = tokio::select! { + completed = self.downloads.next(), if !self.downloads.is_empty() => { + if let Some(incoming) = completed { if let Some(incoming) = incoming? { return Ok(incoming); } } + continue; + } + signal = self.receiver.recv() => signal.ok_or_else(|| anyhow!("Relay connection closed"))?, + }; + match signal { + Signal::Event(event) => { + let incoming = Incoming::try_from(event)?; + if let Incoming::RpcRequest(mut request) = incoming { + let payloads = self.payloads.clone(); + let sender = self.sender.clone(); + self.downloads.push(Box::pin(async move { + let params = request + .payload + .0 + .get_mut("params") + .ok_or_else(|| anyhow!("RPC params missing"))?; + match payloads.resolve(params.take()).await { + Ok(resolved) => *params = resolved, + Err(_) => { + let _ = sender.acknowledge(request.id, JsonAck(json!({"$relayError":"RPC payload unavailable; request was not executed"}))).await; + return Ok(None); + } + } + Ok(Some(Incoming::RpcRequest(request))) + })); + } else { + return Ok(incoming); + } + } + Signal::Connect(_) => {} + Signal::ConnectError(_) => bail!("Relay authentication rejected"), + Signal::Disconnect => bail!("Relay disconnected"), + } + } + } + pub async fn close(self) { + self.sender.disconnect().await; + drop(self.sender); + drop(self.receiver); + // Joining releases the Engine.IO heartbeat and socket tasks as well as + // the namespace. A replacement connection has no abandoned owner. + let _ = tokio::time::timeout(Duration::from_secs(5), self.client.join()).await; + } +} +impl RealtimeSender { + pub async fn event(&self, target: &str, params: Value) -> Result<()> { + let response = self + .sender + .emit(MachineEvent( + json!({"targetDeviceId":target,"params":params}), + )) + .await? + .timeout(Duration::from_secs(10)) + .await?; + if response.payload.0["ok"] != true { + bail!("Relay event rejected"); + } + Ok(()) + } + pub async fn call(&self, target: &str, params: Value) -> Result { + let params = self.payloads.upload_if_large(params).await?; + let ack = tokio::time::timeout(Duration::from_secs(125), async { + self.sender + .emit(RpcCall( + json!({"method":format!("{target}:invoke"),"params":params,"timeoutMs":120_000}), + )) + .await? + .timeout(Duration::from_secs(123)) + .await + .map_err(anyhow::Error::from) + }) + .await + .map_err(|_| anyhow!("Relay RPC timed out; delivery outcome is unknown"))??; + let result = ack.payload.0; + if result["ok"] != true { + bail!("{}", result["error"].as_str().unwrap_or("Relay RPC failed")); + } + self.payloads + .resolve( + result + .get("result") + .cloned() + .ok_or_else(|| anyhow!("Relay RPC response missing result"))?, + ) + .await + } + pub async fn respond(&self, id: AckId, result: Value) -> Result<()> { + let result = self.payloads.upload_if_large(result).await?; + tokio::time::timeout( + Duration::from_secs(10), + self.sender.acknowledge(id, JsonAck(result)), + ) + .await + .map_err(|_| anyhow!("Relay response write timed out"))??; + Ok(()) + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/realtime_payload.rs b/src/crates/services/services-integrations/src/remote_connect/realtime_payload.rs new file mode 100644 index 0000000000..9fe12e494a --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/realtime_payload.rs @@ -0,0 +1,116 @@ +//! Opaque bulk data lane shared by Rust Relay hosts and controllers. +use anyhow::{bail, Context, Result}; +use serde_json::Value; +use std::time::Duration; + +const INLINE_BYTES: usize = 128 * 1024; +const MAX_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Clone)] +pub(super) struct PayloadClient { + endpoint: reqwest::Url, + token: String, +} +impl PayloadClient { + pub(super) fn new(base: &reqwest::Url, token: &str) -> Result { + Ok(Self { + endpoint: base.join("v1/rpc/payloads")?, + token: token.into(), + }) + } + pub(super) async fn upload_if_large(&self, value: Value) -> Result { + let bytes = serde_json::to_vec(&value)?; + if bytes.len() <= INLINE_BYTES { + return Ok(value); + } + if bytes.len() > MAX_BYTES { + bail!("RPC payload exceeds a transfer block; use paginated or chunked operations"); + } + let response = super::relay_http::relay_http_client() + .post(self.endpoint.clone()) + .bearer_auth(&self.token) + .header("content-type", "application/octet-stream") + .timeout(Duration::from_secs(120)) + .body(bytes) + .send() + .await + .context("RPC payload upload failed")? + .error_for_status() + .context("RPC payload upload rejected")?; + let reference: Value = response.json().await?; + validate_reference(&reference)?; + Ok(reference) + } + pub(super) async fn resolve(&self, value: Value) -> Result { + if value.get("$relayPayload").is_none() { + return Ok(value); + } + let (id, expected) = validate_reference(&value)?; + let mut endpoint = self.endpoint.clone(); + endpoint + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("Invalid Relay endpoint"))? + .push(id); + let mut response = super::relay_http::relay_http_client() + .get(endpoint) + .bearer_auth(&self.token) + .timeout(Duration::from_secs(120)) + .send() + .await + .context("RPC payload download failed")? + .error_for_status() + .context("RPC payload download rejected")?; + if response + .content_length() + .is_some_and(|size| size != expected as u64) + { + bail!("Relay payload length mismatch"); + } + let mut bytes = Vec::with_capacity(expected.min(INLINE_BYTES)); + while let Some(chunk) = response.chunk().await? { + if chunk.len() > expected.saturating_sub(bytes.len()) { + bail!("Relay payload length mismatch"); + } + bytes.extend_from_slice(&chunk); + } + if bytes.len() != expected { + bail!("Incomplete Relay payload transfer"); + } + Ok(serde_json::from_slice(&bytes)?) + } +} + +fn validate_reference(value: &Value) -> Result<(&str, usize)> { + let reference = &value["$relayPayload"]; + let id = reference["id"] + .as_str() + .filter(|id| uuid::Uuid::parse_str(id).is_ok()) + .ok_or_else(|| anyhow::anyhow!("Invalid Relay payload id"))?; + let size = reference["bytes"] + .as_u64() + .filter(|size| *size > 0 && *size <= MAX_BYTES as u64) + .ok_or_else(|| anyhow::anyhow!("Invalid Relay payload size"))?; + Ok((id, size as usize)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + #[test] + fn references_cannot_select_a_url_or_escape_payload_route() { + for id in [ + "../../account", + "https://example.com/secret", + "?token=x", + "", + ] { + assert!(validate_reference(&json!({"$relayPayload":{"id":id,"bytes":1}})).is_err()); + } + let id = uuid::Uuid::new_v4().to_string(); + assert!( + validate_reference(&json!({"$relayPayload":{"id":id,"bytes":MAX_BYTES+1}})).is_err() + ); + assert!(validate_reference(&json!({"$relayPayload":{"id":id,"bytes":10}})).is_ok()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index 9c439a34f2..5ca9cb100d 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -1,103 +1,40 @@ -//! WebSocket client for connecting to the Relay Server. -//! -//! Account devices authenticate over WebSocket and receive presence and opaque -//! device messages. Payload submission uses bounded HTTP; reconnect repeats -//! account authentication before sending further control messages. - -use anyhow::{anyhow, Result}; -use futures::{SinkExt, StreamExt}; -use log::{debug, error, info, warn}; +//! Account connection owner shared by Desktop, CLI/daemon and bot adapters. +//! Socket.IO owns framing, heartbeat and RPC acks. This owner supplies account +//! epochs, bounded delivery, method registration and reconnect policy. +use super::realtime_client::{Incoming, JsonAck, RealtimeConnection, RealtimeSender}; +use anyhow::{anyhow, bail, Result}; use serde::{Deserialize, Serialize}; -use std::sync::{Arc, Mutex}; +use serde_json::json; +use sioc::prelude::AckId; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; use tokio::sync::{mpsc, oneshot}; -use tokio_tungstenite::tungstenite::Message; -#[cfg(windows)] -use tokio_tungstenite::{tungstenite::client::IntoClientRequest, Connector}; -/// Install the rustls ring CryptoProvider as the process-level default. -/// -/// Call this once at application startup so that all subsequent TLS operations -/// (relay_client, reqwest, tokio-tungstenite) reuse the same provider. -/// `install_default()` returns `Err` only when a provider is already installed, -/// which is harmless — we silently ignore it. -/// -/// This is safe to call multiple times and from any thread. Installing it -/// explicitly keeps provider choice deterministic for every product client. +pub const RELAY_INBOUND_IDLE_TIMEOUT: Duration = Duration::from_secs(60); + pub fn ensure_rustls_crypto_provider() { openbitfun_services_core::tls_provider::ensure_ring_crypto_provider(); } - -type WsStream = - tokio_tungstenite::WebSocketStream>; - -const RELAY_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); -/// Heartbeats are sent every 30 seconds. Two missed acknowledgements plus -/// scheduling/network slack indicates a half-open socket that should be -/// replaced even when the OS has not surfaced a read error yet. -pub const RELAY_INBOUND_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75); - -/// Messages in the relay protocol (both directions). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum RelayMessage { - // ── Outbound (desktop → relay) ────────────────────────────────── - Heartbeat, - /// Authenticate the socket and register this account device. - AuthConnect { - token: String, - device_name: String, - device_kind: String, - }, - /// Route an encrypted payload to another device in the same account. - DeviceMessage { - target_device_id: String, - correlation_id: String, - encrypted_data: String, - nonce: String, - }, - - // ── Inbound (relay → desktop) ─────────────────────────────────── - HeartbeatAck, - Error { - message: String, - }, - /// Account connect succeeded — relay validated the token. - AuthOk { - user_id: String, - device_id: String, - }, - AuthError { - message: String, - }, - /// A device-to-device message routed from another device in the account. - IncomingDeviceMessage { - source_device_id: String, - correlation_id: String, - encrypted_data: String, - nonce: String, - }, - /// Current online devices in the account (presence broadcast). - DevicePresence { - devices: Vec, - }, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DevicePresenceEntry { pub device_id: String, pub device_name: String, } - -/// Events emitted by the relay client to the upper layers. #[derive(Debug, Clone)] pub enum RelayEvent { + SessionUpdated { + relay_session_id: String, + message: serde_json::Value, + }, Connected, Reconnected, Disconnected, Error { message: String, }, - /// Account auth-connect succeeded. AuthOk { user_id: String, device_id: String, @@ -105,19 +42,16 @@ pub enum RelayEvent { AuthError { message: String, }, - /// Encrypted device-to-device message from another device in the account. DeviceMessageReceived { source_device_id: String, correlation_id: String, encrypted_data: String, nonce: String, }, - /// Online device list for the account. DevicePresence { devices: Vec, }, } - #[derive(Debug, Clone, PartialEq, Eq)] pub enum ConnectionState { Disconnected, @@ -125,950 +59,428 @@ pub enum ConnectionState { Connected, Reconnecting, } - -#[derive(Debug, Clone, Default)] -struct ReconnectCtx { - ws_url: String, - /// Account token for device-routing re-auth after reconnect. - token: String, - /// Device name for re-auth after reconnect. - device_name: String, +struct Reply { + id: AckId, + deadline: Instant, + source: String, } - -// One owner controls the socket, heartbeat, write deadline and reconnect loop. -// A generation fences late completion when connect replaces an earlier run. -struct ConnectionLifecycle { - generation: u64, +struct Owner { + epoch: u64, state: ConnectionState, + url: Option, task: Option>, - cmd_tx: Option>, - reconnect_ctx: Option, + sender: Option, + replies: HashMap, +} +struct EpochCleanup { + owner: Arc>, + epoch: u64, +} +impl Drop for EpochCleanup { + fn drop(&mut self) { + if let Ok(mut owner) = self.owner.lock() { + if owner.epoch == self.epoch { + owner.sender = None; + owner.replies.clear(); + owner.state = ConnectionState::Disconnected; + } + } + } } - -type ConnectionOwner = Arc>; - -// This is transport backpressure, not a limit on Agent work. A full queue -// rejects enqueue explicitly; unacknowledged commands are never replayed. -const RELAY_COMMAND_QUEUE_CAPACITY: usize = 64; -const RELAY_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); - pub struct RelayClient { - lifecycle: ConnectionOwner, - event_tx: mpsc::UnboundedSender, + owner: Arc>, + events: mpsc::Sender, + machine: bool, } - impl RelayClient { - pub fn new() -> (Self, mpsc::UnboundedReceiver) { - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let client = Self { - lifecycle: Arc::new(Mutex::new(ConnectionLifecycle { - generation: 0, - state: ConnectionState::Disconnected, - task: None, - cmd_tx: None, - reconnect_ctx: None, - })), - event_tx, - }; - (client, event_rx) + pub fn new() -> (Self, mpsc::Receiver) { + Self::with_role(true) + } + pub fn new_controller() -> (Self, mpsc::Receiver) { + Self::with_role(false) + } + fn with_role(machine: bool) -> (Self, mpsc::Receiver) { + let (events, receiver) = mpsc::channel(128); + ( + Self { + owner: Arc::new(Mutex::new(Owner { + epoch: 0, + state: ConnectionState::Disconnected, + url: None, + task: None, + sender: None, + replies: HashMap::new(), + })), + events, + machine, + }, + receiver, + ) } - pub async fn connection_state(&self) -> ConnectionState { - self.lifecycle.lock().unwrap().state.clone() + self.owner.lock().unwrap().state.clone() + } + /// Capture the endpoint; authentication establishes the actual account + /// socket. No application event is exposed before that handshake finishes. + pub async fn connect(&self, relay_url: &str) -> Result<()> { + let mut url = super::account::validate_relay_base_url(relay_url)?; + let path = url.path().trim_end_matches('/').to_owned(); + url.set_path(&path); + self.disconnect().await; + let mut owner = self.owner.lock().unwrap(); + owner.url = Some(url.to_string()); + owner.state = ConnectionState::Connecting; + Ok(()) } - - pub async fn connect(&self, ws_url: &str) -> Result<()> { - let (ready_tx, ready_rx) = oneshot::channel(); - let generation = { - let mut owner = self.lifecycle.lock().unwrap(); + pub async fn connect_authenticated(&self, token: &str, _device_name: &str) -> Result<()> { + let (ready, receive) = oneshot::channel(); + let epoch = { + let mut owner = self.owner.lock().unwrap(); + let url = owner + .url + .clone() + .ok_or_else(|| anyhow!("Relay endpoint is not configured"))?; if let Some(task) = owner.task.take() { task.abort(); } - owner.generation += 1; - owner.state = ConnectionState::Connecting; - owner.cmd_tx = None; - owner.reconnect_ctx = Some(ReconnectCtx { - ws_url: ws_url.to_string(), - ..Default::default() - }); - let generation = owner.generation; - owner.task = Some(tokio::spawn(Self::run_connection( - self.lifecycle.clone(), - self.event_tx.clone(), - generation, - ws_url.to_string(), - ready_tx, + owner.epoch += 1; + let epoch = owner.epoch; + owner.sender = None; + owner.replies.clear(); + owner.task = Some(tokio::spawn(Self::run( + self.owner.clone(), + self.events.clone(), + epoch, + url, + token.to_owned(), + self.machine, + ready, ))); - generation + epoch }; - ready_rx - .await - .map_err(|_| anyhow!("Relay connection attempt cancelled"))??; - if self.lifecycle.lock().unwrap().generation != generation { - return Err(anyhow!("Relay connection attempt superseded")); + match tokio::time::timeout(Duration::from_secs(20), receive).await { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(anyhow!("Relay authentication cancelled")), + Err(_) => { + let task = { + let mut owner = self.owner.lock().unwrap(); + if owner.epoch == epoch { + owner.epoch += 1; + owner.sender = None; + owner.replies.clear(); + owner.state = ConnectionState::Disconnected; + owner.task.take() + } else { + None + } + }; + if let Some(task) = task { + task.abort(); + let _ = task.await; + } + Err(anyhow!("Relay authentication timed out")) + } } - Ok(()) } - - async fn run_connection( - lifecycle: ConnectionOwner, - event_tx: mpsc::UnboundedSender, - generation: u64, - ws_url: String, + async fn run( + owner: Arc>, + events: mpsc::Sender, + epoch: u64, + url: String, + token: String, + machine: bool, ready: oneshot::Sender>, ) { - let mut socket = match dial(&ws_url).await { - Ok(socket) => socket, - Err(error) => { - let mut owner = lifecycle.lock().unwrap(); - if owner.generation == generation { - owner.state = ConnectionState::Disconnected; - owner.cmd_tx = None; - owner.reconnect_ctx = None; - let _ = event_tx.send(RelayEvent::Disconnected); - } - let _ = ready.send(Err(error)); - return; - } + // Every exit (including abort/panic) relinquishes this epoch's sender + // and reply table. A failed handshake must not leave Connecting forever. + let _cleanup = EpochCleanup { + owner: owner.clone(), + epoch, }; let mut ready = Some(ready); + let mut delay = Duration::from_secs(1); loop { - let (cmd_tx, cmd_rx) = mpsc::channel(RELAY_COMMAND_QUEUE_CAPACITY); - { - let mut owner = lifecycle.lock().unwrap(); - if owner.generation != generation { - return; + let connection = RealtimeConnection::connect(&url, &token, machine).await; + match connection { + Ok(mut connection) => { + let first = ready.is_some(); + { + let mut state = owner.lock().unwrap(); + if state.epoch != epoch { + return; + } + state.sender = Some(connection.sender()); + state.state = ConnectionState::Connected; + } + let _ = events.try_send(if first { + RelayEvent::Connected + } else { + RelayEvent::Reconnected + }); + if events + .try_send(RelayEvent::AuthOk { + user_id: connection.user_id.clone(), + device_id: connection.device_id.clone(), + }) + .is_err() + { + connection.close().await; + return; + } + if let Some(ready) = ready.take() { + let _ = ready.send(Ok(())); + } + delay = Duration::from_secs(1); + while let Ok(incoming) = connection.receive().await { + if !Self::route(&owner, &events, epoch, incoming) { + break; + } + } + connection.close().await; + } + Err(error) => { + if let Some(ready) = ready.take() { + let _ = ready.send(Err(anyhow!("Relay connection failed: {error}"))); + return; + } + log::warn!("Relay reconnect failed: {error}"); } - owner.state = ConnectionState::Connected; - owner.cmd_tx = Some(cmd_tx); - let event = if let Some(ready) = ready.take() { - let _ = ready.send(Ok(())); - RelayEvent::Connected - } else { - RelayEvent::Reconnected - }; - let _ = event_tx.send(event); } - info!("Relay transport connected"); - Self::run_socket(socket, cmd_rx, &lifecycle, &event_tx, generation).await; { - let mut owner = lifecycle.lock().unwrap(); - if owner.generation != generation { + let mut state = owner.lock().unwrap(); + if state.epoch != epoch { return; } - owner.state = ConnectionState::Reconnecting; - // Drop all commands from the failed socket. Delivery may have - // happened without a response; the protocol caller owns recovery. - owner.cmd_tx = None; + state.sender = None; + state.replies.clear(); + state.state = ConnectionState::Reconnecting; } - let mut backoff = 2; - socket = loop { - tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; - let ctx = { - let owner = lifecycle.lock().unwrap(); - if owner.generation != generation { - return; - } - let Some(ctx) = owner.reconnect_ctx.clone() else { - return; - }; - ctx - }; - match Self::reconnect(&ctx).await { - Ok(socket) => break socket, - Err(error) => { - warn!("Relay reconnect failed: {error}"); - backoff = std::cmp::min(backoff * 2, 30); - } - } - }; + if events.try_send(RelayEvent::Disconnected).is_err() { + return; + } + let jitter = rand::random::() as u64 % (delay.as_millis() as u64 + 1); + tokio::time::sleep(delay / 2 + Duration::from_millis(jitter)).await; + delay = (delay * 2).min(Duration::from_secs(5)); } } - - async fn reconnect(ctx: &ReconnectCtx) -> Result { - let mut socket = dial(&ctx.ws_url).await?; - if !ctx.token.is_empty() { - write_relay_message( - &mut socket, - &RelayMessage::AuthConnect { - token: ctx.token.clone(), - device_name: ctx.device_name.clone(), - device_kind: "desktop".to_string(), - }, - ) - .await?; + fn route( + owner: &Arc>, + events: &mpsc::Sender, + epoch: u64, + event: Incoming, + ) -> bool { + let mut state = owner.lock().unwrap(); + if state.epoch != epoch { + return false; } - Ok(socket) - } - - async fn run_socket( - socket: WsStream, - mut commands: mpsc::Receiver, - lifecycle: &ConnectionOwner, - event_tx: &mpsc::UnboundedSender, - generation: u64, - ) { - let (mut writer, mut reader) = socket.split(); - // Keep full-duplex progress under backpressure, but keep both futures - // inside this owner. Either failure drops both halves and the old queue. - let read = async { - loop { - match await_relay_inbound(reader.next()).await { - Ok(Some(Ok(Message::Text(text)))) => match serde_json::from_str(&text) { - Ok(msg) => Self::dispatch(msg, event_tx, lifecycle, generation).await, - Err(error) => warn!("Unparseable relay message: {error}"), + match event { + Incoming::RpcRequest(event) => { + state + .replies + .retain(|_, reply| reply.deadline > Instant::now()); + let value = event.payload.0; + let Some(source) = value["sourceDeviceId"].as_str() else { + return false; + }; + let Some(data) = value["params"]["encrypted_data"].as_str() else { + return false; + }; + let Some(nonce) = value["params"]["nonce"].as_str() else { + return false; + }; + let correlation = format!("rpc-{}", uuid::Uuid::new_v4()); + state.replies.insert( + correlation.clone(), + Reply { + id: event.id, + deadline: reply_deadline(&value), + source: source.into(), }, - Ok(Some(Ok(Message::Close(_)))) | Ok(None) => break, - Ok(Some(Err(error))) => { - warn!("Relay WebSocket read failed: {error}"); - break; - } - Err(()) => { - warn!("Relay inbound traffic timed out"); - break; + ); + events + .try_send(RelayEvent::DeviceMessageReceived { + source_device_id: source.into(), + correlation_id: correlation, + encrypted_data: data.into(), + nonce: nonce.into(), + }) + .is_ok() + } + Incoming::Ephemeral(event) => { + let value = event.payload.0; + if value["type"] == "device-presence" { + match serde_json::from_value(value["devices"].clone()) { + Ok(devices) => events + .try_send(RelayEvent::DevicePresence { devices }) + .is_ok(), + Err(_) => false, } - _ => {} + } else if value["type"] == "device-event" { + let (Some(source), Some(data), Some(nonce)) = ( + value["sourceDeviceId"].as_str(), + value["params"]["encrypted_data"].as_str(), + value["params"]["nonce"].as_str(), + ) else { + return false; + }; + events + .try_send(RelayEvent::DeviceMessageReceived { + source_device_id: source.into(), + correlation_id: String::new(), + encrypted_data: data.into(), + nonce: nonce.into(), + }) + .is_ok() + } else { + true } } - }; - let write = async { - let period = std::time::Duration::from_secs(30); - let mut heartbeat = - tokio::time::interval_at(tokio::time::Instant::now() + period, period); - heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - while let Some(command) = next_relay_outbound(&mut commands, &mut heartbeat).await { - if let Err(error) = write_relay_message(&mut writer, &command).await { - warn!("Relay WebSocket write failed: {error}"); - break; + Incoming::Update(event) => { + if let Some(sid) = event.payload.0["body"]["sid"].as_str() { + events + .try_send(RelayEvent::SessionUpdated { + relay_session_id: sid.to_owned(), + message: event.payload.0["body"]["message"].clone(), + }) + .is_ok() + } else { + true } } - }; - let _ = futures::future::select(std::pin::pin!(read), std::pin::pin!(write)).await; - } - - async fn dispatch( - msg: RelayMessage, - event_tx: &mpsc::UnboundedSender, - lifecycle: &ConnectionOwner, - generation: u64, - ) { - let mut owner = lifecycle.lock().unwrap(); - if owner.generation != generation { - return; - } - match msg { - RelayMessage::HeartbeatAck => { - debug!("Heartbeat acknowledged"); - } - RelayMessage::Error { message } => { - error!("Relay error: {message}"); - let _ = event_tx.send(RelayEvent::Error { message }); - } - RelayMessage::AuthOk { user_id, device_id } => { - info!("Account auth-connect ok: user_id={user_id}"); - let _ = event_tx.send(RelayEvent::AuthOk { user_id, device_id }); - } - RelayMessage::AuthError { message } => { - warn!("Account auth-connect failed: {message}"); - let _ = event_tx.send(RelayEvent::AuthError { message }); - } - RelayMessage::IncomingDeviceMessage { - source_device_id, - correlation_id, - encrypted_data, - nonce, - } => { - debug!("DeviceMessage from {source_device_id} corr={correlation_id}"); - let _ = event_tx.send(RelayEvent::DeviceMessageReceived { - source_device_id, - correlation_id, - encrypted_data, - nonce, - }); - } - RelayMessage::DevicePresence { devices } => { - debug!("DevicePresence: {} online", devices.len()); - let _ = event_tx.send(RelayEvent::DevicePresence { devices }); - } - _ => {} + Incoming::AuthOk(_) | Incoming::Registered(_) => true, } } - - pub async fn send(&self, msg: RelayMessage) -> Result<()> { - let owner = self.lifecycle.lock().unwrap(); - Self::enqueue(&owner, msg) - } - - fn enqueue(owner: &ConnectionLifecycle, msg: RelayMessage) -> Result<()> { - if owner.state != ConnectionState::Connected { - return Err(anyhow!("Relay transport is not connected")); - } - let tx = owner - .cmd_tx - .as_ref() - .ok_or_else(|| anyhow!("Relay transport is not connected"))?; - tx.try_send(msg).map_err(|error| match error { - mpsc::error::TrySendError::Full(_) => { - anyhow!("Relay send queue is full; request was not queued") - } - mpsc::error::TrySendError::Closed(_) => anyhow!("Relay connection is closed"), - }) - } - - pub async fn connect_authenticated(&self, token: &str, device_name: &str) -> Result<()> { - let mut owner = self.lifecycle.lock().unwrap(); - // Only desktops hold a relay WebSocket — phones and watches talk HTTP — - // so the kind is a constant here rather than a parameter. - Self::enqueue( - &owner, - RelayMessage::AuthConnect { - token: token.to_string(), - device_name: device_name.to_string(), - device_kind: "desktop".to_string(), - }, - )?; - if let Some(ctx) = owner.reconnect_ctx.as_mut() { - ctx.token = token.to_string(); - ctx.device_name = device_name.to_string(); - } - Ok(()) - } - - /// Submit device payloads over memory-admitted HTTP. The WebSocket remains - /// the receiving/control channel and does not accept attachment-sized input. - pub async fn send_device_message( + pub async fn request_device( &self, - target_device_id: &str, - correlation_id: &str, + target: &str, encrypted_data: &str, nonce: &str, - ) -> Result<()> { - let context = self - .lifecycle + ) -> Result<(String, String)> { + let sender = self + .owner .lock() .unwrap() - .reconnect_ctx + .sender .clone() - .filter(|context| !context.token.is_empty()) - .ok_or_else(|| anyhow!("Authenticated relay connection is unavailable"))?; - let endpoint = device_message_endpoint(&context.ws_url, target_device_id)?; - let response = super::relay_http::relay_http_client() - .post(endpoint) - .bearer_auth(&context.token) - .timeout(RELAY_WRITE_TIMEOUT) - .json(&RelayMessage::DeviceMessage { - target_device_id: target_device_id.to_string(), - correlation_id: correlation_id.to_string(), - encrypted_data: encrypted_data.to_string(), - nonce: nonce.to_string(), - }) - .send() + .ok_or_else(|| anyhow!("Relay is disconnected; request was not submitted"))?; + let result = sender + .call( + target, + json!({"encrypted_data":encrypted_data,"nonce":nonce}), + ) .await?; - if response.status() != reqwest::StatusCode::NO_CONTENT { - return Err(anyhow!( - "Relay device message rejected (HTTP {})", - response.status() - )); + let data = result["encrypted_data"] + .as_str() + .ok_or_else(|| anyhow!("RPC response missing encrypted data"))?; + let nonce = result["nonce"] + .as_str() + .ok_or_else(|| anyhow!("RPC response missing nonce"))?; + Ok((data.into(), nonce.into())) + } + pub async fn send_device_message( + &self, + target: &str, + correlation: &str, + encrypted_data: &str, + nonce: &str, + ) -> Result<()> { + let (sender, reply) = { + let mut state = self.owner.lock().unwrap(); + let sender = state + .sender + .clone() + .ok_or_else(|| anyhow!("Relay is disconnected"))?; + (sender, state.replies.remove(correlation)) + }; + if let Some(reply) = reply { + if reply.source != target || reply.deadline <= Instant::now() { + bail!("RPC response owner expired"); + } + sender + .respond( + reply.id, + json!({"encrypted_data":encrypted_data,"nonce":nonce}), + ) + .await + } else { + if correlation.starts_with("rpc-") { + bail!("RPC acknowledgement is no longer pending"); + } + sender + .event( + target, + json!({"encrypted_data":encrypted_data,"nonce":nonce}), + ) + .await } - Ok(()) } - pub async fn disconnect(&self) { let task = { - let mut owner = self.lifecycle.lock().unwrap(); - owner.generation += 1; - owner.state = ConnectionState::Disconnected; - owner.cmd_tx = None; - owner.reconnect_ctx = None; - let task = owner.task.take(); - if let Some(task) = &task { - task.abort(); - } - let _ = self.event_tx.send(RelayEvent::Disconnected); - task + let mut state = self.owner.lock().unwrap(); + state.epoch += 1; + state.sender = None; + state.replies.clear(); + state.state = ConnectionState::Disconnected; + state.task.take() }; - // The supervisor owns every socket/timer/future; joining cancellation - // releases them before returning, including an in-progress handshake. if let Some(task) = task { + task.abort(); let _ = task.await; } - info!("Relay client disconnected"); - } -} - -fn device_message_endpoint(ws_url: &str, target_device_id: &str) -> Result { - if target_device_id.is_empty() - || target_device_id.len() > 128 - || !target_device_id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) - || matches!(target_device_id, "." | "..") - { - return Err(anyhow!("Invalid relay target device id")); - } - let mut url = reqwest::Url::parse(ws_url)?; - let scheme = match url.scheme() { - "wss" => "https", - "ws" => "http", - _ => return Err(anyhow!("Invalid relay WebSocket scheme")), - }; - url.set_scheme(scheme) - .map_err(|_| anyhow!("Invalid relay HTTP scheme"))?; - if !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - { - return Err(anyhow!("Invalid relay WebSocket endpoint")); } - let base = url - .path() - .strip_suffix("/ws") - .ok_or_else(|| anyhow!("Invalid relay WebSocket path"))?; - let path = format!("{base}/api/devices/{target_device_id}/messages"); - url.set_path(&path); - Ok(url) } - impl Drop for RelayClient { fn drop(&mut self) { - let mut owner = self.lifecycle.lock().unwrap(); - owner.generation += 1; - owner.state = ConnectionState::Disconnected; - owner.cmd_tx = None; - owner.reconnect_ctx = None; - if let Some(task) = owner.task.take() { + if let Some(task) = self.owner.lock().unwrap().task.take() { task.abort(); } } } -async fn next_relay_outbound( - commands: &mut mpsc::Receiver, - heartbeat: &mut tokio::time::Interval, -) -> Option { - let received = std::pin::pin!(commands.recv()); - let tick = std::pin::pin!(heartbeat.tick()); - // select polls its first future first. A continuously ready command queue - // must not starve the keepalive that preserves device presence and inbound health. - match futures::future::select(tick, received).await { - futures::future::Either::Left(_) => Some(RelayMessage::Heartbeat), - futures::future::Either::Right((command, _)) => command, - } -} - -async fn write_relay_message(socket: &mut S, message: &RelayMessage) -> Result<()> -where - S: futures::Sink + Unpin, -{ - let json = serde_json::to_string(message)?; - tokio::time::timeout(RELAY_WRITE_TIMEOUT, socket.send(Message::Text(json.into()))) - .await - .map_err(|_| anyhow!("Relay WebSocket write timed out"))??; - Ok(()) -} - -async fn dial(ws_url: &str) -> Result { - // Ensure CryptoProvider is installed before any rustls TLS handshake. - // Startup already calls this; calling again is a no-op once installed and - // protects reconnect / late-init paths. - ensure_rustls_crypto_provider(); - - let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() - .max_message_size(Some(64 * 1024 * 1024)) - .max_frame_size(Some(64 * 1024 * 1024)) - .max_write_buffer_size(64 * 1024 * 1024); - - #[cfg(windows)] - { - await_dial(ws_url, async move { - let request = ws_url - .into_client_request() - .map_err(|e| anyhow!("dial {ws_url}: build request failed: {e}"))?; - - // Wrap TLS connector construction in catch_unwind so that a panic - // (e.g. duplicate CryptoProvider install) is converted to an error - // instead of unwinding the tokio task and potentially crashing the - // process. - let connector = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - build_windows_rustls_connector() - })) - .map_err(|_| anyhow!("dial {ws_url}: TLS connector construction panicked"))??; - - let (stream, _) = tokio_tungstenite::connect_async_tls_with_config( - request, - Some(config), - false, - Some(connector), - ) - .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; - Ok(stream) - }) - .await - } - - #[cfg(not(windows))] - { - // Non-Windows uses tokio-tungstenite's built-in rustls connector. - // CryptoProvider must already be installed (see ensure_rustls_crypto_provider). - await_dial(ws_url, async move { - let (stream, _) = - tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) - .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; - Ok(stream) - }) - .await - } -} - -async fn await_dial(ws_url: &str, dial_future: F) -> Result -where - F: std::future::Future>, -{ - tokio::time::timeout(RELAY_DIAL_TIMEOUT, dial_future) - .await - .map_err(|_| { - anyhow!( - "dial {ws_url}: connection timed out after {} seconds", - RELAY_DIAL_TIMEOUT.as_secs() - ) - })? -} - -async fn await_relay_inbound(inbound_future: F) -> std::result::Result -where - F: std::future::Future, -{ - tokio::time::timeout(RELAY_INBOUND_IDLE_TIMEOUT, inbound_future) - .await - .map_err(|_| ()) +fn reply_deadline(payload: &serde_json::Value) -> Instant { + let timeout = payload["timeoutMs"] + .as_u64() + .filter(|value| *value > 0) + .unwrap_or(120_000); + let now = Instant::now(); + now.checked_add(Duration::from_millis(timeout)) + .unwrap_or(now) } #[cfg(test)] mod tests { - #[test] - fn device_http_endpoint_preserves_version_prefix_and_rejects_path_injection() { - assert_eq!( - super::device_message_endpoint("wss://remote.example/v/1.0.0/ws", "desktop-1") - .unwrap() - .as_str(), - "https://remote.example/v/1.0.0/api/devices/desktop-1/messages" - ); - assert_eq!( - super::device_message_endpoint("ws://127.0.0.1:3000/ws", "desktop") - .unwrap() - .as_str(), - "http://127.0.0.1:3000/api/devices/desktop/messages" - ); - for id in [ - "", - ".", - "..", - "../other", - "device?x=1", - "%2f", - "device#fragment", + #[tokio::test] + async fn base_endpoint_preserves_proxy_prefix_without_legacy_ws_conversion() { + let (client, _events) = super::RelayClient::new(); + for (input, expected) in [ + ("https://relay.example/", "https://relay.example/"), + ( + "https://relay.example/v/1.0.1/", + "https://relay.example/v/1.0.1", + ), + ( + "http://127.0.0.1:9700/prefix", + "http://127.0.0.1:9700/prefix", + ), ] { - assert!(super::device_message_endpoint("wss://remote.example/ws", id).is_err()); + client.connect(input).await.unwrap(); + assert_eq!(client.owner.lock().unwrap().url.as_deref(), Some(expected)); } - for url in [ - "https://remote.example/ws", - "wss://user@remote.example/ws", - "wss://remote.example/ws?token=x", - "wss://remote.example/wrong", + for invalid in [ + "ws://relay.example/ws", + "https://user:pass@relay.example", + "https://relay.example/?token=x", ] { - assert!(super::device_message_endpoint(url, "desktop").is_err()); + assert!(client.connect(invalid).await.is_err()); } } use super::*; - - async fn connected_fixture() -> ( - RelayClient, - mpsc::UnboundedReceiver, - tokio::net::TcpListener, - tokio_tungstenite::WebSocketStream, - ) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("ws://{}/ws", listener.local_addr().unwrap()); - let (client, events) = RelayClient::new(); - let (connected, socket) = tokio::join!(client.connect(&url), async { - let (stream, _) = listener.accept().await.unwrap(); - tokio_tungstenite::accept_async(stream).await.unwrap() - }); - connected.unwrap(); - (client, events, listener, socket) - } - - #[tokio::test] - async fn device_payload_uses_authenticated_http_and_reports_rejection() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let (client, _events, listener, _socket) = connected_fixture().await; - client - .connect_authenticated("fixture-token", "Desktop") - .await - .unwrap(); - let server = tokio::spawn(async move { - for status in ["204 No Content", "503 Service Unavailable"] { - let (mut stream, _) = listener.accept().await.unwrap(); - let mut bytes = Vec::new(); - let (header_end, length) = loop { - let mut buffer = [0u8; 8192]; - assert!(bytes.len() < 1024 * 1024); - let count = stream.read(&mut buffer).await.unwrap(); - assert!(count > 0); - bytes.extend_from_slice(&buffer[..count]); - if let Some(end) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { - let header = String::from_utf8_lossy(&bytes[..end]).to_lowercase(); - assert!( - header.starts_with("post /api/devices/controller/messages http/1.1") - ); - assert!(header.contains("authorization: bearer fixture-token")); - let length: usize = header - .lines() - .find_map(|line| line.strip_prefix("content-length:")) - .unwrap() - .trim() - .parse() - .unwrap(); - break (end + 4, length); - } - }; - while bytes.len() < header_end + length { - let mut buffer = [0u8; 8192]; - let count = stream.read(&mut buffer).await.unwrap(); - assert!(count > 0); - bytes.extend_from_slice(&buffer[..count]); - } - let body: serde_json::Value = - serde_json::from_slice(&bytes[header_end..header_end + length]).unwrap(); - assert_eq!(body["encrypted_data"].as_str().unwrap().len(), 256 * 1024); - assert_eq!(body["correlation_id"], "correlation"); - let reply = - format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); - stream.write_all(reply.as_bytes()).await.unwrap(); - } - }); - let payload = "a".repeat(256 * 1024); - client - .send_device_message("controller", "correlation", &payload, "nonce") - .await - .unwrap(); - let error = client - .send_device_message("controller", "correlation", &payload, "nonce") - .await - .unwrap_err(); - assert!(error.to_string().contains("503")); - server.await.unwrap(); - client.disconnect().await; - } - - #[tokio::test] - async fn failed_initial_dial_returns_to_disconnected() { - let (client, _) = RelayClient::new(); - assert!(client.connect("invalid://relay").await.is_err()); - assert_eq!( - client.connection_state().await, - ConnectionState::Disconnected - ); - assert!(client.send(RelayMessage::Heartbeat).await.is_err()); - } - - #[tokio::test] - async fn disconnect_closes_the_socket_without_waiting_for_inbound_timeout() { - let (client, _, _listener, mut socket) = connected_fixture().await; - client.disconnect().await; - let closed = tokio::time::timeout(std::time::Duration::from_millis(500), socket.next()) - .await - .expect("disconnect must release the socket promptly"); - assert!(!matches!(closed, Some(Ok(Message::Text(_))))); - } - - #[tokio::test] - async fn dropping_client_closes_its_socket() { - let (client, _, _listener, mut socket) = connected_fixture().await; - drop(client); - tokio::time::timeout(std::time::Duration::from_millis(500), socket.next()) - .await - .expect("dropping the owner must stop its transport tasks"); - } - - #[tokio::test] - async fn disconnect_during_backoff_does_not_reconnect() { - let (client, _, listener, mut socket) = connected_fixture().await; - socket.close(None).await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(1), async { - while client.connection_state().await != ConnectionState::Reconnecting { - tokio::task::yield_now().await; - } - }) - .await - .unwrap(); - // Let the reconnect task enter its first backoff before disconnecting. - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - client.disconnect().await; - assert!( - tokio::time::timeout(std::time::Duration::from_millis(2200), listener.accept()) - .await - .is_err(), - "a disconnected owner must not dial again" - ); - assert_eq!( - client.connection_state().await, - ConnectionState::Disconnected - ); - } - - #[tokio::test] - async fn disconnect_cancels_an_in_progress_handshake() { - use tokio::io::AsyncReadExt; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("ws://{}/ws", listener.local_addr().unwrap()); - let (client, _) = RelayClient::new(); - let client = Arc::new(client); - let connecting_client = client.clone(); - let connecting = tokio::spawn(async move { connecting_client.connect(&url).await }); - let (mut socket, _) = listener.accept().await.unwrap(); - // Never answer the HTTP upgrade. Disconnect must cancel the dial too. - client.disconnect().await; - assert!(connecting.await.unwrap().is_err()); - let mut bytes = Vec::new(); - tokio::time::timeout( - std::time::Duration::from_millis(500), - socket.read_to_end(&mut bytes), - ) - .await - .unwrap() - .unwrap(); - assert_eq!( - client.connection_state().await, - ConnectionState::Disconnected - ); - } - - #[tokio::test] - async fn replacement_connection_retires_the_old_socket_and_preserves_the_new_one() { - let (client, _, _old_listener, mut old_socket) = connected_fixture().await; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("ws://{}/ws", listener.local_addr().unwrap()); - let (result, mut socket) = tokio::join!(client.connect(&url), async { - tokio_tungstenite::accept_async(listener.accept().await.unwrap().0) - .await - .unwrap() - }); - result.unwrap(); - tokio::time::timeout(std::time::Duration::from_millis(500), old_socket.next()) - .await - .expect("superseded socket must close"); - client.send(RelayMessage::Heartbeat).await.unwrap(); - let message = tokio::time::timeout(std::time::Duration::from_secs(1), socket.next()) - .await - .unwrap() - .unwrap() - .unwrap(); - assert!(matches!( - serde_json::from_str::(&message.into_text().unwrap()).unwrap(), - RelayMessage::Heartbeat - )); - assert_eq!(client.connection_state().await, ConnectionState::Connected); - client.disconnect().await; - } - - #[tokio::test] - async fn reconnect_authenticates_account_before_new_commands() { - let (client, _events, listener, mut socket) = connected_fixture().await; - client - .connect_authenticated("test-token", "test-device") - .await - .unwrap(); - socket.next().await.unwrap().unwrap(); - socket.close(None).await.unwrap(); - let mut replacement = tokio::time::timeout(std::time::Duration::from_secs(5), async { - tokio_tungstenite::accept_async(listener.accept().await.unwrap().0) - .await - .unwrap() - }) - .await - .unwrap(); - let auth: RelayMessage = serde_json::from_str( - &replacement - .next() - .await - .unwrap() - .unwrap() - .into_text() - .unwrap(), - ) - .unwrap(); - assert!( - matches!(auth, RelayMessage::AuthConnect { token, device_name, .. } - if token == "test-token" && device_name == "test-device") - ); - client.disconnect().await; - } - - #[tokio::test] - async fn full_outbound_queue_rejects_without_leaking_the_payload() { - let (client, _) = RelayClient::new(); - let (tx, _rx) = mpsc::channel(1); - { - let mut owner = client.lifecycle.lock().unwrap(); - owner.state = ConnectionState::Connected; - owner.cmd_tx = Some(tx); - owner.reconnect_ctx = Some(ReconnectCtx { - token: "accepted-token".into(), - ..Default::default() - }); - } - client.send(RelayMessage::Heartbeat).await.unwrap(); - let error = client - .send(RelayMessage::AuthConnect { - token: "must-not-appear-in-error".into(), - device_name: "device".into(), - device_kind: "desktop".into(), - }) - .await - .unwrap_err(); - assert_eq!( - error.to_string(), - "Relay send queue is full; request was not queued" - ); - assert!(client - .connect_authenticated("rejected-token", "device") - .await - .is_err()); - let owner = client.lifecycle.lock().unwrap(); - let ctx = owner.reconnect_ctx.as_ref().unwrap(); - assert_eq!(ctx.token, "accepted-token"); - } - - #[tokio::test(start_paused = true)] - async fn heartbeat_deadline_is_not_starved_by_queued_commands() { - let (tx, mut commands) = mpsc::channel(2); - let period = std::time::Duration::from_secs(30); - let mut heartbeat = tokio::time::interval_at(tokio::time::Instant::now() + period, period); - heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - for id in ["first", "second"] { - tx.try_send(RelayMessage::AuthConnect { - token: id.into(), - device_name: "test-device".into(), - device_kind: "desktop".into(), - }) - .unwrap(); - } - tokio::time::advance(period).await; - assert!( - matches!( - next_relay_outbound(&mut commands, &mut heartbeat).await, - Some(RelayMessage::Heartbeat) - ), - "a due heartbeat must progress even while the command queue is full" - ); - for expected in ["first", "second"] { - assert!(matches!( - next_relay_outbound(&mut commands, &mut heartbeat).await, - Some(RelayMessage::AuthConnect { token, .. }) if token == expected - )); - } - drop(tx); - assert!(next_relay_outbound(&mut commands, &mut heartbeat) - .await - .is_none()); - } - - #[tokio::test(start_paused = true)] - async fn stalled_writes_are_bounded() { - let mut sink = Box::pin(futures::sink::unfold((), |_, _: Message| async { - std::future::pending::>() - .await - })); - let error = write_relay_message(&mut sink, &RelayMessage::Heartbeat) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "Relay WebSocket write timed out"); - } - - #[tokio::test(start_paused = true)] - async fn dial_timeout_bounds_a_pending_connection_attempt() { - let result = await_dial( - "wss://relay.example.invalid/ws", - std::future::pending::>(), - ) - .await; - - let error = result.expect_err("pending dial must be bounded by the connection timeout"); - assert_eq!( - error.to_string(), - "dial wss://relay.example.invalid/ws: connection timed out after 15 seconds" - ); - } - - #[tokio::test] - async fn dial_timeout_preserves_connection_errors() { - let result = await_dial::<(), _>( - "wss://relay.example.invalid/ws", - std::future::ready(Err(anyhow!("dial failed before timeout"))), - ) - .await; - - assert_eq!( - result.expect_err("dial error must be returned").to_string(), - "dial failed before timeout" - ); - } - - #[tokio::test(start_paused = true)] - async fn inbound_idle_timeout_detects_a_half_open_socket() { - let result = await_relay_inbound(std::future::pending::<()>()).await; - assert!(result.is_err(), "an idle relay stream must time out"); - } -} - -#[cfg(windows)] -fn build_windows_rustls_connector() -> Result { - openbitfun_services_core::tls_provider::ensure_ring_crypto_provider(); - - let mut root_store = rustls::RootCertStore::empty(); - - let native_certs = rustls_native_certs::load_native_certs(); - if !native_certs.errors.is_empty() { - warn!( - "Windows native root certificate loading errors: {:?}", - native_certs.errors - ); - } - let (added, ignored) = root_store.add_parsable_certificates(native_certs.certs); - debug!( - "Loaded current-user Windows root certificates, added={}, ignored={}", - added, ignored - ); - - if let Ok(local_machine_root) = schannel::cert_store::CertStore::open_local_machine("ROOT") { - let local_machine_der_certs = local_machine_root - .certs() - .map(|cert| rustls::pki_types::CertificateDer::from(cert.to_der().to_vec())) - .collect::>(); - let total = local_machine_der_certs.len(); - let (added, ignored) = root_store.add_parsable_certificates(local_machine_der_certs); - debug!( - "Loaded local-machine Windows root certificates, total={}, added={}, ignored={}", - total, added, ignored - ); - } else { - warn!("Failed to open local-machine Windows ROOT certificate store"); - } - - if root_store.is_empty() { - return Err(anyhow!( - "No trusted Windows root certificates available for relay connection" - )); + #[test] + fn reply_deadline_respects_forwarded_timeout() { + let now = Instant::now(); + assert!(reply_deadline(&json!({})) >= now + Duration::from_secs(120)); + assert!(reply_deadline(&json!({"timeoutMs":240_000})) >= now + Duration::from_secs(240)); + assert!(reply_deadline(&json!({"timeoutMs":1000})) < now + Duration::from_secs(2)); } - - let client_config = rustls::ClientConfig::builder() - .with_root_certificates(root_store) - .with_no_client_auth(); - - Ok(Connector::Rustls(std::sync::Arc::new(client_config))) } diff --git a/src/crates/services/services-integrations/src/remote_connect/session_log.rs b/src/crates/services/services-integrations/src/remote_connect/session_log.rs new file mode 100644 index 0000000000..4a24ae4335 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/session_log.rs @@ -0,0 +1,968 @@ +//! Durable encrypted session publication. Network acknowledgements advance only +//! the upload cursor; the local journal remains available after restart. +use super::encryption::{decrypt_from_base64, encrypt_to_base64}; +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use fs2::FileExt; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PendingMessage { + pub local_id: String, + pub content: String, +} + +#[derive(Serialize, Deserialize)] +struct Ciphertext { + nonce: String, + data: String, +} + +#[derive(Serialize, Deserialize)] +struct BoundEvent { + session_id: String, + event: String, + payload: serde_json::Value, +} + +/// One local owner per (account, machine, session). Files are private and keys +/// are randomly generated, never derived from public account identifiers. +#[derive(Clone)] +pub struct SessionLog { + directory: PathBuf, + session_id: String, +} + +impl SessionLog { + pub fn open(root: &Path, account: &str, machine: &str, session: &str) -> Result { + let binding = serde_json::to_vec(&(account, machine, session))?; + let directory = root.join(format!("{:x}", Sha256::digest(&binding))); + fs::create_dir_all(&directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + } + let log = Self { + directory, + session_id: session.to_owned(), + }; + let _lock = log.lock()?; + if !log.directory.join("key").exists() { + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + log.atomic_write("key", &key)?; + } + log.key()?; + if !log.directory.join("identity").exists() { + log.atomic_write("identity", &binding)?; + } + Ok(log) + } + + pub fn for_host(account: &str, machine: &str, session: &str) -> Result { + let root = super::product_home_dir() + .context("product home unavailable")? + .join("relay-session-log"); + Self::open(&root, account, machine, session) + } + + /// Grant lookup never creates a session or repairs missing data. + pub fn existing_for_host(account: &str, machine: &str, session: &str) -> Result { + let root = super::product_home_dir() + .context("product home unavailable")? + .join("relay-session-log"); + let binding = serde_json::to_vec(&(account, machine, session))?; + let directory = root.join(format!("{:x}", Sha256::digest(&binding))); + if fs::read(directory.join("identity")).context("session log unavailable")? != binding { + bail!("session log identity mismatch"); + } + let log = Self { + directory, + session_id: session.to_owned(), + }; + log.key()?; + Ok(log) + } + + fn lock(&self) -> Result { + let file = private_file(&self.directory.join("lock"), false)?; + file.lock_exclusive()?; + Ok(file) + } + + fn key(&self) -> Result<[u8; 32]> { + let bytes = fs::read(self.directory.join("key"))?; + bytes + .try_into() + .map_err(|_| anyhow::anyhow!("invalid session encryption key; journal preserved")) + } + + pub fn relay_session_id(&self) -> String { + self.directory + .file_name() + .expect("session log directory has an identity") + .to_string_lossy() + .into_owned() + } + + /// Call only inside an authenticated, end-to-end encrypted peer response. + pub fn key_grant(&self) -> Result { + Ok(STANDARD.encode(self.key()?)) + } + + /// Commit before notifying the uploader. No network operation holds the + /// filesystem lock. A stable ID and ciphertext survive uncertain ACKs. + pub fn append(&self, event: &str, payload: serde_json::Value) -> Result { + self.append_batch(vec![(event.to_owned(), payload)])? + .pop() + .context("empty journal append") + } + + /// A whole producer microbatch commits with one atomic segment rename. + /// A crash exposes either every record in the batch or none of them. + pub fn append_batch( + &self, + events: Vec<(String, serde_json::Value)>, + ) -> Result> { + if events.is_empty() { + return Ok(Vec::new()); + } + let _lock = self.lock()?; + self.append_batch_locked(events) + } + + fn append_batch_locked( + &self, + events: Vec<(String, serde_json::Value)>, + ) -> Result> { + let start = self + .last_sequence()? + .checked_add(1) + .context("session sequence exhausted")?; + let end = start + .checked_add(events.len() as u64 - 1) + .context("session sequence exhausted")?; + let key = self.key()?; + let mut messages = Vec::with_capacity(events.len()); + for (index, (event, mut payload)) in events.into_iter().enumerate() { + if event == "session-record" { + payload["revision"] = serde_json::json!(start + index as u64); + } + let plaintext = serde_json::to_string(&BoundEvent { + session_id: self.session_id.clone(), + event, + payload, + })?; + let (data, nonce) = encrypt_to_base64(&key, &plaintext)?; + messages.push(PendingMessage { + local_id: format!("{:020}", start + index as u64), + content: serde_json::to_string(&Ciphertext { nonce, data })?, + }); + } + self.atomic_write( + &format!("{start:020}-{end:020}.batch"), + &serde_json::to_vec(&messages)?, + )?; + Ok(messages) + } + + /// The caller serializes source reads with this session's publication owner. + /// An index failure after a log commit can produce a duplicate on retry, but + /// cannot lose a record; stable IDs/revisions make that duplicate harmless. + pub fn append_records(&self, records: Vec, full: bool) -> Result<()> { + let _lock = self.lock()?; + let index_path = self.directory.join("record-index"); + let mut index: std::collections::BTreeMap = + match fs::read(&index_path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .context("invalid record index; journal preserved")?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Default::default(), + Err(error) => return Err(error.into()), + }; + let mut changed = Vec::new(); + let mut present = std::collections::BTreeSet::new(); + let mut scope = std::collections::BTreeSet::new(); + for record in records { + let id = record["id"] + .as_str() + .context("record identity missing")? + .to_owned(); + present.insert(id.clone()); + let turn = record["turn"]["turnId"] + .as_str() + .context("record turn identity missing")? + .to_owned(); + scope.insert(turn.clone()); + if record["sessionId"].as_str() != Some(self.session_id.as_str()) { + bail!("record session identity mismatch"); + } + // Parent status changes get their own small header record; they + // must not re-upload every unchanged large tool body. + let body = record + .get("item") + .or_else(|| record.get("round")) + .unwrap_or(&record["turn"]); + let hash = format!("{:x}", Sha256::digest(serde_json::to_vec(body)?)); + let old_hash = index + .get(&id) + .and_then(|entry| entry.as_str().or_else(|| entry["hash"].as_str())); + if old_hash != Some(hash.as_str()) { + index.insert(id, serde_json::json!({"hash":hash,"turn":turn})); + changed.push(("session-record".into(), record)); + } + } + let removed: Vec<_> = index + .iter() + .filter(|(id, entry)| { + !present.contains(*id) + && (full + || entry["turn"] + .as_str() + .is_some_and(|turn| scope.contains(turn))) + }) + .map(|(id, _)| id.clone()) + .collect(); + for id in removed { + index.remove(&id); + changed.push(( + "session-record".into(), + serde_json::json!({"sessionId":self.session_id,"id":id,"deleted":true}), + )); + } + if changed.is_empty() { + return Ok(()); + } + self.append_batch_locked(changed)?; + self.atomic_write("record-index", &serde_json::to_vec(&index)?) + } + + fn segments(&self) -> Result> { + let mut segments = Vec::new(); + for entry in fs::read_dir(&self.directory)? { + let entry = entry?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if let Some(sequence) = name + .strip_suffix(".json") + .and_then(|n| n.parse::().ok()) + { + segments.push((sequence, sequence, entry.path())); + } else if let Some((start, end)) = + name.strip_suffix(".batch").and_then(|n| n.split_once('-')) + { + let (start, end) = (start.parse::()?, end.parse::()?); + if start == 0 || end < start { + bail!("invalid journal segment; journal preserved"); + } + segments.push((start, end, entry.path())); + } + } + segments.sort_by_key(|(start, _, _)| *start); + Ok(segments) + } + + fn last_sequence(&self) -> Result { + Ok(self + .segments()? + .iter() + .map(|(_, end, _)| *end) + .max() + .unwrap_or(0)) + } + + fn acknowledged(&self) -> Result { + match fs::read_to_string(self.directory.join("uploaded")) { + Ok(value) => Ok(value + .parse() + .context("invalid upload cursor; journal preserved")?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(error) => Err(error.into()), + } + } + + /// Small batches bound memory, not journal size. The caller drains all + /// batches, so a large session does not fail because of an arbitrary quota. + pub fn pending(&self, batch_size: usize) -> Result> { + let _lock = self.lock()?; + self.read_after(self.acknowledged()?, batch_size) + } + + fn read_after(&self, cursor: u64, batch_size: usize) -> Result> { + if batch_size == 0 { + return Ok(Vec::new()); + } + let mut messages = Vec::new(); + let mut next = cursor + .checked_add(1) + .context("session sequence exhausted")?; + for (start, end, path) in self.segments()? { + if end < next { + continue; + } + if start > next { + bail!("session journal has a missing record; journal preserved"); + } + let bytes = fs::read(&path)?; + let batch: Vec = if path.extension().is_some_and(|e| e == "json") { + vec![serde_json::from_slice(&bytes)?] + } else { + serde_json::from_slice(&bytes)? + }; + if batch.len() as u64 != end - start + 1 { + bail!("incomplete journal batch; journal preserved"); + } + for (index, message) in batch.into_iter().enumerate() { + let sequence = start + index as u64; + if message.local_id != format!("{sequence:020}") { + bail!("journal message identity mismatch"); + } + if sequence < next { + continue; + } + messages.push(message); + next += 1; + if messages.len() == batch_size { + return Ok(messages); + } + } + } + Ok(messages) + } + + /// ACK exactly the submitted prefix, never accept a receive cursor or an + /// unrelated server sequence as proof that an upload succeeded. + pub fn acknowledge(&self, messages: &[PendingMessage]) -> Result<()> { + let _lock = self.lock()?; + let cursor = self.acknowledged()?; + let mut fresh = Vec::new(); + for message in messages { + let sequence: u64 = message.local_id.parse()?; + if sequence > cursor { + fresh.push(message.clone()); + } + } + if fresh.is_empty() { + return Ok(()); + } + if self.read_after(cursor, fresh.len())? != fresh { + bail!("upload acknowledgement does not match contiguous journal prefix"); + } + let next = fresh + .last() + .context("empty acknowledgement")? + .local_id + .parse::()?; + self.atomic_write("uploaded", next.to_string().as_bytes()) + } + + pub fn decrypt(&self, content: &str) -> Result<(String, serde_json::Value)> { + let cipher: Ciphertext = serde_json::from_str(content)?; + let event: BoundEvent = serde_json::from_str(&decrypt_from_base64( + &self.key()?, + &cipher.data, + &cipher.nonce, + )?)?; + if event.session_id != self.session_id { + bail!("session encryption binding mismatch"); + } + Ok((event.event, event.payload)) + } + + fn atomic_write(&self, name: &str, bytes: &[u8]) -> Result<()> { + let temporary = self + .directory + .join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let mut file = private_file(&temporary, true)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, self.directory.join(name))?; + #[cfg(unix)] + File::open(&self.directory)?.sync_all()?; + Ok(()) + } +} + +fn private_file(path: &Path, exclusive: bool) -> Result { + let mut options = OpenOptions::new(); + options.write(true).read(true); + if exclusive { + options.create_new(true); + } else { + options.create(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + Ok(options.open(path)?) +} + +/// HTTP is the durable data lane; Socket.IO notifications wake readers. One +/// drain owns upload order. Call again after a network error with the same log. +pub async fn upload_pending( + log: &SessionLog, + client: &reqwest::Client, + relay: &str, + token: &str, +) -> Result { + upload_round(log, client, relay, token, true).await +} + +async fn upload_round( + log: &SessionLog, + client: &reqwest::Client, + relay: &str, + token: &str, + drain: bool, +) -> Result { + let base = relay.trim_end_matches('/'); + let mut uploaded = 0; + loop { + let reader = log.clone(); + let messages = tokio::task::spawn_blocking(move || reader.pending(50)).await??; + if messages.is_empty() { + return Ok(uploaded); + } + if uploaded == 0 { + client + .post(format!("{base}/v1/sessions")) + .bearer_auth(token) + .json(&serde_json::json!({"id":log.relay_session_id(),"metadata":"{}"})) + .send() + .await? + .error_for_status()?; + } + let mut wire_batch = Vec::new(); + // Fragment the already-encrypted record. Stable fragment IDs survive + // retries, including a response lost after only part of a large event. + for message in &messages { + let bytes = message.content.as_bytes(); + let count = bytes.len().div_ceil(48 * 1024); + for (index, chunk) in bytes.chunks(48 * 1024).enumerate() { + let fragment = PendingMessage { + local_id: format!("{}-{index}", message.local_id), + content: serde_json::to_string(&serde_json::json!({ + "v":1,"eventId":message.local_id,"index":index,"count":count, + "data":STANDARD.encode(chunk) + }))?, + }; + wire_batch.push(fragment); + if wire_batch.len() == 50 { + upload_batch(client, base, token, &log.relay_session_id(), &wire_batch).await?; + wire_batch.clear(); + } + } + } + if !wire_batch.is_empty() { + upload_batch(client, base, token, &log.relay_session_id(), &wire_batch).await?; + } + uploaded += messages.len(); + let writer = log.clone(); + tokio::task::spawn_blocking(move || writer.acknowledge(&messages)).await??; + if !drain { + return Ok(uploaded); + } + } +} + +async fn upload_batch( + client: &reqwest::Client, + base: &str, + token: &str, + session_id: &str, + messages: &[PendingMessage], +) -> Result<()> { + let response: serde_json::Value = client + .post(format!( + "{base}/v3/sessions/{}/messages", + urlencoding::encode(session_id) + )) + .bearer_auth(token) + .json(&serde_json::json!({"messages":messages})) + .send() + .await? + .error_for_status()? + .json() + .await?; + let acknowledged = response + .get("messages") + .and_then(|v| v.as_array()) + .context("missing upload acknowledgements")?; + if acknowledged.len() != messages.len() { + bail!("incomplete upload acknowledgement"); + } + for (ack, sent) in acknowledged.iter().zip(messages) { + if ack.get("localId").and_then(|v| v.as_str()) != Some(sent.local_id.as_str()) + || ack.pointer("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/content/c").and_then(|v| v.as_str()) != Some(sent.content.as_str()) + { + bail!("invalid upload acknowledgement"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn restart_preserves_key_ciphertext_and_pending_prefix() { + let root = tempfile::tempdir().unwrap(); + let log = SessionLog::open(root.path(), "account", "machine", "session").unwrap(); + let a = log + .append("start", serde_json::json!({"text":"hello"})) + .unwrap(); + let b = log.append("end", serde_json::json!({})).unwrap(); + let key = log.key_grant().unwrap(); + drop(log); + let log = SessionLog::open(root.path(), "account", "machine", "session").unwrap(); + assert_eq!(log.key_grant().unwrap(), key); + assert_eq!(log.pending(50).unwrap(), [a.clone(), b.clone()]); + assert!(log.acknowledge(&[b.clone()]).is_err()); + log.acknowledge(&[a.clone()]).unwrap(); + assert_eq!(log.pending(50).unwrap(), [b]); + assert_eq!(log.decrypt(&a.content).unwrap().0, "start"); + assert!(SessionLog::open(root.path(), "account", "machine", "other") + .unwrap() + .decrypt(&a.content) + .is_err()); + } + #[tokio::test] + #[ignore = "requires an isolated source Relay and its machine token"] + async fn source_relay_large_session_round_trip() { + let relay = std::env::var("RELAY_LAB_URL").expect("RELAY_LAB_URL"); + let token = std::env::var("RELAY_LAB_TOKEN").expect("RELAY_LAB_TOKEN"); + let root = tempfile::tempdir().unwrap(); + let session = format!("session-log-test-{}", uuid::Uuid::new_v4()); + let log = SessionLog::open(root.path(), "isolated-test", "source-host", &session).unwrap(); + let body = "large session payload ".repeat(100_000); + let original = log + .append("test-event", serde_json::json!({"body":body})) + .unwrap(); + let client = super::super::relay_http::relay_http_client(); + assert_eq!( + upload_pending(&log, &client, &relay, &token).await.unwrap(), + 1 + ); + assert_eq!( + upload_pending(&log, &client, &relay, &token).await.unwrap(), + 0 + ); + let mut cursor = 0; + let mut assembled = Vec::new(); + loop { + let response: serde_json::Value = client + .get(format!( + "{}/v3/sessions/{}/messages?after_seq={cursor}", + relay.trim_end_matches('/'), + log.relay_session_id() + )) + .bearer_auth(&token) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + for message in response["messages"].as_array().unwrap() { + assert_eq!(message["seq"].as_u64().unwrap(), cursor + 1); + cursor += 1; + let part: serde_json::Value = + serde_json::from_str(message["content"]["c"].as_str().unwrap()).unwrap(); + assembled.extend(STANDARD.decode(part["data"].as_str().unwrap()).unwrap()); + } + if !response["hasMore"].as_bool().unwrap() { + break; + } + } + assert!(cursor > 50, "large event crosses upload and read pages"); + assert_eq!(String::from_utf8(assembled).unwrap(), original.content); + assert_eq!(log.decrypt(&original.content).unwrap().1["body"], body); + } + + #[test] + fn batch_commit_reads_legacy_records_and_recovers_partial_uploads() { + let root = tempfile::tempdir().unwrap(); + let log = SessionLog::open(root.path(), "a", "m", "s").unwrap(); + let legacy = log.append("legacy", serde_json::json!({})).unwrap(); + fs::write( + log.directory.join("00000000000000000001.json"), + serde_json::to_vec(&legacy).unwrap(), + ) + .unwrap(); + fs::remove_file( + log.directory + .join("00000000000000000001-00000000000000000001.batch"), + ) + .unwrap(); + fs::write(log.directory.join(".interrupted.tmp"), b"partial").unwrap(); + let start = std::time::Instant::now(); + for batch in 0..16 { + log.append_batch( + (0..64) + .map(|i| ("event".into(), serde_json::json!({"index":batch*64+i}))) + .collect(), + ) + .unwrap(); + } + eprintln!("Session journal: 1024 events in {:?}", start.elapsed()); + let prefix = log.pending(50).unwrap(); + assert_eq!(prefix[0], legacy); + log.acknowledge(&prefix).unwrap(); + let tail = log.pending(2000).unwrap(); + assert_eq!(tail.len(), 975); + assert_eq!(tail.first().unwrap().local_id, "00000000000000000051"); + assert_eq!(tail.last().unwrap().local_id, "00000000000000001025"); + log.acknowledge(&tail).unwrap(); + assert!(log.pending(1).unwrap().is_empty()); + assert!(log.directory.join(".interrupted.tmp").exists()); + } + + #[test] + fn stable_records_preserve_large_bodies_and_scoped_tombstones() { + let root = tempfile::tempdir().unwrap(); + let log = SessionLog::open(root.path(), "a", "m", "s").unwrap(); + let turn = |id: &str, status: &str| serde_json::json!({"sessionId":"s","id":format!("turn/{id}"),"turn":{"turnId":id,"sessionId":"s","status":status}}); + let mut item = serde_json::json!({"sessionId":"s","id":"item/tool","turn":{"turnId":"one","sessionId":"s","status":"inprogress"},"item":{"type":"tool","data":{"id":"tool","result":"x".repeat(1024*1024)}}}); + log.append_records( + vec![ + turn("one", "inprogress"), + item.clone(), + turn("two", "completed"), + ], + true, + ) + .unwrap(); + assert_eq!(log.pending(50).unwrap().len(), 3); + item["turn"]["status"] = serde_json::json!("completed"); + log.append_records(vec![turn("one", "completed"), item.clone()], false) + .unwrap(); + let records = log.pending(50).unwrap(); + assert_eq!( + records.len(), + 4, + "parent state must not retransmit large unchanged tool output or remove unrelated turn" + ); + log.append_records(vec![turn("one", "completed")], false) + .unwrap(); + let records = log.pending(50).unwrap(); + assert_eq!(records.len(), 5); + let deleted = log.decrypt(&records[4].content).unwrap().1; + assert_eq!(deleted["id"], "item/tool"); + assert_eq!(deleted["deleted"], true); + assert_eq!(deleted["revision"], 5); + log.append_records(vec![turn("one", "completed"), item], false) + .unwrap(); + let records = log.pending(50).unwrap(); + assert_eq!(records.len(), 6); + assert_eq!(log.decrypt(&records[5].content).unwrap().1["revision"], 6); + } + + #[tokio::test] + async fn publisher_establishes_reserved_catalog_and_closes_observers() { + let root = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let relay = format!("http://{}", listener.local_addr().unwrap()); + let publisher = SessionPublisher::start( + root.path().into(), + "account".into(), + "host".into(), + relay, + "fixture".into(), + ) + .await + .unwrap(); + assert!(openbitfun_core_types::validate_session_id(HOST_CATALOG_ID).is_err()); + let log = SessionLog::open(root.path(), "account", "host", HOST_CATALOG_ID).unwrap(); + let pending = log.pending(10).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!( + log.decrypt(&pending[0].content).unwrap().0, + "host-catalog-changed" + ); + let closed = publisher.subscribe_closed(); + publisher.close(); + assert!(*closed.borrow()); + drop(listener); + } + + #[test] + fn concurrent_writers_and_corruption_preserve_records() { + let root = tempfile::tempdir().unwrap(); + let log = SessionLog::open(root.path(), "a", "m", "s").unwrap(); + std::thread::scope(|scope| { + for _ in 0..8 { + let log = log.clone(); + scope.spawn(move || { + for _ in 0..10 { + log.append("event", serde_json::json!({})).unwrap(); + } + }); + } + }); + let records = log.pending(100).unwrap(); + assert_eq!(records.len(), 80); + log.acknowledge(&records).unwrap(); + assert!(log.pending(1).unwrap().is_empty()); + fs::write(log.directory.join("key"), b"corrupt").unwrap(); + assert!(SessionLog::open(root.path(), "a", "m", "s").is_err()); + assert_eq!(log.last_sequence().unwrap(), 80); + } +} + +/// Contains a separator rejected by runtime Session IDs, so control metadata +/// cannot alias a user's session. +pub const HOST_CATALOG_ID: &str = "@host/catalog"; + +/// Account-scoped uploader: events are committed locally before returning; +/// notifications coalesce while the network is unavailable. Dropping the owner +/// stops network work without deleting pending messages. +pub struct SessionPublisher { + source_gates: tokio::sync::Mutex< + std::collections::HashMap>>, + >, + root: PathBuf, + account: String, + machine: String, + logs: std::sync::Arc>>, + wake: std::sync::Arc, + worker: tokio::task::JoinHandle<()>, + closed: tokio::sync::watch::Sender, +} + +impl SessionPublisher { + pub fn close(&self) { + self.closed.send_replace(true); + self.worker.abort(); + } + + pub fn subscribe_closed(&self) -> tokio::sync::watch::Receiver { + self.closed.subscribe() + } + + pub async fn start_for_host( + account: String, + machine: String, + relay: String, + token: String, + ) -> Result { + let root = super::product_home_dir() + .context("product home unavailable")? + .join("relay-session-log"); + Self::start(root, account, machine, relay, token).await + } + pub async fn start( + root: PathBuf, + account: String, + machine: String, + relay: String, + token: String, + ) -> Result { + let discover_root = root.clone(); + let discover_account = account.clone(); + let discover_machine = machine.clone(); + let known = tokio::task::spawn_blocking( + move || -> Result> { + let mut known = std::collections::BTreeMap::new(); + if !discover_root.exists() { + return Ok(known); + } + for entry in fs::read_dir(&discover_root)? { + let path = entry?.path().join("identity"); + if !path.exists() { + continue; + } + let (account, machine, session): (String, String, String) = + serde_json::from_slice(&fs::read(path)?)?; + if account == discover_account && machine == discover_machine { + known.insert( + session.clone(), + SessionLog::open(&discover_root, &account, &machine, &session)?, + ); + } + } + Ok(known) + }, + ) + .await??; + let logs = std::sync::Arc::new(tokio::sync::Mutex::new(known)); + let wake = std::sync::Arc::new(tokio::sync::Notify::new()); + let worker_logs = logs.clone(); + let worker_wake = wake.clone(); + let worker = tokio::spawn(async move { + let client = super::relay_http::relay_http_client(); + let mut retries: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + loop { + let current: Vec<_> = worker_logs.lock().await.values().cloned().collect(); + let mut progressed = false; + for log in current { + if retries + .get(&log.session_id) + .is_some_and(|(at, _)| *at > tokio::time::Instant::now()) + { + continue; + } + match upload_round(&log, &client, &relay, &token, false).await { + Ok(count) => { + progressed |= count > 0; + retries.remove(&log.session_id); + } + Err(error) => { + log::warn!("Durable session upload deferred: {error}"); + let delay = retries + .get(&log.session_id) + .map_or(1, |(_, delay)| (*delay * 2).min(30)); + retries.insert( + log.session_id.clone(), + ( + tokio::time::Instant::now() + + std::time::Duration::from_secs(delay), + delay, + ), + ); + } + } + } + if progressed { + continue; + } + if let Some(deadline) = retries.values().map(|(at, _)| *at).min() { + tokio::select! { _=tokio::time::sleep_until(deadline)=>{}, _=worker_wake.notified()=>{} } + } else { + worker_wake.notified().await; + } + } + }); + let publisher = Self { + source_gates: Default::default(), + root, + account, + machine, + logs, + wake, + worker, + closed: tokio::sync::watch::channel(false).0, + }; + // Establish the host control namespace before granting any subscriber. + publisher + .append( + HOST_CATALOG_ID.into(), + "host-catalog-changed".into(), + serde_json::json!({"sessionsRevision":0,"workspacesRevision":0}), + ) + .await?; + Ok(publisher) + } + + /// A source delivery gap is explicit; controllers reconcile from the + /// runtime's authoritative view. It must never cancel host-owned work. + pub async fn session_ids(&self) -> Vec { + self.logs.lock().await.keys().cloned().collect() + } + + pub async fn report_source_gap(&self, reason: &str) -> Result<()> { + let sessions: Vec<_> = self.logs.lock().await.keys().cloned().collect(); + for session in sessions { + self.append( + session.clone(), + "relay://session-gap".into(), + serde_json::json!({"sessionId":session,"reason":reason}), + ) + .await?; + } + Ok(()) + } + + pub async fn synchronize_records( + &self, + session_id: String, + full: bool, + load: F, + ) -> Result<()> + where + F: FnOnce() -> Fut, + Fut: std::future::Future>>, + { + let gate = self + .source_gates + .lock() + .await + .entry(session_id.clone()) + .or_default() + .clone(); + let _source = gate.lock().await; + let records = load().await?; + let root = self.root.clone(); + let account = self.account.clone(); + let machine = self.machine.clone(); + let log = tokio::task::spawn_blocking(move || -> Result { + let log = SessionLog::open(&root, &account, &machine, &session_id)?; + log.append_records(records, full)?; + Ok(log) + }) + .await??; + self.logs.lock().await.insert(log.session_id.clone(), log); + self.wake.notify_one(); + Ok(()) + } + + pub async fn append( + &self, + session_id: String, + event: String, + payload: serde_json::Value, + ) -> Result<()> { + self.append_batch(vec![(session_id, event, payload)]).await + } + + pub async fn append_batch( + &self, + events: Vec<(String, String, serde_json::Value)>, + ) -> Result<()> { + let root = self.root.clone(); + let account = self.account.clone(); + let machine = self.machine.clone(); + let logs = tokio::task::spawn_blocking(move || -> Result> { + let mut grouped: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (session, event, payload) in events { + grouped.entry(session).or_default().push((event, payload)); + } + let mut logs = Vec::new(); + for (session, events) in grouped { + let log = SessionLog::open(&root, &account, &machine, &session)?; + log.append_batch(events)?; + logs.push(log); + } + Ok(logs) + }) + .await??; + let mut known = self.logs.lock().await; + for log in logs { + known.insert(log.session_id.clone(), log); + } + drop(known); + self.wake.notify_one(); + Ok(()) + } +} + +impl Drop for SessionPublisher { + fn drop(&mut self) { + self.close(); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/session_records.rs b/src/crates/services/services-integrations/src/remote_connect/session_records.rs new file mode 100644 index 0000000000..645d8ad8c0 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/session_records.rs @@ -0,0 +1,114 @@ +//! Relay records retain the runtime's persisted turn, round and item contracts. +//! Parent headers make a latest-page record independently interpretable, while +//! large text/tool bodies occur only on their own stable item record. +use anyhow::Result; +use openbitfun_services_core::session::DialogTurnData; +use serde_json::{json, Value}; + +/// Transcript facts and interaction controls have different persistence roles. +/// Provider chunks are not durable messages; completed runtime blocks are read +/// from the canonical turn store. Approval controls retain their full payload. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SessionEventPublication { + pub synchronize_records: bool, + pub persist_control: bool, +} + +pub fn session_event_publication(name: &str, payload: &Value) -> SessionEventPublication { + if name == "agentic://text-chunk" { + return SessionEventPublication { + synchronize_records: false, + persist_control: false, + }; + } + if name == "agentic://tool-event" { + let kind = payload["toolEvent"]["event_type"].as_str().unwrap_or(""); + return SessionEventPublication { + synchronize_records: !matches!( + kind, + "EarlyDetected" + | "ParamsPartial" + | "Queued" + | "Waiting" + | "Progress" + | "Streaming" + | "StreamChunk" + ), + persist_control: matches!( + kind, + "ConfirmationNeeded" | "Confirmed" | "Rejected" | "Cancelled" + ), + }; + } + SessionEventPublication { + synchronize_records: true, + persist_control: true, + } +} + +pub fn records_from_turns(turns: &[DialogTurnData]) -> Result> { + let mut records = Vec::new(); + for source in turns { + let mut turn = serde_json::to_value(source)?; + turn.as_object_mut() + .expect("turn serializes as object") + .remove("modelRounds"); + records.push(json!({"sessionId":source.session_id,"id":format!("turn/{}",source.turn_id),"turn":turn})); + for source_round in &source.model_rounds { + let mut round = serde_json::to_value(source_round)?; + let object = round.as_object_mut().expect("round serializes as object"); + for field in ["textItems", "thinkingItems", "toolItems"] { + object.remove(field); + } + records.push(json!({"sessionId":source.session_id,"id":format!("round/{}",source_round.id),"turn":turn,"round":round})); + for (kind, items) in [ + ("text", serde_json::to_value(&source_round.text_items)?), + ( + "thinking", + serde_json::to_value(&source_round.thinking_items)?, + ), + ("tool", serde_json::to_value(&source_round.tool_items)?), + ] { + for item in items.as_array().expect("items serialize as array") { + let id = item["id"].as_str().expect("persisted item has id"); + records.push(json!({"sessionId":source.session_id,"id":format!("item/{id}"),"turn":turn,"round":round,"item":{"type":kind,"data":item}})); + } + } + } + } + Ok(records) +} + +#[cfg(test)] +mod publication_tests { + use super::*; + #[test] + fn completed_tool_body_is_only_published_as_a_canonical_record() { + for kind in ["Started", "Completed", "Failed"] { + let policy = session_event_publication( + "agentic://tool-event", + &json!({"toolEvent":{"event_type":kind,"result":"large tool output"}}), + ); + assert!(policy.synchronize_records); + assert!(!policy.persist_control); + } + for kind in ["ParamsPartial", "StreamChunk", "Streaming"] { + let policy = session_event_publication( + "agentic://tool-event", + &json!({"toolEvent":{"event_type":kind}}), + ); + assert!(!policy.synchronize_records); + assert!(!policy.persist_control); + } + } + #[test] + fn approvals_and_lifecycle_controls_are_retained() { + for kind in ["ConfirmationNeeded", "Confirmed", "Rejected", "Cancelled"] { + assert!(session_event_publication("agentic://tool-event",&json!({"toolEvent":{"event_type":kind,"params":{"question":"Review this input"}}})).persist_control); + } + assert!( + session_event_publication("agentic://dialog-turn-completed", &json!({"turnId":"turn"})) + .persist_control + ); + } +} diff --git a/src/crates/services/services-integrations/src/remote_connect/session_subscriber.rs b/src/crates/services/services-integrations/src/remote_connect/session_subscriber.rs new file mode 100644 index 0000000000..e099fc04ee --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_connect/session_subscriber.rs @@ -0,0 +1,562 @@ +//! Controller-side encrypted cache and cursor recovery. No runtime work lives +//! here: closing the subscriber only stops observing the target host. +use super::{ + account::{AccountClient, AccountSession}, + encryption::decrypt_from_base64, +}; +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{path::PathBuf, sync::Arc}; +use tokio::sync::{broadcast, mpsc, oneshot}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Message { + seq: u64, + content: Content, +} +#[derive(Deserialize)] +struct Content { + c: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Page { + messages: Vec, + has_more: bool, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Fragment { + v: u8, + event_id: String, + index: usize, + count: usize, + data: String, +} +#[derive(Deserialize)] +struct Ciphertext { + nonce: String, + data: String, +} +#[derive(Deserialize)] +pub struct SessionEvent { + pub session_id: String, + pub event: String, + pub payload: serde_json::Value, +} + +pub struct SessionSubscriber { + older: mpsc::Sender>>, + worker: tokio::task::JoinHandle<()>, +} +impl SessionSubscriber { + pub async fn start( + account: AccountSession, + relay: String, + target: String, + session_id: String, + emit: Arc Result<()> + Send + Sync>, + error: Arc, + ) -> Result { + let mut updates = account.session_updates(); + let grant = AccountClient::new() + .device_rpc( + &relay, + &account, + &target, + &serde_json::json!({"cmd":"get_session_key","session_id":session_id}).to_string(), + ) + .await?; + let grant: serde_json::Value = serde_json::from_str(&grant)?; + let key = grant["key"] + .as_str() + .context("session key unavailable")? + .to_owned(); + let wire_id = grant["relay_session_id"] + .as_str() + .context("session stream unavailable")? + .to_owned(); + if grant["session_id"].as_str() != Some(&session_id) { + bail!("session grant identity mismatch"); + } + let binding = serde_json::to_vec(&(&relay, &account.user_id, &target, &session_id))?; + let directory = super::product_home_dir() + .context("product home unavailable")? + .join("relay-session-cache") + .join(format!("{:x}", Sha256::digest(binding))); + tokio::fs::create_dir_all(&directory).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700)).await?; + } + let (older, mut older_requests) = mpsc::channel::>>(1); + let worker = tokio::spawn(async move { + let client = super::relay_http::relay_http_client(); + let mut replica = Replica { + directory, + cursor: 0, + parts: Vec::new(), + event_id: None, + count: 0, + total: 0, + key, + session_id, + emit, + }; + if let Err(failure) = replica.replay().await { + error(failure.to_string()); + return; + } + let mut oldest = replica.cached_start().await.unwrap_or(1); + let mut has_more = oldest > 1; + let mut retry = 1; + loop { + let result = async { + if replica.cursor == 0 { + let page = read_before( + &client, + &relay, + &account.token, + &wire_id, + 9_007_199_254_740_991, + ) + .await?; + if let Some(first) = page.messages.first() { + oldest = first.seq; + replica.cursor = oldest - 1; + has_more = page.has_more; + replica.save_start(oldest).await?; + for message in page.messages { + replica.apply(message).await?; + } + } + } + loop { + let page: Page = client + .get(format!( + "{}/v3/sessions/{}/messages?after_seq={}", + relay.trim_end_matches('/'), + urlencoding::encode(&wire_id), + replica.cursor + )) + .bearer_auth(&account.token) + .send() + .await? + .error_for_status()? + .json() + .await?; + for message in page.messages { + replica.apply(message).await?; + } + if !page.has_more { + break; + } + } + Ok::<_, anyhow::Error>(()) + } + .await; + if let Err(failure) = result { + error(failure.to_string()); + tokio::time::sleep(std::time::Duration::from_secs(retry)).await; + retry = (retry * 2).min(30); + continue; + } + retry = 1; + let ready = || SessionEvent { + session_id: replica.session_id.clone(), + event: "relay://session-ready".into(), + payload: serde_json::json!({"sessionId":replica.session_id,"hasMore":has_more,"oldestSeq":oldest,"cursor":replica.cursor}), + }; + if let Err(failure) = (replica.emit)(ready()) { + error(failure.to_string()); + } + loop { + let update = tokio::select! { + update=updates.recv()=>update, + request=older_requests.recv()=>{ + let Some(reply)=request else{return;}; + if reply.is_closed() { continue; } + let result=async { + if !has_more { + (replica.emit)(SessionEvent{session_id:replica.session_id.clone(),event:"relay://session-ready".into(),payload:serde_json::json!({"sessionId":replica.session_id,"hasMore":false,"oldestSeq":oldest,"cursor":replica.cursor})})?; + return Ok(()); + } + let page=read_before(&client,&relay,&account.token,&wire_id,oldest).await?; + if let Some(first)=page.messages.first(){ + let start=first.seq; + let mut history=Replica{directory:replica.directory.clone(),cursor:start-1,parts:Vec::new(),event_id:None,count:0,total:0,key:replica.key.clone(),session_id:replica.session_id.clone(),emit:replica.emit.clone()}; + for message in page.messages {history.apply(message).await?;} + history.save_start(start).await?;oldest=start; + } + has_more=page.has_more; + (replica.emit)(SessionEvent{session_id:replica.session_id.clone(),event:"relay://session-ready".into(),payload:serde_json::json!({"sessionId":replica.session_id,"hasMore":has_more,"oldestSeq":oldest,"cursor":replica.cursor})})?; + Ok(()) + }.await; + let _=reply.send(result);continue; + } + }; + match update { + Ok(None) | Err(broadcast::error::RecvError::Lagged(_)) => break, + Ok(Some((id, value))) if id == wire_id => { + match serde_json::from_value::(value) { + Ok(message) if message.seq <= replica.cursor => {} + Ok(message) if message.seq == replica.cursor + 1 => { + if let Err(failure) = replica.apply(message).await { + error(failure.to_string()); + break; + } + } + _ => break, + } + } + Ok(_) => {} + Err(broadcast::error::RecvError::Closed) => return, + } + } + } + }); + Ok(Self { worker, older }) + } + pub fn load_older(&self) -> impl std::future::Future> + Send + 'static { + let older = self.older.clone(); + async move { + let (send, receive) = oneshot::channel(); + tokio::time::timeout(std::time::Duration::from_secs(120), async { + older + .send(send) + .await + .map_err(|_| anyhow::anyhow!("Session subscriber closed"))?; + receive.await.context("Session subscriber closed")? + }) + .await + .context("Session history page timed out")? + } + } + pub fn close(&self) { + self.worker.abort(); + } +} +impl Drop for SessionSubscriber { + fn drop(&mut self) { + self.close(); + } +} + +async fn read_before( + client: &reqwest::Client, + relay: &str, + token: &str, + session: &str, + before: u64, +) -> Result { + let mut cursor = before; + let mut messages = Vec::new(); + loop { + let page: Page = client + .get(format!( + "{}/v3/sessions/{}/messages?before_seq={cursor}&limit=100", + relay.trim_end_matches('/'), + urlencoding::encode(session) + )) + .bearer_auth(token) + .send() + .await? + .error_for_status()? + .json() + .await?; + let has_more = page.has_more; + if page.messages.is_empty() { + return Ok(Page { + messages, + has_more: false, + }); + } + let next = page + .messages + .iter() + .map(|message| message.seq) + .min() + .context("empty history page")?; + if next >= cursor { + bail!("Session history cursor did not advance"); + } + messages.extend(page.messages); + messages.sort_by_key(|message| message.seq); + let first: Fragment = serde_json::from_str(&messages[0].content.c)?; + if first.index == 0 { + return Ok(Page { messages, has_more }); + } + if !has_more { + bail!("Session history begins with an incomplete event"); + } + cursor = next; + } +} + +struct Replica { + directory: PathBuf, + cursor: u64, + parts: Vec, + event_id: Option, + count: usize, + total: usize, + key: String, + session_id: String, + emit: Arc Result<()> + Send + Sync>, +} +impl Replica { + async fn cached_start(&self) -> Result { + match tokio::fs::read_to_string(self.directory.join("start")).await { + Ok(value) => Ok(value.parse()?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(1), + Err(error) => Err(error.into()), + } + } + async fn save_start(&self, start: u64) -> Result<()> { + let temporary = self + .directory + .join(format!(".start-{}", uuid::Uuid::new_v4())); + tokio::fs::write(&temporary, start.to_string()).await?; + tokio::fs::rename(temporary, self.directory.join("start")).await?; + Ok(()) + } + async fn replay(&mut self) -> Result<()> { + self.cursor = self.cached_start().await?.saturating_sub(1); + loop { + let path = self.directory.join(format!("{:020}.json", self.cursor + 1)); + match tokio::fs::read(path).await { + Ok(bytes) => { + let complete = self.decode(&String::from_utf8(bytes)?)?; + if complete { + self.finish_event(); + } + self.cursor += 1; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + } + } + } + async fn apply(&mut self, message: Message) -> Result<()> { + if message.seq != self.cursor + 1 { + bail!("session receive sequence gap"); + } + // Keep the pre-apply state if authentication/reducer fails so retry + // starts at exactly the same fragment, without duplicated bytes. + let old_length = self.parts.len(); + let old_id = self.event_id.clone(); + let old_count = self.count; + let old_total = self.total; + let complete = match self.decode(&message.content.c) { + Ok(complete) => complete, + Err(error) => { + self.parts.truncate(old_length); + self.event_id = old_id; + self.count = old_count; + self.total = old_total; + return Err(error); + } + }; + let path = self.directory.join(format!("{:020}.json", message.seq)); + let temporary = self + .directory + .join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let content = message.content.c; + let result = tokio::task::spawn_blocking(move || -> Result<()> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary)?; + file.write_all(content.as_bytes())?; + file.sync_all()?; + std::fs::rename(temporary, &path)?; + #[cfg(unix)] + std::fs::File::open(path.parent().context("cache parent unavailable")?)?.sync_all()?; + Ok(()) + }) + .await?; + if let Err(error) = result { + self.parts.truncate(old_length); + self.event_id = old_id; + self.count = old_count; + self.total = old_total; + return Err(error); + } + if complete { + self.finish_event(); + } + self.cursor = message.seq; + Ok(()) + } + fn decode(&mut self, content: &str) -> Result { + let part: Fragment = serde_json::from_str(content)?; + if part.v != 1 || part.count == 0 || part.index >= part.count { + bail!("invalid session fragment"); + } + if part.index == 0 { + if self.event_id.is_some() { + bail!("incomplete preceding session event"); + } + self.event_id = Some(part.event_id.clone()); + self.count = 0; + self.total = part.count; + } + if self.event_id.as_deref() != Some(&part.event_id) + || self.count != part.index + || self.total != part.count + { + bail!("session fragment order mismatch"); + } + self.parts.extend(STANDARD.decode(part.data)?); + self.count += 1; + if self.count == part.count { + let cipher: Ciphertext = serde_json::from_slice(&self.parts)?; + let key = super::encryption::parse_public_key(&self.key)?; + let event: SessionEvent = + serde_json::from_str(&decrypt_from_base64(&key, &cipher.data, &cipher.nonce)?)?; + if event.session_id != self.session_id { + bail!("session encryption binding mismatch"); + } + (self.emit)(event)?; + return Ok(true); + } + Ok(false) + } + fn finish_event(&mut self) { + self.parts.clear(); + self.event_id = None; + self.count = 0; + self.total = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn vector() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../../../../shared/relay-transport/session-cipher.fixture.json" + )) + .unwrap() + } + #[tokio::test] + async fn latest_window_restart_does_not_require_seq_one() { + let vector = vector(); + let directory = tempfile::tempdir().unwrap(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let emitted = count.clone(); + let callback: Arc Result<()> + Send + Sync> = Arc::new(move |_| { + emitted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }); + let make = || Replica { + directory: directory.path().to_owned(), + cursor: 0, + parts: vec![], + event_id: None, + count: 0, + total: 0, + key: vector["key"].as_str().unwrap().into(), + session_id: vector["sessionId"].as_str().unwrap().into(), + emit: callback.clone(), + }; + let mut replica = make(); + replica.save_start(100).await.unwrap(); + replica.cursor = 99; + for (index, part) in vector["fragments"].as_array().unwrap().iter().enumerate() { + replica + .apply(Message { + seq: 100 + index as u64, + content: Content { + c: part.to_string(), + }, + }) + .await + .unwrap(); + } + let last = replica.cursor; + drop(replica); + let mut restarted = make(); + restarted.replay().await.unwrap(); + assert_eq!(restarted.cursor, last); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2); + assert!(!directory.path().join("00000000000000000001.json").exists()); + } + + #[tokio::test] + async fn restart_mid_fragment_recovers_and_auth_failure_preserves_cursor() { + let vector = vector(); + let directory = tempfile::tempdir().unwrap(); + let emitted = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let output = emitted.clone(); + let callback: Arc Result<()> + Send + Sync> = Arc::new(move |_| { + output.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }); + let make = || Replica { + directory: directory.path().to_owned(), + cursor: 0, + parts: Vec::new(), + event_id: None, + count: 0, + total: 0, + key: vector["key"].as_str().unwrap().into(), + session_id: vector["sessionId"].as_str().unwrap().into(), + emit: callback.clone(), + }; + let fragments = vector["fragments"].as_array().unwrap(); + let mut replica = make(); + replica + .apply(Message { + seq: 1, + content: Content { + c: fragments[0].to_string(), + }, + }) + .await + .unwrap(); + drop(replica); + let mut replica = make(); + replica.replay().await.unwrap(); + assert_eq!(replica.cursor, 1); + for (index, fragment) in fragments.iter().enumerate().skip(1) { + replica + .apply(Message { + seq: index as u64 + 1, + content: Content { + c: fragment.to_string(), + }, + }) + .await + .unwrap(); + } + assert_eq!(emitted.load(std::sync::atomic::Ordering::SeqCst), 1); + let cursor = replica.cursor; + let mut corrupt = fragments[0].clone(); + corrupt["count"] = serde_json::json!(1); + assert!(replica + .apply(Message { + seq: cursor + 1, + content: Content { + c: corrupt.to_string() + } + }) + .await + .is_err()); + assert_eq!(replica.cursor, cursor); + assert!(replica.parts.is_empty()); + assert!(!directory + .path() + .join(format!("{:020}.json", cursor + 1)) + .exists()); + } +} diff --git a/src/crates/services/services-integrations/src/remote_persistence.rs b/src/crates/services/services-integrations/src/remote_persistence.rs index 11b12dd1a6..80f16590c2 100644 --- a/src/crates/services/services-integrations/src/remote_persistence.rs +++ b/src/crates/services/services-integrations/src/remote_persistence.rs @@ -1282,7 +1282,7 @@ mod device_secret_tests { key, load_or_create_device_secret( dir.path(), - "https://remote.openbitfun.com/v/1.0.0", + "https://remote.openbitfun.com/v/1.0.1", "1", "device" ) diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index 362a0d5a50..8d720a7946 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -5086,6 +5086,42 @@ impl SSHConnectionManager { .await } + pub async fn open_workspace_file_write_new( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result { + let path = self.resolve_sftp_path(connection_id, path).await?; + if self.is_shell_workspace(connection_id).await { + anyhow::bail!("Streaming upload requires an SFTP workspace provider"); + } + let sftp = self.get_sftp(connection_id).await?; + Ok(Box::new(sftp.create_new(&path).await?)) + } + + pub async fn atomic_replace_workspace_file( + &self, + connection_id: &str, + from: &str, + to: &str, + ) -> anyhow::Result<()> { + let from = self.resolve_sftp_path(connection_id, from).await?; + let to = self.resolve_sftp_path(connection_id, to).await?; + if from.rsplit_once('/').map(|p| p.0) != to.rsplit_once('/').map(|p| p.0) { + anyhow::bail!("Atomic replacement requires a same-directory staged file"); + } + let command = format!( + "test ! -d {1} && mv -f -- {0} {1}", + crate::remote_ssh::shell::quote_arg(&from), + crate::remote_ssh::shell::quote_arg(&to) + ); + let (_, stderr, code) = self.execute_command(connection_id, &command).await?; + if code != 0 { + anyhow::bail!("Remote atomic replacement failed: {stderr}"); + } + Ok(()) + } + pub async fn open_workspace_file_read( &self, connection_id: &str, diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager/tests/workspace_sftp.rs b/src/crates/services/services-integrations/src/remote_ssh/manager/tests/workspace_sftp.rs index 8993d1fdb3..9297092ccb 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager/tests/workspace_sftp.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager/tests/workspace_sftp.rs @@ -744,3 +744,38 @@ async fn a_directory_waiter_detects_retirement_after_acquiring_the_lock() { assert_eq!(f.state.channels.load(Ordering::SeqCst), 2); assert_eq!(f.state.opened.load(Ordering::SeqCst), 1); } + +#[tokio::test] +#[ignore = "Set OPENBITFUN_TEST_SFTP_SERVER to the OpenSSH sftp-server executable"] +async fn openssh_streaming_upload_create_is_exclusive_and_closes_handle() { + use tokio::io::AsyncWriteExt; + let executable = std::env::var("OPENBITFUN_TEST_SFTP_SERVER") + .expect("OpenSSH sftp-server executable required"); + let f = Fixture::with_openssh(Some(executable)).await; + let path = f + ._dir + .path() + .join("upload.tmp") + .to_string_lossy() + .into_owned(); + let mut writer = f + .manager + .open_workspace_file_write_new("sftp-test", &path) + .await + .unwrap(); + let chunk = vec![29u8; 65536]; + for _ in 0..32 { + writer.write_all(&chunk).await.unwrap(); + } + writer.flush().await.unwrap(); + writer.shutdown().await.unwrap(); + drop(writer); + assert!(f + .manager + .open_workspace_file_write_new("sftp-test", &path) + .await + .is_err()); + let data = f.manager.sftp_read("sftp-test", &path).await.unwrap(); + assert_eq!(data.len(), chunk.len() * 32); + assert!(data.iter().all(|byte| *byte == 29)); +} diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs index f760342433..021c1b52f5 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs @@ -54,6 +54,29 @@ impl RemoteFileService { Self { manager } } + pub async fn open_write_new( + &self, + connection_id: &str, + path: &str, + ) -> anyhow::Result { + self.get_manager(connection_id) + .await? + .open_workspace_file_write_new(connection_id, path) + .await + } + + pub async fn atomic_replace( + &self, + connection_id: &str, + from: &str, + to: &str, + ) -> anyhow::Result<()> { + self.get_manager(connection_id) + .await? + .atomic_replace_workspace_file(connection_id, from, to) + .await + } + pub async fn open_read( &self, connection_id: &str, diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs index 060b185f0b..05f33cfc3c 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs @@ -31,6 +31,7 @@ pub struct RemoteTerminalSession { pub cols: u16, pub rows: u16, pub source: SessionSource, + pub replay: terminal_core::session::TerminalReplayHistory, } #[derive(Debug, Clone, PartialEq)] @@ -90,11 +91,26 @@ impl RemoteTerminalManager { initial_cwd: Option<&str>, source: Option, ) -> anyhow::Result { - let ssh_guard = self.ssh_manager.read().await; - let manager = ssh_guard.as_ref().context("SSH manager not initialized")?; + let manager = self + .ssh_manager + .read() + .await + .clone() + .context("SSH manager not initialized")?; let session_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let name = name.unwrap_or_else(|| format!("Remote Terminal {}", &session_id[..8])); + if session_id.is_empty() || self.sessions.read().await.contains_key(&session_id) { + anyhow::bail!("Terminal session ID is empty or already exists"); + } + if cols == 0 || rows == 0 { + anyhow::bail!("Terminal dimensions must be positive"); + } + let name = name.unwrap_or_else(|| { + format!( + "Remote Terminal {}", + session_id.chars().take(8).collect::() + ) + }); manager.ensure_connected(connection_id).await?; if manager.is_local_process_connection(connection_id).await { @@ -117,7 +133,6 @@ impl RemoteTerminalManager { .local_process_shell_spec(connection_id, (cwd != "~").then_some(cwd.as_str())) .await? .ok_or_else(|| anyhow::anyhow!("Local workspace shell is unavailable"))?; - drop(ssh_guard); return self .create_local_workspace_session( session_id, @@ -197,6 +212,7 @@ impl RemoteTerminalManager { cols, rows, source: source.unwrap_or_default(), + replay: Default::default(), }; { @@ -270,9 +286,15 @@ impl RemoteTerminalManager { msg = channel.wait() => { match msg { Some(russh::ChannelMsg::Data { data }) => { + if let Some(session) = task_sessions.write().await.get_mut(&task_session_id) { + session.replay.record_output(session.cols, session.rows, &String::from_utf8_lossy(&data)); + } let _ = output_tx.send(data.to_vec()); } Some(russh::ChannelMsg::ExtendedData { data, .. }) => { + if let Some(session) = task_sessions.write().await.get_mut(&task_session_id) { + session.replay.record_output(session.cols, session.rows, &String::from_utf8_lossy(&data)); + } let _ = output_tx.send(data.to_vec()); } Some(russh::ChannelMsg::Eof) @@ -364,6 +386,7 @@ impl RemoteTerminalManager { cols, rows, source, + replay: Default::default(), }; self.sessions .write() @@ -402,6 +425,9 @@ impl RemoteTerminalManager { event = events.recv() => { match event { Some(PtyEvent::Data(data)) => { + if let Some(session) = task_sessions.write().await.get_mut(&session_id) { + session.replay.record_output(session.cols, session.rows, &String::from_utf8_lossy(&data)); + } let _ = output_tx.send(data); } Some(PtyEvent::Exit { .. }) | None => break, @@ -423,6 +449,27 @@ impl RemoteTerminalManager { self.sessions.read().await.get(session_id).cloned() } + pub async fn replay_cursor(&self, session_id: &str) -> Option { + self.sessions + .read() + .await + .get(session_id) + .map(|session| session.replay.cursor()) + } + + pub async fn replay_page( + &self, + session_id: &str, + after: u64, + max_bytes: usize, + ) -> Option { + self.sessions.read().await.get(session_id).map(|session| { + session + .replay + .page(after, max_bytes, session.cols, session.rows) + }) + } + pub async fn list_sessions(&self) -> Vec { self.sessions .read() @@ -447,23 +494,82 @@ impl RemoteTerminalManager { .map_err(|_| anyhow::anyhow!("PTY task has exited")) } + /// Run a captured command on the session's SSH target and initial directory. + /// Interactive shell state is accessed through `write`, not a separate exec channel. + pub async fn execute( + &self, + session_id: &str, + command: &str, + timeout_ms: Option, + ) -> anyhow::Result { + let session = self + .get_session(session_id) + .await + .context("Terminal session is unavailable")?; + let ssh = self + .ssh_manager + .read() + .await + .clone() + .context("SSH manager is unavailable")?; + let script = if session.cwd == "~" { + command.to_string() + } else { + format!( + "cd -- {} && {}", + crate::remote_ssh::shell_quote_posix(&session.cwd), + command + ) + }; + let result = ssh + .execute_command_with_options( + &session.connection_id, + &script, + crate::remote_ssh::types::SSHCommandOptions { + timeout_ms, + cancellation_token: None, + }, + ) + .await?; + if result.interrupted { + anyhow::bail!("Remote terminal command was interrupted; its effects may have occurred"); + } + Ok(terminal_core::ExecuteCommandResponse { + command: command.to_string(), + command_id: uuid::Uuid::new_v4().to_string(), + output: if result.stderr.is_empty() { + result.stdout + } else { + format!("{}\n{}", result.stdout, result.stderr) + }, + exit_code: (!result.timed_out).then_some(result.exit_code), + completion_reason: if result.timed_out { + terminal_core::CommandCompletionReason::TimedOut + } else { + terminal_core::CommandCompletionReason::Completed + }, + }) + } + pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> anyhow::Result<()> { - { - let mut sessions = self.sessions.write().await; - if let Some(s) = sessions.get_mut(session_id) { - s.cols = cols; - s.rows = rows; - } + if cols == 0 || rows == 0 { + anyhow::bail!("Terminal dimensions must be positive"); } - let cmd_tx = { - let handles = self.handles.read().await; - handles.get(session_id).map(|handle| handle.cmd_tx.clone()) - }; - if let Some(cmd_tx) = cmd_tx { - cmd_tx - .send(PtyCommand::Resize(cols as u32, rows as u32)) - .await - .map_err(|_| anyhow::anyhow!("PTY task has exited"))?; + let sender = self + .handles + .read() + .await + .get(session_id) + .map(|handle| handle.cmd_tx.clone()) + .context("Session not found or PTY not active")?; + sender + .send(PtyCommand::Resize(cols as u32, rows as u32)) + .await + .map_err(|_| anyhow::anyhow!("PTY task has exited"))?; + if let Some(session) = self.sessions.write().await.get_mut(session_id) { + session.cols = cols; + session.rows = rows; + session.replay.record_resize(cols, rows); } Ok(()) } diff --git a/src/crates/services/services-integrations/src/remote_ssh/sftp_file.rs b/src/crates/services/services-integrations/src/remote_ssh/sftp_file.rs index 436a014cd6..e6296e98ee 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/sftp_file.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/sftp_file.rs @@ -48,18 +48,18 @@ impl ManagedSftpSession { } pub(super) async fn open(self: &Arc, path: &str) -> Result { - self.open_owned(path, false).await + self.open_owned(path, 0).await } pub(super) async fn create(self: &Arc, path: &str) -> Result { - self.open_owned(path, true).await + self.open_owned(path, 1).await } - async fn open_owned( - self: &Arc, - path: &str, - create: bool, - ) -> Result { + pub(super) async fn create_new(self: &Arc, path: &str) -> Result { + self.open_owned(path, 2).await + } + + async fn open_owned(self: &Arc, path: &str, mode: u8) -> Result { let session = self.clone(); let path = path.to_owned(); let (send, receive) = tokio::sync::oneshot::channel(); @@ -69,7 +69,16 @@ impl ManagedSftpSession { if send.is_closed() { return; } - let result = if create { + let result = if mode == 2 { + use russh_sftp::protocol::OpenFlags; + session + .session + .open_with_flags( + path, + OpenFlags::CREATE | OpenFlags::EXCLUDE | OpenFlags::WRITE, + ) + .await + } else if mode == 1 { session.session.create(path).await } else { session.session.open(path).await diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs index 2738dc0eb6..66f9b07e4b 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_services.rs @@ -101,6 +101,22 @@ fn join_posix_path(root: &str, components: &[&str]) -> String { #[async_trait] impl WorkspaceFileSystem for RemoteWorkspaceFs { + #[cfg(feature = "remote-ssh-concrete")] + async fn open_write_new( + &self, + path: &str, + ) -> anyhow::Result { + self.file_service + .open_write_new(&self.connection_id, path) + .await + } + #[cfg(feature = "remote-ssh-concrete")] + async fn atomic_replace(&self, from: &str, to: &str) -> anyhow::Result<()> { + self.file_service + .atomic_replace(&self.connection_id, from, to) + .await + } + #[cfg(feature = "remote-ssh-concrete")] async fn open_read( &self, diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index dc70172066..0a87802e25 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -33,15 +33,15 @@ use openbitfun_services_integrations::remote_connect::{ resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, - QrGenerator, RelayMessage, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, - RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, - RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, - RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, - RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, - RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, - RemoteDialogSteerOutcome, RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, - RemoteImageContext, RemoteImageContextAdapter, RemoteModelCapabilityFact, RemoteModelCatalog, + QrGenerator, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, + RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, + RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, + RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, + RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, + RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSteerOutcome, + RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, + RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, + RemoteImageContextAdapter, RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelConfig, RemoteModelFacts, RemoteRecentWorkspaceFacts, RemoteResponse, RemoteSessionMetadata, RemoteSessionModelSelection, RemoteSessionStateTracker, RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, @@ -57,7 +57,7 @@ use std::sync::{Arc, Mutex}; #[test] fn relay_invitations_and_authentication_use_the_same_protocol_for_all_endpoints() { for endpoint in [ - "https://remote.openbitfun.com/v/1.0.0", + "https://remote.openbitfun.com/v/1.0.1", "http://192.168.1.8:9700", ] { assert_eq!( @@ -65,14 +65,14 @@ fn relay_invitations_and_authentication_use_the_same_protocol_for_all_endpoints( format!("{endpoint}/#/pair?did=desktop-1") ); } - let message = RelayMessage::AuthConnect { - token: "test-token".into(), - device_name: "Desktop".into(), - device_kind: "desktop".into(), - }; + use openbitfun_services_integrations::remote_connect::realtime_client::account_auth_payload; + assert_eq!( + account_auth_payload("test-token", true), + serde_json::json!({"token":"test-token","clientType":"machine-scoped"}) + ); assert_eq!( - serde_json::to_value(message).unwrap()["type"], - "auth_connect" + account_auth_payload("test-token", false), + serde_json::json!({"token":"test-token","clientType":"user-scoped"}) ); } @@ -846,6 +846,8 @@ impl RemoteCommandRuntimeHost for RecordingCommandHost { self.events.lock().unwrap().push("session".to_string()); RemoteResponse::SessionCreated { session_id: "session-created".to_string(), + workspace_path: None, + remote_connection_id: None, } } @@ -1124,6 +1126,8 @@ async fn remote_connect_command_owner_preserves_cancel_and_group_routing() { &RemoteCommand::GetFileInfo { path: "README.md".to_string(), session_id: None, + workspace_path: None, + remote_connection_id: None, }, RemoteConnectSubmissionSource::Relay, ) @@ -1742,6 +1746,8 @@ impl RemoteWorkspaceFileRuntimeHost for UnavailableSessionFileHost { &self, _: &str, session: Option<&str>, + _: Option<&str>, + _: Option<&str>, _: u64, _: u64, ) -> Result, String> { @@ -1753,6 +1759,8 @@ impl RemoteWorkspaceFileRuntimeHost for UnavailableSessionFileHost { &self, _: &str, session: Option<&str>, + _: Option<&str>, + _: Option<&str>, ) -> Result, String> { assert_eq!(session, Some("remote-session")); Err("Session host is offline".into()) @@ -1773,8 +1781,15 @@ async fn remote_connect_file_provider_errors_never_fall_back_to_local_files() { session_id: session_id.clone(), offset: 0, limit: 3, + workspace_path: None, + remote_connection_id: None, + }, + RemoteCommand::GetFileInfo { + path, + session_id, + workspace_path: None, + remote_connection_id: None, }, - RemoteCommand::GetFileInfo { path, session_id }, ] { assert_eq!( handle_remote_workspace_file_command(&UnavailableSessionFileHost, &command).await, @@ -1785,6 +1800,58 @@ async fn remote_connect_file_provider_errors_never_fall_back_to_local_files() { } } +struct ExplicitFileWorkspaceHost; + +#[async_trait::async_trait] +impl RemoteWorkspaceFileRuntimeHost for ExplicitFileWorkspaceHost { + async fn resolve_remote_file_workspace_root(&self, _: Option<&str>) -> Option { + panic!("Explicit remote identity must never fall back to the selected local workspace") + } + async fn read_remote_file_chunk( + &self, + _: &str, + session: Option<&str>, + workspace: Option<&str>, + connection: Option<&str>, + _: u64, + _: u64, + ) -> Result, String> { + assert_eq!(session, None); + assert_eq!(workspace, Some("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/captured/workspace")); + assert_eq!(connection, Some("saved-runtime-profile")); + Ok(None) + } + async fn remote_file_info( + &self, + _: &str, + session: Option<&str>, + workspace: Option<&str>, + connection: Option<&str>, + ) -> Result, String> { + assert_eq!(session, None); + assert_eq!(workspace, Some("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/captured/workspace")); + assert_eq!(connection, Some("saved-runtime-profile")); + Ok(None) + } +} + +#[tokio::test] +async fn remote_connect_explicit_file_workspace_is_forwarded_without_local_fallback() { + for name in ["read_file_chunk", "get_file_info"] { + let command: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": name, "path": "file.bin", "workspace_path": "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/captured/workspace", + "remote_connection_id": "saved-runtime-profile", "offset": 0, "limit": 3 + })) + .unwrap(); + assert_eq!( + handle_remote_workspace_file_command(&ExplicitFileWorkspaceHost, &command).await, + RemoteResponse::Error { + message: "This host cannot resolve an explicit file workspace".into() + } + ); + } +} + #[test] fn remote_connect_execution_response_helpers_preserve_wire_shape() { let started = remote_dialog_submit_response(Ok(RemoteDialogSubmitOutcome::Started { @@ -2064,6 +2131,8 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { remote_session_created_response("session-new"), RemoteResponse::SessionCreated { session_id: "session-new".to_string(), + workspace_path: None, + remote_connection_id: None, } ); assert_eq!( diff --git a/src/crates/services/terminal/src/session/manager.rs b/src/crates/services/terminal/src/session/manager.rs index d2f31f4b6f..e86796f31c 100644 --- a/src/crates/services/terminal/src/session/manager.rs +++ b/src/crates/services/terminal/src/session/manager.rs @@ -9,7 +9,7 @@ use dashmap::DashMap; use futures::{Stream, StreamExt}; use log::{debug, warn}; use serde::{Deserialize, Serialize}; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::{mpsc, watch, RwLock}; use crate::config::{ShellConfig, TerminalConfig}; use crate::events::{TerminalEvent, TerminalEventEmitter}; @@ -185,6 +185,7 @@ pub struct SessionManager { /// Per-session output taps for real-time output streaming output_taps: Arc>>>>, + replay_watchers: Arc>>, /// Persistent plain-text transcripts for manually created terminal sessions. transcript_recorder: Option, @@ -217,6 +218,7 @@ impl SessionManager { binding, scripts_manager, output_taps, + replay_watchers: Arc::new(DashMap::new()), transcript_recorder, }; @@ -242,6 +244,7 @@ impl SessionManager { let pty_to_session = self.pty_to_session.clone(); let session_integrations = self.session_integrations.clone(); let output_taps = self.output_taps.clone(); + let replay_watchers = self.replay_watchers.clone(); let transcript_recorder = self.transcript_recorder.clone(); tokio::spawn(async move { @@ -298,6 +301,9 @@ impl SessionManager { session.touch(); // Record output to history for frontend recovery session.add_output(&data_str); + if let Some(watch) = replay_watchers.get(&session_id) { + watch.send_replace(session.replay_history.cursor()); + } session.source == SessionSource::Manual } else { false @@ -1414,6 +1420,28 @@ impl SessionManager { sessions.get(session_id).cloned() } + /// Cursor notifications must not clone the retained PTY history. + pub async fn replay_cursor(&self, session_id: &str) -> Option { + self.sessions + .read() + .await + .get(session_id) + .map(|session| session.replay_history.cursor()) + } + + pub async fn replay_page( + &self, + session_id: &str, + after: u64, + max_bytes: usize, + ) -> Option { + self.sessions.read().await.get(session_id).map(|session| { + session + .replay_history + .page(after, max_bytes, session.cols, session.rows) + }) + } + /// List all sessions pub async fn list_sessions(&self) -> Vec { let sessions = self.sessions.read().await; @@ -1528,6 +1556,7 @@ impl SessionManager { // Drop output taps so file-writing tasks can detect session end self.output_taps.remove(session_id); + self.replay_watchers.remove(session_id); // Remove session { @@ -1606,6 +1635,15 @@ impl SessionManager { self.pty_service.shutdown_all().await; } + /// A slow replay observer retains the newest cursor without buffering PTY + /// bytes or dropping its subscription. Subscribe before creating a session. + pub fn subscribe_replay_cursor(&self, session_id: &str) -> watch::Receiver { + self.replay_watchers + .entry(session_id.to_string()) + .or_insert_with(|| watch::channel(0).0) + .subscribe() + } + /// Subscribe to the raw PTY output of a specific session. /// /// Returns a receiver that yields raw output strings as they arrive from the PTY. diff --git a/src/crates/services/terminal/src/session/mod.rs b/src/crates/services/terminal/src/session/mod.rs index fb3d85c0c7..32b91a9533 100644 --- a/src/crates/services/terminal/src/session/mod.rs +++ b/src/crates/services/terminal/src/session/mod.rs @@ -16,7 +16,7 @@ pub use manager::{ ExecuteOptions, SessionManager, }; pub use persistent::PersistentSession; -pub use replay::{TerminalReplayEvent, TerminalReplayHistory}; +pub use replay::{TerminalReplayEvent, TerminalReplayHistory, TerminalReplayPage}; pub use serializer::SessionSerializer; pub use singleton::{ get_session_manager, init_session_manager, is_session_manager_initialized, session_manager, diff --git a/src/crates/services/terminal/src/session/replay.rs b/src/crates/services/terminal/src/session/replay.rs index f98312bd2d..f8190c69be 100644 --- a/src/crates/services/terminal/src/session/replay.rs +++ b/src/crates/services/terminal/src/session/replay.rs @@ -38,10 +38,23 @@ impl TerminalReplayEvent { } } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalReplayPage { + pub data: String, + pub next_offset: u64, + pub cursor: u64, + pub truncated: bool, + pub history_size: usize, + pub cols: u16, + pub rows: u16, +} + /// Bounded replay history for one terminal session. #[derive(Debug, Clone)] pub struct TerminalReplayHistory { events: VecDeque, + total_bytes: u64, max_bytes: usize, max_events: usize, } @@ -50,6 +63,7 @@ impl Default for TerminalReplayHistory { fn default() -> Self { Self { events: VecDeque::new(), + total_bytes: 0, max_bytes: Self::DEFAULT_MAX_BYTES, max_events: Self::DEFAULT_MAX_EVENTS, } @@ -57,8 +71,8 @@ impl Default for TerminalReplayHistory { } impl TerminalReplayHistory { - /// Default maximum output payload retained for replay: 100KB. - pub const DEFAULT_MAX_BYTES: usize = 100 * 1024; + /// Default maximum output payload retained for replay: 4 MiB. + pub const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; /// Maximum event count retained for replay. pub const DEFAULT_MAX_EVENTS: usize = 2_000; @@ -67,6 +81,8 @@ impl TerminalReplayHistory { return; } + self.total_bytes = self.total_bytes.saturating_add(data.len() as u64); + // Keep contiguous output with identical PTY dimensions in one event so // replay applies geometry only at real resize boundaries. match self.events.back_mut() { @@ -99,6 +115,7 @@ impl TerminalReplayHistory { } pub fn replace_events(&mut self, events: Vec) { + self.total_bytes = events.iter().map(|event| event.data.len() as u64).sum(); self.events = events.into(); self.trim(); } @@ -114,6 +131,57 @@ impl TerminalReplayHistory { .collect() } + /// Monotonic UTF-8 byte cursor, including output evicted from replay. + pub fn cursor(&self) -> u64 { + self.total_bytes + } + + /// Returns a UTF-8 safe page and explicitly reports a cursor gap. + pub fn read_after(&self, after: u64, max_bytes: usize) -> (String, u64, bool) { + let start = self.total_bytes.saturating_sub(self.size_bytes() as u64); + let requested = after.max(start).min(self.total_bytes); + let mut skip = (requested - start) as usize; + let mut data = String::new(); + let mut next = requested; + let mut remaining = max_bytes.max(4); + for event in &self.events { + if skip >= event.data.len() { + skip -= event.data.len(); + continue; + } + let mut offset = skip; + while !event.data.is_char_boundary(offset) { + offset += 1; + } + next += (offset - skip) as u64; + skip = 0; + let mut end = offset.saturating_add(remaining).min(event.data.len()); + while !event.data.is_char_boundary(end) { + end -= 1; + } + data.push_str(&event.data[offset..end]); + next += (end - offset) as u64; + remaining -= end - offset; + if end < event.data.len() || remaining == 0 { + break; + } + } + (data, next, after < start || after > self.total_bytes) + } + + pub fn page(&self, after: u64, max_bytes: usize, cols: u16, rows: u16) -> TerminalReplayPage { + let (data, next_offset, truncated) = self.read_after(after, max_bytes); + TerminalReplayPage { + data, + next_offset, + cursor: self.cursor(), + truncated, + history_size: self.size_bytes(), + cols, + rows, + } + } + pub fn clear(&mut self) { self.events.clear(); } @@ -129,8 +197,18 @@ impl TerminalReplayHistory { let mut total_size = self.size_bytes(); while total_size > self.max_bytes && !self.events.is_empty() { - if let Some(oldest) = self.events.pop_front() { - total_size = total_size.saturating_sub(oldest.data.len()); + let excess = total_size - self.max_bytes; + if let Some(oldest) = self.events.front_mut() { + if oldest.data.len() > excess { + let mut cut = excess; + while !oldest.data.is_char_boundary(cut) { + cut += 1; + } + oldest.data.drain(..cut); + total_size -= cut; + } else if let Some(oldest) = self.events.pop_front() { + total_size -= oldest.data.len(); + } } } @@ -152,6 +230,19 @@ impl TerminalReplayHistory { mod tests { use super::*; + #[test] + fn oversized_coalesced_output_retains_utf8_tail_and_reports_gap() { + let mut history = TerminalReplayHistory::default(); + history.max_bytes = 8; + history.record_output(80, 24, "abcdefgh你好世界"); + assert_eq!(history.data(), "世界"); + let (data, cursor, truncated) = history.read_after(0, 4); + assert_eq!(data, "世"); + assert_eq!(cursor, 17); + assert!(truncated); + assert_eq!(history.read_after(cursor, 4), ("界".into(), 20, false)); + } + #[test] fn coalesces_output_with_matching_dimensions() { let mut history = TerminalReplayHistory::default(); diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 6601a824cb..4fe34ed190 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -229,8 +229,10 @@ "startup_window_control" ], "fileTransferPrimitives": [ + "local_file_download", "export_local_file_to_path", - "paste_files" + "paste_files", + "workspace_file_upload" ], "htmlPreview": [ "html_preview_create", @@ -240,6 +242,12 @@ "account_get_credential_hint", "account_login", "account_token_expired" + ], + "relaySessionSynchronization": [ + "account_load_older_session", + "account_subscribe_session", + "account_unsubscribe_session", + "get_session_interaction_mailbox" ] }, "capabilities": [ @@ -9493,6 +9501,9 @@ "editor_ai": { "capabilityId": "feature.ai-assistant" }, + "local_file_download": { + "capabilityId": "feature.files-editor" + }, "clipboard_file": { "capabilityId": "feature.files-editor" }, diff --git a/src/shared/relay-transport/AccountRealtime.ts b/src/shared/relay-transport/AccountRealtime.ts new file mode 100644 index 0000000000..c3fe500cb9 --- /dev/null +++ b/src/shared/relay-transport/AccountRealtime.ts @@ -0,0 +1,261 @@ +import { DEFAULT_RPC_TIMEOUT_MS } from './RpcPolicy'; +/** Shared account connection. Protocol reference: Happy apiSocket/RpcHandlerManager. */ +import { io, type Socket } from 'socket.io-client'; +import { RpcPayload } from './RpcPayload'; + +export interface DurableMessage { + id: string; + seq: number; + localId: string; + content: { t: 'encrypted'; c: string }; + createdAt: number; + updatedAt: number; +} +export interface SessionMessageUpdate { + id: string; + seq: number; + createdAt: number; + body: { t: 'new-message'; sid: string; message: DurableMessage }; +} +export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'closed'; +export interface RealtimeOptions { + url: string; + token: string; + machineId?: string; +} + +/** One owner per account. Reconnect reauthenticates and signals catch-up; it + * does not retransmit mutations whose outcome is unknown. */ +export class AccountRealtime { + private readonly socket: Socket; + private readonly payloads: RpcPayload; + private epoch = 0; + private closed = false; + private readonly updates = new Set<(update: SessionMessageUpdate) => void>(); + private readonly reconnects = new Set<() => void>(); + private readonly directoryChanges = new Set<() => void>(); + private readonly statusListeners = new Set<(status: ConnectionStatus) => void>(); + private status: ConnectionStatus = 'connecting'; + + constructor(options: RealtimeOptions) { + this.payloads = new RpcPayload(options.url, options.token); + const url = new URL(options.url); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('Invalid Relay URL'); + } + const path = `${url.pathname.replace(/\/$/, '')}/v1/updates`; + this.socket = io(url.origin, { + path, transports: ['websocket'], forceNew: true, autoConnect: false, + auth: { token: options.token, clientType: options.machineId ? 'machine-scoped' : 'user-scoped', + ...(options.machineId ? { machineId: options.machineId } : {}) }, + reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, + randomizationFactor: 0.5, timeout: 15000, + }); + this.socket.on('auth-ok', () => { + if (this.closed) return; + this.epoch++; + this.setStatus('connected'); + for (const listener of this.reconnects) listener(); + }); + this.socket.on('disconnect', () => { + this.epoch++; + if (!this.closed) this.setStatus('disconnected'); + }); + this.socket.on('connect_error', () => { + if (!this.closed) this.setStatus('disconnected'); + }); + this.socket.on('ephemeral', (event: unknown) => { + if (this.closed || !event || typeof event !== 'object' + || (event as { type?: unknown }).type !== 'device-presence') return; + for (const listener of this.directoryChanges) listener(); + }); + this.socket.on('update', (update: unknown) => { + if (this.closed || !isSessionMessageUpdate(update)) return; + for (const listener of this.updates) listener(update); + }); + this.socket.connect(); + } + + private setStatus(status: ConnectionStatus): void { + this.status = status; + for (const listener of this.statusListeners) listener(status); + } + onStatus(listener: (status: ConnectionStatus) => void): () => void { + this.statusListeners.add(listener); listener(this.status); + return () => { this.statusListeners.delete(listener); }; + } + onReconnect(listener: () => void): () => void { + this.reconnects.add(listener); + return () => { this.reconnects.delete(listener); }; + } + onDeviceDirectoryChanged(listener: () => void): () => void { + this.directoryChanges.add(listener); + return () => { this.directoryChanges.delete(listener); }; + } + onUpdate(listener: (update: SessionMessageUpdate) => void): () => void { + this.updates.add(listener); + return () => { this.updates.delete(listener); }; + } + private async ready(): Promise { + if (this.closed) throw new Error('Relay is closed'); + if (this.socket.connected && this.status === 'connected') return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { stop(); reject(new Error('Relay connection timed out; request was not submitted')); }, 15000); + const listener = (status: ConnectionStatus) => { + if (status === 'connected' || status === 'closed') { + clearTimeout(timer); stop(); + if (status === 'connected') resolve(); else reject(new Error('Relay is closed')); + } + }; + const stop = () => { this.statusListeners.delete(listener); }; + this.statusListeners.add(listener); + listener(this.status); + }); + } + async call(deviceId: string, encryptedParams: unknown, options: { timeoutMs?: number; beforeSend?: () => void } = {}): Promise { + if (this.closed) throw new Error('Relay is closed; request was not submitted'); + await this.ready(); + options.beforeSend?.(); + const epoch = this.epoch; + const params = await this.payloads.uploadIfLarge(encryptedParams); + options.beforeSend?.(); + if (this.closed || this.epoch !== epoch || this.status !== 'connected') { + throw new Error('Relay connection changed; request was not submitted'); + } + const timeoutMs = options.timeoutMs ?? DEFAULT_RPC_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) throw new Error('Invalid RPC timeout'); + const response = await this.socket.timeout(timeoutMs).emitWithAck('rpc-call', { + method: `${deviceId}:invoke`, params, timeoutMs, + }); + if (this.closed || this.epoch !== epoch) throw new Error('Relay connection changed; delivery outcome is unknown'); + if (!response || response.ok !== true) throw new Error(response?.error ?? 'Relay RPC failed'); + const result = await this.payloads.resolve(response.result); + options.beforeSend?.(); + if (this.closed || this.epoch !== epoch) throw new Error('Relay connection changed; delivery outcome is unknown'); + return result as T; + } + close(): void { + if (this.closed) return; + this.closed = true; this.epoch++; + this.payloads.close(); + this.socket.disconnect(); + this.socket.removeAllListeners(); + this.setStatus('closed'); + this.statusListeners.clear(); this.reconnects.clear(); this.updates.clear(); this.directoryChanges.clear(); + } +} + +function isSessionMessageUpdate(value: unknown): value is SessionMessageUpdate { + if (!value || typeof value !== 'object') return false; + const update = value as Partial; + const message = update.body?.message; + return update.body?.t === 'new-message' && typeof update.body.sid === 'string' + && Number.isSafeInteger(update.seq) && !!message && Number.isSafeInteger(message.seq) + && message.seq > 0 && typeof message.id === 'string' + && typeof message.localId === 'string' && message.content?.t === 'encrypted' + && typeof message.content.c === 'string'; +} + +export interface MessagePage { messages: DurableMessage[]; hasMore: boolean } +export interface MessageReplica { + /** Durable receive cursor. Upload acknowledgements never write this value. */ + cursor(): Promise; + /** Commit messages and cursor together, after decryption/application succeeds. */ + apply(messages: readonly DurableMessage[], cursor: number): Promise; +} + +/** Happy's after_seq recovery with a single in-flight reader and invalidation + * coalescing. Live updates wake this owner; they do not race a second reducer. + * The replica commits only contiguous messages, including our own echoes. */ +export class SessionSync { + private dirty = false; + private fetchRequired = true; + private running: Promise | null = null; + private closed = false; + private retry: ReturnType | null = null; + private retryDelay = 1000; + private readonly pending = new Map(); + private readonly stopUpdate: () => void; + private readonly stopReconnect: () => void; + constructor( + connection: Pick, + sessionId: string, + private readonly replica: MessageReplica, + private readonly fetchAfter: (cursor: number) => Promise, + private readonly onError: (error: unknown) => void, + private readonly onCaughtUp: () => void = () => {}, + ) { + this.stopUpdate = connection.onUpdate(update => { + if (update.body.sid !== sessionId || this.closed) return; + if (this.pending.size >= 100) { + // The committed log is the delivery authority. A burst can collapse + // its notifications without discarding any committed message. + this.pending.clear(); this.fetchRequired = true; + } + this.pending.set(update.body.message.seq, update.body.message); + this.wake(); + }); + this.stopReconnect = connection.onReconnect(() => this.invalidate()); + this.invalidate(); + } + invalidate(): void { this.fetchRequired = true; this.wake(); } + private wake(): void { + if (this.closed) return; + this.dirty = true; + if (this.running || this.retry) return; + this.running = this.drain().then(() => { this.retryDelay = 1000; if (!this.closed) this.onCaughtUp(); }).catch(error => { + if (this.closed) return; + this.fetchRequired = true; + this.retry = setTimeout(() => { this.retry = null; this.wake(); }, this.retryDelay); + this.retryDelay = Math.min(30000, this.retryDelay * 2); + this.onError(error); + }).finally(() => { + this.running = null; + if (this.dirty && !this.closed && !this.retry) this.wake(); + }); + } + private async drain(): Promise { + while (this.dirty && !this.closed) { + this.dirty = false; + let cursor = await this.replica.cursor(); + for (const seq of this.pending.keys()) if (seq <= cursor) this.pending.delete(seq); + const contiguous: DurableMessage[] = []; + while (this.pending.has(cursor + contiguous.length + 1)) { + contiguous.push(this.pending.get(cursor + contiguous.length + 1)!); + } + if (contiguous.length) { + if (this.closed) return; + await this.replica.apply(contiguous, cursor + contiguous.length); + for (const message of contiguous) this.pending.delete(message.seq); + cursor += contiguous.length; + } + if (!this.fetchRequired && this.pending.size === 0) continue; + this.fetchRequired = false; + let more = true; + while (more && !this.closed) { + const page = await this.fetchAfter(cursor); + if (this.closed) return; + let next = cursor; + for (const message of page.messages) { + if (message.seq !== next + 1) throw new Error('Relay message sequence is not contiguous'); + next = message.seq; + } + if (page.hasMore && next === cursor) throw new Error('Relay message pagination did not advance'); + if (next > cursor) { + await this.replica.apply(page.messages, next); + for (const message of page.messages) this.pending.delete(message.seq); + } + cursor = next; + more = page.hasMore; + } + for (const seq of this.pending.keys()) if (seq <= cursor) this.pending.delete(seq); + if (this.pending.has(cursor + 1)) this.dirty = true; + else if (this.pending.size > 0) throw new Error('Relay log has not supplied the notified message sequence'); + } + } + close(): void { + this.closed = true; + if (this.retry) clearTimeout(this.retry); + this.pending.clear(); this.stopUpdate(); this.stopReconnect(); + } +} diff --git a/src/shared/relay-transport/InvalidationSync.ts b/src/shared/relay-transport/InvalidationSync.ts new file mode 100644 index 0000000000..62e0e4fbd9 --- /dev/null +++ b/src/shared/relay-transport/InvalidationSync.ts @@ -0,0 +1,23 @@ +/** Happy InvalidateSync ownership: one read in flight and one dirty successor. + * Read transport owns retries; this owner coalesces notifications and lifecycle. + */ +export class InvalidationSync { + private dirty = false; + private stopped = false; + private running: Promise | null = null; + constructor(private readonly read: () => Promise) {} + invalidate(): Promise { + if (this.stopped) return Promise.resolve(); + this.dirty = true; + if (!this.running) { + this.running = Promise.resolve().then(async () => { + while (this.dirty && !this.stopped) { + this.dirty = false; + await this.read(); + } + }).finally(() => { this.running = null; }); + } + return this.running; + } + stop(): void { this.stopped = true; this.dirty = false; } +} diff --git a/src/shared/relay-transport/RpcPayload.ts b/src/shared/relay-transport/RpcPayload.ts new file mode 100644 index 0000000000..bff12f88f3 --- /dev/null +++ b/src/shared/relay-transport/RpcPayload.ts @@ -0,0 +1,66 @@ +/** Short-lived opaque bulk transfers. References stay on the configured Relay. */ +const INLINE_BYTES = 128 * 1024; +const MAX_BYTES = 64 * 1024 * 1024; + +export class RpcPayload { + private readonly endpoint: string; + private readonly lifetime = new AbortController(); + constructor(url: string, private readonly token: string) { + const base = new URL(url); + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) { + throw new Error('Invalid Relay URL'); + } + this.endpoint = `${base.href.replace(/\/$/, '')}/v1/rpc/payloads`; + } + close(): void { this.lifetime.abort(); } + async uploadIfLarge(value: unknown): Promise { + const body = new TextEncoder().encode(JSON.stringify(value)); + if (body.length <= INLINE_BYTES) return value; + if (body.length > MAX_BYTES) throw new Error('RPC payload exceeds a transfer block; use paginated or chunked operations'); + const response = await fetch(this.endpoint, { + method: 'POST', headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/octet-stream' }, + body, signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(120000)]), + }); + if (!response.ok) throw new Error(`Relay payload upload failed (${response.status}); request was not submitted`); + const reference: unknown = await response.json(); + validate(reference); + return reference; + } + async resolve(value: unknown): Promise { + if (!value || typeof value !== 'object' || !('$relayPayload' in value)) return value as T; + const { id, bytes } = validate(value); + const response = await fetch(`${this.endpoint}/${id}`, { + headers: { Authorization: `Bearer ${this.token}` }, + signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(120000)]), + }); + if (!response.ok) throw new Error(`Relay payload download failed (${response.status})`); + const declared = response.headers.get('content-length'); + if (declared !== null && Number(declared) !== bytes) throw new Error('Relay payload length mismatch'); + const reader = response.body?.getReader(); + if (!reader) throw new Error('Relay payload response has no body'); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + size += chunk.value.length; + if (size > bytes) throw new Error('Relay payload length mismatch'); + chunks.push(chunk.value); + } + if (size !== bytes) throw new Error('Incomplete Relay payload transfer'); + const all = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { all.set(chunk, offset); offset += chunk.length; } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(all)) as T; + } finally { await reader.cancel(); reader.releaseLock(); } + } +} +function validate(value: unknown): { id: string; bytes: number } { + const ref = (value as { $relayPayload?: { id?: unknown; bytes?: unknown } })?.$relayPayload; + if (!ref || typeof ref.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref.id) + || typeof ref.bytes !== 'number' || !Number.isSafeInteger(ref.bytes) || ref.bytes <= 0 || ref.bytes > MAX_BYTES) { + throw new Error('Invalid Relay payload reference'); + } + return { id: ref.id, bytes: ref.bytes }; +} diff --git a/src/shared/relay-transport/RpcPolicy.ts b/src/shared/relay-transport/RpcPolicy.ts new file mode 100644 index 0000000000..048e5678f4 --- /dev/null +++ b/src/shared/relay-transport/RpcPolicy.ts @@ -0,0 +1,2 @@ +/** Shared acknowledged RPC deadline; admission belongs to the Relay byte budget. */ +export const DEFAULT_RPC_TIMEOUT_MS = 120_000; diff --git a/src/shared/relay-transport/SessionCipher.ts b/src/shared/relay-transport/SessionCipher.ts new file mode 100644 index 0000000000..1d81ebafba --- /dev/null +++ b/src/shared/relay-transport/SessionCipher.ts @@ -0,0 +1,78 @@ +/** Encrypted durable event framing shared with remote_connect/session_log.rs. + * Fragment storage belongs to the durable replica, so reconnect/restart never + * depends on an in-memory partial event. */ +export interface SessionFragment { + v: 1; + eventId: string; + index: number; + count: number; + data: string; +} +export type SessionDecrypt = (key: Uint8Array, ciphertext: Uint8Array, nonce: Uint8Array) => Uint8Array | Promise; + +export interface SessionEvent { + session_id: string; + event: string; + payload: unknown; +} + +export function parseSessionFragment(content: string): SessionFragment { + const value: unknown = JSON.parse(content); + if (!value || typeof value !== 'object') throw new Error('Invalid session fragment'); + const fragment = value as Partial; + if (fragment.v !== 1 || typeof fragment.eventId !== 'string' + || typeof fragment.data !== 'string' || !Number.isSafeInteger(fragment.index) + || !Number.isSafeInteger(fragment.count) || fragment.index! < 0 + || fragment.count! < 1 || fragment.index! >= fragment.count!) { + throw new Error('Invalid session fragment'); + } + return fragment as SessionFragment; +} + +function decodeBase64(value: string): Uint8Array { + return Uint8Array.from(atob(value), character => character.charCodeAt(0)); +} + +/** Read fragments from persistent storage in order. Allocation follows actual + * bytes present, never the peer-provided fragment count. */ +export async function decryptSessionEvent( + sessionId: string, + keyGrant: string, + fragments: readonly SessionFragment[], + decrypt?: SessionDecrypt, +): Promise { + if (!fragments.length) throw new Error('Missing session fragments'); + const first = fragments[0]; + if (first.count !== fragments.length) throw new Error('Incomplete session event'); + const parts = fragments.map((fragment, index) => { + if (fragment.index !== index || fragment.eventId !== first.eventId || fragment.count !== first.count) { + throw new Error('Session fragment order mismatch'); + } + return decodeBase64(fragment.data); + }); + const bytes = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { bytes.set(part, offset); offset += part.length; } + const envelope: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); + if (!envelope || typeof envelope !== 'object') throw new Error('Invalid session ciphertext'); + const ciphertext = envelope as { nonce?: unknown; data?: unknown }; + if (typeof ciphertext.nonce !== 'string' || typeof ciphertext.data !== 'string') { + throw new Error('Invalid session ciphertext'); + } + const keyBytes = decodeBase64(keyGrant); + if (keyBytes.length !== 32) throw new Error('Invalid session key'); + const nonce = decodeBase64(ciphertext.nonce); + if (nonce.length !== 12) throw new Error('Invalid session nonce'); + const data = decodeBase64(ciphertext.data); + let plaintext: Uint8Array | ArrayBuffer; + if (decrypt) plaintext = await decrypt(keyBytes, data, nonce); + else { + const key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']); + plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, data); + } + const event = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plaintext)) as SessionEvent; + if (event.session_id !== sessionId || typeof event.event !== 'string' || !('payload' in event)) { + throw new Error('Session encryption binding mismatch'); + } + return event; +} diff --git a/src/shared/relay-transport/SessionRecordReplica.ts b/src/shared/relay-transport/SessionRecordReplica.ts new file mode 100644 index 0000000000..3febcf58cc --- /dev/null +++ b/src/shared/relay-transport/SessionRecordReplica.ts @@ -0,0 +1,123 @@ +/** Stable structural record contract; extra runtime fields survive spread/materialization. */ +export interface TextItemData { id: string; orderIndex?: number; } +export interface ThinkingItemData { id: string; orderIndex?: number; } +export interface ToolItemData { id: string; orderIndex?: number; } +export interface ModelRoundData { id: string; turnId: string; roundIndex: number; textItems: TextItemData[]; thinkingItems?: ThinkingItemData[]; toolItems: ToolItemData[]; } +export interface DialogTurnData { turnId: string; sessionId: string; modelRounds: ModelRoundData[]; } + +export interface SessionRecordUpsert { + sessionId: string; + id: string; + revision: number; + deleted?: false; + turn: Omit; + round?: Omit; + item?: { type: 'text'; data: TextItemData } + | { type: 'thinking'; data: ThinkingItemData } + | { type: 'tool'; data: ToolItemData }; +} +export type SessionRecord = SessionRecordUpsert | { sessionId: string; id: string; revision: number; deleted: true }; +export interface SessionRecordChange { turnId: string; turn: DialogTurnData | null; } + +type Versioned = { revision: number; value: T }; +type RoundEntry = { + header: Versioned>; + items: Map>>; +}; +type TurnEntry = { + header: Versioned; + rounds: Map; +}; + +/** + * Live and fetched records enter the same reducer, as in Happy's message map. + * Runtime revisions also make newest-first history safe for mutable blocks. + * This instance belongs to one (device surface, session), never a global ID. + */ +export class SessionRecordReplica { + private readonly records = new Map(); + private readonly turns = new Map(); + private readonly deleted = new Map(); + + constructor(readonly sessionId: string) {} + + apply(record: SessionRecord): SessionRecordChange | null { + if (record.sessionId !== this.sessionId || (!record.deleted && record.turn.sessionId !== this.sessionId)) { + throw new Error('Session record belongs to another session'); + } + if (!Number.isSafeInteger(record.revision) || record.revision < 1) { + throw new Error('Invalid session record revision'); + } + if ((this.records.get(record.id) ?? 0) >= record.revision) return null; + if (record.deleted) { + if (!/^(turn|round|item)\/.+/.test(record.id)) throw new Error('Invalid session record identity'); + this.records.set(record.id, record.revision); + this.deleted.set(record.id, record.revision); + for (const [turnId, turn] of this.turns) { + if (record.id === `turn/${turnId}` || [...turn.rounds.entries()].some(([roundId, round]) => ( + record.id === `round/${roundId}` || [...round.items.keys()].some(itemId => record.id === `item/${itemId}`) + ))) return this.change(turnId, turn); + } + return record.id.startsWith('turn/') ? { turnId: record.id.slice(5), turn: null } : null; + } + const turnId = record.turn.turnId; + if (!turnId || (record.round && record.round.turnId !== turnId) || (record.item && !record.round)) { + throw new Error('Invalid session record ancestry'); + } + const expectedId = record.item ? `item/${record.item.data.id}` + : record.round ? `round/${record.round.id}` : `turn/${turnId}`; + if (record.id !== expectedId) throw new Error('Invalid session record identity'); + + let turn = this.turns.get(turnId); + if (!turn) { + turn = { header: { revision: record.revision, value: record.turn }, rounds: new Map() }; + this.turns.set(turnId, turn); + } else if (record.revision > turn.header.revision) { + turn.header = { revision: record.revision, value: record.turn }; + } + if (record.round) { + let round = turn.rounds.get(record.round.id); + if (!round) { + round = { header: { revision: record.revision, value: record.round }, items: new Map() }; + turn.rounds.set(record.round.id, round); + } else if (record.revision > round.header.revision) { + round.header = { revision: record.revision, value: record.round }; + } + if (record.item) { + const existing = round.items.get(record.item.data.id); + if (!existing || existing.revision < record.revision) { + round.items.set(record.item.data.id, { revision: record.revision, value: record.item }); + } + } + } + this.records.set(record.id, record.revision); + return this.change(turnId, turn); + } + + private change(turnId: string, turn: TurnEntry): SessionRecordChange { + return { turnId, turn: turn.header.revision <= (this.deleted.get(`turn/${turnId}`) ?? 0) + ? null : this.materialize(turnId, turn) }; + } + + private materialize(turnId: string, turn: TurnEntry): DialogTurnData { + const turnFence = this.deleted.get(`turn/${turnId}`) ?? 0; + return { + ...turn.header.value, + modelRounds: [...turn.rounds.values()].filter(round => round.header.revision > Math.max(turnFence, this.deleted.get(`round/${round.header.value.id}`) ?? 0)).map(round => { + const roundFence = Math.max(turnFence, this.deleted.get(`round/${round.header.value.id}`) ?? 0); + const result: ModelRoundData = { ...round.header.value, textItems: [], thinkingItems: [], toolItems: [] }; + for (const { value: item, revision } of round.items.values()) { + if (revision <= Math.max(roundFence, this.deleted.get(`item/${item.data.id}`) ?? 0)) continue; + if (item.type === 'text') result.textItems.push(item.data); + else if (item.type === 'thinking') result.thinkingItems!.push(item.data); + else result.toolItems.push(item.data); + } + const order = (a: { orderIndex?: number }, b: { orderIndex?: number }): number => (a.orderIndex ?? 0) - (b.orderIndex ?? 0); + result.textItems.sort(order); + result.thinkingItems!.sort(order); + result.toolItems.sort(order); + return result; + }).sort((a, b) => a.roundIndex - b.roundIndex), + }; + } +} diff --git a/src/shared/relay-transport/SessionStream.ts b/src/shared/relay-transport/SessionStream.ts new file mode 100644 index 0000000000..339e5b11b5 --- /dev/null +++ b/src/shared/relay-transport/SessionStream.ts @@ -0,0 +1,149 @@ +import { AccountRealtime, SessionSync, type DurableMessage, type MessageReplica } from './AccountRealtime'; +import { decryptSessionEvent, parseSessionFragment, type SessionEvent, type SessionFragment, type SessionDecrypt } from './SessionCipher'; + +/** Controller-only cache. The host log remains authoritative. Cursor and raw + * encrypted fragments commit together; a killed browser resumes mid-event. */ +class BrowserSessionReplica implements MessageReplica { + constructor(private readonly db: IDBDatabase, private readonly stream: string, + private readonly sessionId: string, private readonly key: string, + private readonly emit: (event: SessionEvent) => void, private readonly decrypt?: SessionDecrypt) {} + private request(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); + } + async cursor(): Promise { + return (await this.request(this.db.transaction('cursors').objectStore('cursors').get(this.stream)) as number | undefined) ?? 0; + } + private writing = Promise.resolve(); + apply(messages: readonly DurableMessage[], cursor: number, history?: SessionHistoryState): Promise { + const operation = this.writing.then(() => this.commit(messages, cursor, history)); + this.writing = operation.catch(() => {}); + return operation; + } + async history(): Promise { + const state = await this.request(this.db.transaction('cursors').objectStore('cursors').get(this.stream + ':history')) as SessionHistoryState | undefined; + return state ?? {hasMore:false,oldestSeq:0,cursor:await this.cursor()}; + } + async setHistory(state: SessionHistoryState): Promise { + await this.request(this.db.transaction('cursors','readwrite').objectStore('cursors').put(state,this.stream + ':history')); + } + private async commit(messages: readonly DurableMessage[], cursor: number, history?: SessionHistoryState): Promise { + cursor = Math.max(cursor, await this.cursor()); + const incoming = messages.map(message => parseSessionFragment(message.content.c)); + const finished = new Set(incoming.filter(part => part.index === part.count - 1).map(part => part.eventId)); + const events: SessionEvent[] = []; + for (const eventId of finished) { + const range = IDBKeyRange.bound([this.stream, eventId, 0], [this.stream, eventId, Number.MAX_SAFE_INTEGER]); + const stored = await this.request(this.db.transaction('fragments').objectStore('fragments').getAll(range)) as SessionFragment[]; + const parts = new Map(stored.map(part => [part.index, part])); + for (const part of incoming) if (part.eventId === eventId) parts.set(part.index, part); + events.push(await decryptSessionEvent(this.sessionId, this.key, [...parts.values()].sort((a,b) => a.index - b.index), this.decrypt)); + } + // Authentication/decryption and reducer failure must not advance the cursor. + for (const event of events) this.emit(event); + const transaction = this.db.transaction(['fragments', 'cursors'], 'readwrite'); + const complete = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error); + }); + const fragments = transaction.objectStore('fragments'); + for (const fragment of incoming) fragments.put(fragment, [this.stream, fragment.eventId, fragment.index]); + transaction.objectStore('cursors').put(cursor, this.stream); + if(history)transaction.objectStore('cursors').put({...history,cursor},this.stream + ':history'); + await complete; + } + async replay(): Promise { + const range = IDBKeyRange.bound([this.stream, ''], [this.stream, '\uffff']); + // Walk cache keys rather than materializing the entire transcript at once. + const keys = await this.request(this.db.transaction('fragments').objectStore('fragments').getAllKeys(range)); + let previous: string | undefined; + for (const key of keys) { + const eventId = (key as [string, string, number])[1]; + if (eventId === previous) continue; + previous = eventId; + const partRange = IDBKeyRange.bound([this.stream, eventId, 0], [this.stream, eventId, Number.MAX_SAFE_INTEGER]); + const parts = await this.request(this.db.transaction('fragments').objectStore('fragments').getAll(partRange)) as SessionFragment[]; + if (parts.length && parts.length === parts[0].count) this.emit(await decryptSessionEvent(this.sessionId, this.key, parts, this.decrypt)); + } + } +} + +export interface SessionHistoryState { hasMore: boolean; oldestSeq: number; cursor: number } +export interface SessionStreamHandle { close(): void; wake(): void; loadOlder(): Promise } +export async function openSessionStream(options: { + connection: AccountRealtime; relay: string; token: string; account: string; machine: string; + decrypt?: SessionDecrypt; sessionId: string; relaySessionId: string; key: string; onEvent: (event: SessionEvent) => void; onError: (error: unknown) => void; onCaughtUp?: () => void; onHistoryState?: (state: SessionHistoryState) => void; onResumed?: () => void; +}): Promise { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('openbitfun-relay-session-cache', 1); + request.onupgradeneeded = () => { + request.result.createObjectStore('fragments'); request.result.createObjectStore('cursors'); + }; + request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); + }); + const stream = JSON.stringify([options.relay, options.account, options.machine, options.sessionId]); + let active = true; + const replica = new BrowserSessionReplica(db, stream, options.sessionId, options.key, event => { + if (active) options.onEvent(event); + }, options.decrypt); + const lifetime = new AbortController(); + try { await replica.replay(); } catch (error) { db.close(); throw error; } + const fetchPage = async (query: string) => { + const response = await fetch(`${options.relay.replace(/\/$/, '')}/v3/sessions/${encodeURIComponent(options.relaySessionId)}/messages?${query}`, { + headers: { Authorization: `Bearer ${options.token}` }, signal: lifetime.signal, + }); + if (!response.ok) throw new Error(`Session catch-up failed (${response.status})`); + return await response.json() as {messages:DurableMessage[];hasMore:boolean}; + }; + let history = await replica.history(); + let older: Promise | null = null; + const fetchBefore = async (before: number) => { + const messages:DurableMessage[]=[]; + let hasMore=true; + do { + const page=await fetchPage(`before_seq=${before}&limit=100`); + const sorted=[...page.messages].sort((a,b)=>a.seq-b.seq); + if(!sorted.length){hasMore=false;break;} + if(sorted.some((message,index)=>message.content.t!=='encrypted'||(index>0&&message.seq!==sorted[index-1].seq+1)))throw new Error('History sequence gap'); + if(messages.length&&sorted.at(-1)!.seq!==before-1)throw new Error('History page gap'); + if(sorted.at(-1)!.seq>=before)throw new Error('History pagination did not advance'); + messages.unshift(...sorted);before=sorted[0].seq;hasMore=page.hasMore; + if(parseSessionFragment(messages[0].content.c).index===0)break; + if(!hasMore)throw new Error('History starts within an incomplete event'); + }while(hasMore); + const cursor=Math.max(await replica.cursor(),messages.at(-1)?.seq??0); + history={hasMore,oldestSeq:messages[0]?.seq??history.oldestSeq,cursor}; + await replica.apply(messages,cursor,history); + if(active)options.onHistoryState?.(history); + }; + try { + if(await replica.cursor()===0)await fetchBefore(Number.MAX_SAFE_INTEGER); + else options.onHistoryState?.(history); + }catch(error){lifetime.abort();db.close();throw error;} + const sync = new SessionSync(options.connection, options.relaySessionId, replica, + cursor => fetchPage(`after_seq=${cursor}`), options.onError, + () => { if(active)options.onCaughtUp?.(); }); + const resumed=()=>{if(active)options.onResumed?.();}; + const stopResume=options.connection.onReconnect(resumed); + const resume=()=>{if(document.visibilityState==='visible'){sync.invalidate();resumed();}}; + resumed(); + document.addEventListener('visibilitychange',resume); + const loadOlder = () => { + if (!active || !history.hasMore) return Promise.resolve(); + if (!older) older = fetchBefore(history.oldestSeq).finally(() => { older = null; }); + return older; + }; + // Happy starts an exclusive older-page prefetch after the latest page is visible. + // The same flight serves user scroll requests; disposal aborts both paths. + let prefetchTimer: ReturnType | undefined; + const prefetch = () => { + if (!active || !history.hasMore) return; + prefetchTimer = setTimeout(() => { + void loadOlder().then(prefetch, error => { if (active) options.onError(error); }); + }, 250); + }; + prefetch(); + return { + close(){active=false;if(prefetchTimer)clearTimeout(prefetchTimer);stopResume();document.removeEventListener('visibilitychange',resume);sync.close();lifetime.abort();db.close();}, + wake(){if(active)sync.invalidate();}, + loadOlder, + }; +} diff --git a/src/shared/relay-transport/session-cipher.fixture.json b/src/shared/relay-transport/session-cipher.fixture.json new file mode 100644 index 0000000000..2feb4345b7 --- /dev/null +++ b/src/shared/relay-transport/session-cipher.fixture.json @@ -0,0 +1,50 @@ +{ + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "sessionId": "terminal-vector", + "event": { + "session_id": "terminal-vector", + "event": "terminal-output", + "payload": { + "terminal_id": "vector", + "cursor": 42, + "text": "hello 世界" + } + }, + "fragments": [ + { + "v": 1, + "eventId": "00000000000000000001", + "index": 0, + "count": 5, + "data": "eyJub25jZSI6IklDRWlJeVFsSmljb0tTb3IiLCJkYXRhIjoicVJqVkZSL3JjMkYw" + }, + { + "v": 1, + "eventId": "00000000000000000001", + "index": 1, + "count": 5, + "data": "SXl1cTR5TFdqYlU3Z2ZYcDRRL0RHcE1QWmliNGZDVlg2NU5oNEZFWDREYUpDK0Ex" + }, + { + "v": 1, + "eventId": "00000000000000000001", + "index": 2, + "count": 5, + "data": "WUhHcGpnZUQrKzVNV0JYc1RsMXRMY3Rtc3JsQzd6SHR0Wkh6K1FlQVNkb3haZ2l4" + }, + { + "v": 1, + "eventId": "00000000000000000001", + "index": 3, + "count": 5, + "data": "dFFrbXdaN2xhcUE5NEFZNGYrdXVjVHRjZFBKd3JOL3I2TUYvMTlUM01SajA0eTcr" + }, + { + "v": 1, + "eventId": "00000000000000000001", + "index": 4, + "count": 5, + "data": "L2t1MXhoV1NnOWY2Ly9rby9hYjlUeE01WUNkejZZV3ZQaFk9In0=" + } + ] +} diff --git a/src/shared/terminal/TerminalInputQueue.ts b/src/shared/terminal/TerminalInputQueue.ts new file mode 100644 index 0000000000..09de386627 --- /dev/null +++ b/src/shared/terminal/TerminalInputQueue.ts @@ -0,0 +1,82 @@ +/** + * Coalesces terminal input into batched, sequentially-ordered writes. + * + * Problem: xterm.js fires `onData` once per keystroke. If each call triggers a + * separate fire-and-forget `invoke('terminal_write')`, rapid typing creates a + * burst of concurrent IPC calls. On macOS (WKWebView) this can cause character + * loss because: + * - concurrent async command handlers are not guaranteed FIFO ordering after + * the `get_or_init_api` mutex releases, + * - WKWebView IPC has higher latency and bursts of concurrent invokes can + * interfere with each other. + * + * Solution: buffer incoming data and flush it as a single batched write. Only + * one flush is in flight at a time, so ordering is guaranteed and IPC overhead + * is reduced. The first keystroke is dispatched via a microtask (near-zero + * latency); subsequent keystrokes that arrive while a flush is in flight are + * automatically coalesced into the next flush. + * + * IME safety: xterm.js fires `onData` only after IME composition completes, so + * this queue never interferes with input-method candidate selection or the + * Enter-key guard in chat input components. + */ +export class TerminalInputQueue { + private buffer = ''; + private flushing = false; + private readonly write: (data: string) => Promise; + private readonly onError: (error: unknown) => void; + + constructor( + write: (data: string) => Promise, + onError: (error: unknown) => void, + ) { + this.write = write; + this.onError = onError; + } + + /** + * Append data to the buffer and schedule a flush if one is not already in + * progress. + */ + enqueue(data: string): void { + this.buffer += data; + if (!this.flushing) { + this.flushing = true; + queueMicrotask(() => { + void this.drain(); + }); + } + } + + /** + * Flush the current buffer contents to the write function. After the write + * completes, if more data arrived during the flush, drain again; otherwise + * mark the queue as idle. + */ + private async drain(): Promise { + const data = this.buffer; + this.buffer = ''; + try { + if (data) { + await this.write(data); + } + } catch (err) { + this.onError(err); + } finally { + if (this.buffer) { + // More data arrived during the flush — drain again. + void this.drain(); + } else { + this.flushing = false; + } + } + } + + /** + * Discard any buffered data that has not been flushed yet. Data that is + * already mid-flush is unaffected. + */ + clear(): void { + this.buffer = ''; + } +} diff --git a/src/shared/terminal/webview/README.md b/src/shared/terminal/webview/README.md new file mode 100644 index 0000000000..c3cab565a7 --- /dev/null +++ b/src/shared/terminal/webview/README.md @@ -0,0 +1,11 @@ +# Native terminal renderer + +This trusted bundled xterm renderer is shared by Android, iOS, and HarmonyOS. It contains no account credentials, network client, runtime, or ANSI parser. Native adapters own the selected runtime and its PTY lifecycle. + +Run `node scripts/build-mobile-terminal.mjs` after changing this source or xterm dependencies. Android assets and the iOS folder resource use `generated/`; add `--harmony` to copy the same output to Harmony rawfile resources. Keep generated assets in sync so native offline builds do not need a JavaScript package install. + +Native sends `OpenBitFunTerminal.accept({epoch, revision, reset, data, theme?})`. Epoch identifies the PTY, revision orders local presentation frames, and reset carries the bounded replay tail after attachment or a dropped presentation frame. The renderer serializes xterm writes before applying the next reset. Older revisions are ignored; a gap requests resynchronization. + +The bridge emits JSON `ready`, `resync`, `input {data}`, and `resize {cols, rows}` events. Android/Harmony expose `OpenBitFunTerminalHost.postMessage`; iOS uses `webkit.messageHandlers.openbitfunTerminal.postMessage`. `connect()` repeats the ready handshake after native navigation completion. Input execution, batching, resize coalescing, failure state, and disposal belong to the native runtime store. + +Verify the actual bundled assets and keyboard/ANSI behavior with `pnpm --dir src/mobile-web run test:terminal-browser`. Native compilation is additional adapter evidence, not a replacement for a device communicating with a runtime through Relay. diff --git a/src/shared/terminal/webview/generated/index.html b/src/shared/terminal/webview/generated/index.html new file mode 100644 index 0000000000..44ca2cf19c --- /dev/null +++ b/src/shared/terminal/webview/generated/index.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/src/shared/terminal/webview/generated/terminal.css b/src/shared/terminal/webview/generated/terminal.css new file mode 100644 index 0000000000..8bacbfd719 --- /dev/null +++ b/src/shared/terminal/webview/generated/terminal.css @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{overflow-y:scroll;cursor:default;position:absolute;inset:0}.xterm:not(.allow-transparency) .xterm-viewport{background-color:#000}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;inset:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{font-family:monospace;user-select:text;white-space:pre}.xterm .xterm-accessibility-tree>div{transform-origin:left;width:fit-content}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;background:#0000;transition:opacity .1s linear;z-index:11}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{position:absolute;display:none}.xterm .xterm-scrollable-element>.shadow.top{display:block;top:0;left:3px;height:3px;width:100%;box-shadow:var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset}.xterm .xterm-scrollable-element>.shadow.left{display:block;top:3px;left:0;height:100%;width:3px;box-shadow:var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset}.xterm .xterm-scrollable-element>.shadow.top-left-corner{display:block;top:0;left:0;height:3px;width:3px}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset} diff --git a/src/shared/terminal/webview/generated/terminal.js b/src/shared/terminal/webview/generated/terminal.js new file mode 100644 index 0000000000..7a074ead05 --- /dev/null +++ b/src/shared/terminal/webview/generated/terminal.js @@ -0,0 +1,58 @@ +(()=>{/** + * Copyright (c) 2014-2024 The xterm.js authors. All rights reserved. + * @license MIT + * + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * @license MIT + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + */var $r=Object.defineProperty,Qn=Object.getOwnPropertyDescriptor,eo=(e,t)=>{for(var i in t)$r(e,i,{get:t[i],enumerable:!0})},K=(e,t,i,s)=>{for(var r=s>1?void 0:s?Qn(t,i):t,n=e.length-1,o;n>=0;n--)(o=e[n])&&(r=(s?o(t,i,r):o(r))||r);return s&&r&&$r(t,i,r),r},w=(e,t)=>(i,s)=>t(i,s,e),Is="Terminal input",Ti={get:()=>Is,set:e=>Is=e},Ns="Too much output to announce, navigate to rows manually to read",Zt={get:()=>Ns,set:e=>Ns=e};function to(e){return e.replace(/\r?\n/g,"\r")}function io(e,t){return t?"\x1B[200~"+e+"\x1B[201~":e}function so(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()}function ro(e,t,i,s){if(e.stopPropagation(),e.clipboardData){let r=e.clipboardData.getData("text/plain");Ur(r,t,i,s)}}function Ur(e,t,i,s){e=to(e),e=io(e,i.decPrivateModes.bracketedPasteMode&&s.rawOptions.ignoreBracketedPasteMode!==!0),i.triggerDataEvent(e,!0),t.value=""}function qr(e,t,i){let s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}function Fs(e,t,i,s,r){qr(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}function Ve(e){return e>65535?(e-=65536,String.fromCharCode((e>>10)+55296)+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)}function at(e,t=0,i=e.length){let s="";for(let r=t;r65535?(n-=65536,s+=String.fromCharCode((n>>10)+55296)+String.fromCharCode(n%1024+56320)):s+=String.fromCharCode(n)}return s}var no=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){let i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){let n=e.charCodeAt(r++);56320<=n&&n<=57343?t[s++]=(this._interim-55296)*1024+n-56320+65536:(t[s++]=this._interim,t[s++]=n),this._interim=0}for(let n=r;n=i)return this._interim=o,s;let h=e.charCodeAt(n);56320<=h&&h<=57343?t[s++]=(o-55296)*1024+h-56320+65536:(t[s++]=o,t[s++]=h);continue}o!==65279&&(t[s++]=o)}return s}},oo=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){let i=e.length;if(!i)return 0;let s=0,r,n,o,h,l=0,a=0;if(this.interim[0]){let u=!1,f=this.interim[0];f&=(f&224)===192?31:(f&240)===224?15:7;let _=0,p;for(;(p=this.interim[++_]&63)&&_<4;)f<<=6,f|=p;let y=(this.interim[0]&224)===192?2:(this.interim[0]&240)===224?3:4,E=y-_;for(;a=i)return 0;if(p=e[a++],(p&192)!==128){a--,u=!0;break}else this.interim[_++]=p,f<<=6,f|=p&63}u||(y===2?f<128?a--:t[s++]=f:y===3?f<2048||f>=55296&&f<=57343||f===65279||(t[s++]=f):f<65536||f>1114111||(t[s++]=f)),this.interim.fill(0)}let d=i-4,c=a;for(;c=i)return this.interim[0]=r,s;if(n=e[c++],(n&192)!==128){c--;continue}if(l=(r&31)<<6|n&63,l<128){c--;continue}t[s++]=l}else if((r&240)===224){if(c>=i)return this.interim[0]=r,s;if(n=e[c++],(n&192)!==128){c--;continue}if(c>=i)return this.interim[0]=r,this.interim[1]=n,s;if(o=e[c++],(o&192)!==128){c--;continue}if(l=(r&15)<<12|(n&63)<<6|o&63,l<2048||l>=55296&&l<=57343||l===65279)continue;t[s++]=l}else if((r&248)===240){if(c>=i)return this.interim[0]=r,s;if(n=e[c++],(n&192)!==128){c--;continue}if(c>=i)return this.interim[0]=r,this.interim[1]=n,s;if(o=e[c++],(o&192)!==128){c--;continue}if(c>=i)return this.interim[0]=r,this.interim[1]=n,this.interim[2]=o,s;if(h=e[c++],(h&192)!==128){c--;continue}if(l=(r&7)<<18|(n&63)<<12|(o&63)<<6|h&63,l<65536||l>1114111)continue;t[s++]=l}}return s}},Pt=class Vr{constructor(){this.fg=0,this.bg=0,this.extended=new Qt}static toColorRGB(t){return[t>>>16&255,t>>>8&255,t&255]}static fromColorRGB(t){return(t[0]&255)<<16|(t[1]&255)<<8|t[2]&255}clone(){let t=new Vr;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},Qt=class Yr{constructor(t=0,i=0){this._ext=0,this._urlId=0,this._ext=t,this._urlId=i}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(t){this._ext=t}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(t){this._ext&=-469762049,this._ext|=t<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(t){this._ext&=-67108864,this._ext|=t&67108863}get urlId(){return this._urlId}set urlId(t){this._urlId=t}get underlineVariantOffset(){let t=(this._ext&3758096384)>>29;return t<0?t^4294967288:t}set underlineVariantOffset(t){this._ext&=536870911,this._ext|=t<<29&3758096384}clone(){return new Yr(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}},ke=class jr extends Pt{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new Qt,this.combinedData=""}static fromCharData(t){let i=new jr;return i.setFromCharData(t),i}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?Ve(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(t){this.fg=t[0],this.bg=0;let i=!1;if(t[1].length>2)i=!0;else if(t[1].length===2){let s=t[1].charCodeAt(0);if(55296<=s&&s<=56319){let r=t[1].charCodeAt(1);56320<=r&&r<=57343?this.content=(s-55296)*1024+r-56320+65536|t[2]<<22:i=!0}else i=!0}else this.content=t[1].charCodeAt(0)|t[2]<<22;i&&(this.combinedData=t[1],this.content=2097152|t[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}},Hs="di$target",Ai="di$dependencies",pi=new Map;function ao(e){return e[Ai]||[]}function G(e){if(pi.has(e))return pi.get(e);let t=function(i,s,r){if(arguments.length!==3)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");ho(t,i,r)};return t._id=e,pi.set(e,t),t}function ho(e,t,i){t[Hs]===t?t[Ai].push({id:e,index:i}):(t[Ai]=[{id:e,index:i}],t[Hs]=t)}var ce=G("BufferService"),Xr=G("CoreMouseService"),je=G("CoreService"),lo=G("CharsetService"),Ss=G("InstantiationService"),Gr=G("LogService"),he=G("OptionsService"),Jr=G("OscLinkService"),co=G("UnicodeService"),Tt=G("DecorationService"),Oi=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new ke}provideLinks(e,t){let i=this._bufferService.buffer.lines.get(e-1);if(!i){t(void 0);return}let s=[],r=this._optionsService.rawOptions.linkHandler,n=this._workCell,o=i.getTrimmedLength(),h=-1,l=-1,a=!1;for(let d=0;dr?r.activate(_,p,u):uo(_,p),hover:(_,p)=>r?.hover?.(_,p,u),leave:(_,p)=>r?.leave?.(_,p,u)})}a=!1,n.hasExtendedAttrs()&&n.extended.urlId?(l=d,h=n.extended.urlId):(l=-1,h=-1)}}t(s)}};Oi=K([w(0,ce),w(1,he),w(2,Jr)],Oi);function uo(e,t){if(confirm(`Do you want to navigate to ${t}? + +WARNING: This link could potentially be dangerous`)){let i=window.open();if(i){try{i.opener=null}catch{}i.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}var hi=G("CharSizeService"),Ie=G("CoreBrowserService"),ws=G("MouseService"),Ne=G("RenderService"),_o=G("SelectionService"),Zr=G("CharacterJoinerService"),ht=G("ThemeService"),Qr=G("LinkProviderService"),fo=G("KeyboardService");function q(e){return{dispose:e}}function Ii(e){if(e)if(Array.isArray(e))for(let t of e)t.dispose();else e.dispose()}var bs=class{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(let e of this._disposables)e.dispose();this._disposables.clear()}}},I=class{constructor(){this._store=new bs}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}},ot=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},me=typeof window=="object"?window:globalThis,po=class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?Ws.isErrorNoTelemetry(e)?new Ws(e.message+` + +`+e.stack):new Error(e.message+` + +`+e.stack):e},0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach(t=>{t(e)})}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}},go=new po;function Kt(e){vo(e)||go.onUnexpectedError(e)}var Ni="Canceled";function vo(e){return e instanceof mo?!0:e instanceof Error&&e.name===Ni&&e.message===Ni}var mo=class extends Error{constructor(){super(Ni),this.name=this.message}};function So(e){return e?new Error(`Illegal argument: ${e}`):new Error("Illegal argument")}var Ws=class Fi extends Error{constructor(t){super(t),this.name="CodeExpectedError"}static fromError(t){if(t instanceof Fi)return t;let i=new Fi;return i.message=t.message,i.stack=t.stack,i}static isErrorNoTelemetry(t){return t.name==="CodeExpectedError"}},Hi=class en extends Error{constructor(t){super(t||"An unexpected bug occurred."),Object.setPrototypeOf(this,en.prototype)}};function wo(e,t){let i=this,s=!1,r;return function(){if(s)return r;if(s=!0,t)try{r=e.apply(i,arguments)}finally{t()}else r=e.apply(i,arguments);return r}}function bo(e,t,i=0,s=e.length){let r=i,n=s;for(;r{function t(n){return n<0}e.isLessThan=t;function i(n){return n<=0}e.isLessThanOrEqual=i;function s(n){return n>0}e.isGreaterThan=s;function r(n){return n===0}e.isNeitherLessOrGreaterThan=r,e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0})(sn||(sn={}));function Co(e,t){return(i,s)=>t(e(i),e(s))}var ko=(e,t)=>e-t,zs=class Wi{constructor(t){this.iterate=t}forEach(t){this.iterate(i=>(t(i),!0))}toArray(){let t=[];return this.iterate(i=>(t.push(i),!0)),t}filter(t){return new Wi(i=>this.iterate(s=>t(s)?i(s):!0))}map(t){return new Wi(i=>this.iterate(s=>i(t(s))))}some(t){let i=!1;return this.iterate(s=>(i=t(s),!i)),i}findFirst(t){let i;return this.iterate(s=>t(s)?(i=s,!1):!0),i}findLast(t){let i;return this.iterate(s=>(t(s)&&(i=s),!0)),i}findLastMaxBy(t){let i,s=!0;return this.iterate(r=>((s||sn.isGreaterThan(t(r,i)))&&(s=!1,i=r),!0)),i}};zs.empty=new zs(e=>{});function xo(e,t){let i=Object.create(null);for(let s of e){let r=t(s),n=i[r];n||(n=i[r]=[]),n.push(s)}return i}var Ks,$s,zl=class{constructor(e,t){this.toKey=t,this._map=new Map,this[Ks]="SetWithKey";for(let i of e)this.add(i)}get size(){return this._map.size}add(e){let t=this.toKey(e);return this._map.set(t,e),this}delete(e){return this._map.delete(this.toKey(e))}has(e){return this._map.has(this.toKey(e))}*entries(){for(let e of this._map.values())yield[e,e]}keys(){return this.values()}*values(){for(let e of this._map.values())yield e}clear(){this._map.clear()}forEach(e,t){this._map.forEach(i=>e.call(t,i,i,this))}[($s=Symbol.iterator,Ks=Symbol.toStringTag,$s)](){return this.values()}},Eo=class{constructor(){this.map=new Map}add(e,t){let i=this.map.get(e);i||(i=new Set,this.map.set(e,i)),i.add(t)}delete(e,t){let i=this.map.get(e);i&&(i.delete(t),i.size===0&&this.map.delete(e))}forEach(e,t){let i=this.map.get(e);i&&i.forEach(t)}get(e){return this.map.get(e)||new Set}},rn;(e=>{function t(x){return x&&typeof x=="object"&&typeof x[Symbol.iterator]=="function"}e.is=t;let i=Object.freeze([]);function s(){return i}e.empty=s;function*r(x){yield x}e.single=r;function n(x){return t(x)?x:r(x)}e.wrap=n;function o(x){return x||i}e.from=o;function*h(x){for(let L=x.length-1;L>=0;L--)yield x[L]}e.reverse=h;function l(x){return!x||x[Symbol.iterator]().next().done===!0}e.isEmpty=l;function a(x){return x[Symbol.iterator]().next().value}e.first=a;function d(x,L){let P=0;for(let re of x)if(L(re,P++))return!0;return!1}e.some=d;function c(x,L){for(let P of x)if(L(P))return P}e.find=c;function*u(x,L){for(let P of x)L(P)&&(yield P)}e.filter=u;function*f(x,L){let P=0;for(let re of x)yield L(re,P++)}e.map=f;function*_(x,L){let P=0;for(let re of x)yield*L(re,P++)}e.flatMap=_;function*p(...x){for(let L of x)yield*L}e.concat=p;function y(x,L,P){let re=P;for(let ye of x)re=L(re,ye);return re}e.reduce=y;function*E(x,L,P=x.length){for(L<0&&(L+=x.length),P<0?P+=x.length:P>x.length&&(P=x.length);Li.source!==null&&!this.getRootParent(i,t).isSingleton).flatMap(([i])=>i)}computeLeakingDisposables(t=10,i){let s;if(i)s=i;else{let l=new Map,a=[...this.livingDisposables.values()].filter(c=>c.source!==null&&!this.getRootParent(c,l).isSingleton);if(a.length===0)return;let d=new Set(a.map(c=>c.value));if(s=a.filter(c=>!(c.parent&&d.has(c.parent))),s.length===0)throw new Error("There are cyclic diposable chains!")}if(!s)return;function r(l){function a(c,u){for(;c.length>0&&u.some(f=>typeof f=="string"?f===c[0]:c[0].match(f));)c.shift()}let d=l.source.split(` +`).map(c=>c.trim().replace("at ","")).filter(c=>c!=="");return a(d,["Error",/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),d.reverse()}let n=new Eo;for(let l of s){let a=r(l);for(let d=0;d<=a.length;d++)n.add(a.slice(0,d).join(` +`),l)}s.sort(Co(l=>l.idx,ko));let o="",h=0;for(let l of s.slice(0,t)){h++;let a=r(l),d=[];for(let c=0;cr(p)[c]),p=>p);delete _[a[c]];for(let[p,y]of Object.entries(_))d.unshift(` - stacktraces of ${y.length} other leaks continue with ${p}`);d.unshift(u)}o+=` + + +==================== Leaking disposable ${h}/${s.length}: ${l.value.constructor.name} ==================== +${d.join(` +`)} +============================================================ + +`}return s.length>t&&(o+=` + + +... and ${s.length-t} more leaking disposables + +`),{leaks:s,details:o}}};Do.idx=0;function Ro(e){tt=e}if(Bo){let e="__is_disposable_tracked__";Ro(new class{trackDisposable(t){let i=new Error("Potentially leaked disposable").stack;setTimeout(()=>{t[e]||console.log(i)},3e3)}setParent(t,i){if(t&&t!==Se.None)try{t[e]=!0}catch{}}markAsDisposed(t){if(t&&t!==Se.None)try{t[e]=!0}catch{}}markAsSingleton(t){}})}function ys(e){return tt?.trackDisposable(e),e}function Cs(e){tt?.markAsDisposed(e)}function zi(e,t){tt?.setParent(e,t)}function Mo(e,t){if(tt)for(let i of e)tt.setParent(i,t)}function Us(e){return tt?.markAsSingleton(e),e}function ei(e){if(rn.is(e)){let t=[];for(let i of e)if(i)try{i.dispose()}catch(s){t.push(s)}if(t.length===1)throw t[0];if(t.length>1)throw new AggregateError(t,"Encountered errors while disposing of store");return Array.isArray(e)?[]:e}else if(e)return e.dispose(),e}function Lo(...e){let t=Ye(()=>ei(e));return Mo(e,t),t}function Ye(e){let t=ys({dispose:wo(()=>{Cs(t),e()})});return t}var on=class an{constructor(){this._toDispose=new Set,this._isDisposed=!1,ys(this)}dispose(){this._isDisposed||(Cs(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(this._toDispose.size!==0)try{ei(this._toDispose)}finally{this._toDispose.clear()}}add(t){if(!t)return t;if(t===this)throw new Error("Cannot register a disposable on itself!");return zi(t,this),this._isDisposed?an.DISABLE_DISPOSED_WARNING||console.warn(new Error("Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!").stack):this._toDispose.add(t),t}delete(t){if(t){if(t===this)throw new Error("Cannot dispose a disposable on itself!");this._toDispose.delete(t),t.dispose()}}deleteAndLeak(t){t&&this._toDispose.has(t)&&(this._toDispose.delete(t),zi(t,null))}};on.DISABLE_DISPOSED_WARNING=!1;var et=on,Se=class{constructor(){this._store=new et,ys(this),zi(this._store,this)}dispose(){Cs(this),this._store.dispose()}_register(e){if(e===this)throw new Error("Cannot register a disposable on itself!");return this._store.add(e)}};Se.None=Object.freeze({dispose(){}});var Ki=class $i{constructor(t){this.element=t,this.next=$i.Undefined,this.prev=$i.Undefined}};Ki.Undefined=new Ki(void 0);var W=Ki,qs=class{constructor(){this._first=W.Undefined,this._last=W.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===W.Undefined}clear(){let e=this._first;for(;e!==W.Undefined;){let t=e.next;e.prev=W.Undefined,e.next=W.Undefined,e=t}this._first=W.Undefined,this._last=W.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){let i=new W(e);if(this._first===W.Undefined)this._first=i,this._last=i;else if(t){let r=this._last;this._last=i,i.prev=r,r.next=i}else{let r=this._first;this._first=i,i.next=r,r.prev=i}this._size+=1;let s=!1;return()=>{s||(s=!0,this._remove(i))}}shift(){if(this._first!==W.Undefined){let e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==W.Undefined){let e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==W.Undefined&&e.next!==W.Undefined){let t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===W.Undefined&&e.next===W.Undefined?(this._first=W.Undefined,this._last=W.Undefined):e.next===W.Undefined?(this._last=this._last.prev,this._last.next=W.Undefined):e.prev===W.Undefined&&(this._first=this._first.next,this._first.prev=W.Undefined);this._size-=1}*[Symbol.iterator](){let e=this._first;for(;e!==W.Undefined;)yield e.element,e=e.next}},Po=globalThis.performance&&typeof globalThis.performance.now=="function",To=class hn{static create(t){return new hn(t)}constructor(t){this._now=Po&&t===!1?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return this._stopTime!==-1?this._stopTime-this._startTime:this._now()-this._startTime}},Ao=!1,Vs=!1,Oo=!1,li;(e=>{e.None=()=>Se.None;function t(m){if(Oo){let{onDidAddListener:g}=m,C=Vi.create(),S=0;m.onDidAddListener=()=>{++S===2&&(console.warn("snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here"),C.print()),g?.()}}}function i(m,g){return u(m,()=>{},0,void 0,!0,void 0,g)}e.defer=i;function s(m){return(g,C=null,S)=>{let k=!1,v;return v=m(M=>{if(!k)return v?v.dispose():k=!0,g.call(C,M)},null,S),k&&v.dispose(),v}}e.once=s;function r(m,g,C){return d((S,k=null,v)=>m(M=>S.call(k,g(M)),null,v),C)}e.map=r;function n(m,g,C){return d((S,k=null,v)=>m(M=>{g(M),S.call(k,M)},null,v),C)}e.forEach=n;function o(m,g,C){return d((S,k=null,v)=>m(M=>g(M)&&S.call(k,M),null,v),C)}e.filter=o;function h(m){return m}e.signal=h;function l(...m){return(g,C=null,S)=>{let k=Lo(...m.map(v=>v(M=>g.call(C,M))));return c(k,S)}}e.any=l;function a(m,g,C,S){let k=C;return r(m,v=>(k=g(k,v),k),S)}e.reduce=a;function d(m,g){let C,S={onWillAddFirstListener(){C=m(k.fire,k)},onDidRemoveLastListener(){C?.dispose()}};g||t(S);let k=new se(S);return g?.add(k),k.event}function c(m,g){return g instanceof Array?g.push(m):g&&g.add(m),m}function u(m,g,C=100,S=!1,k=!1,v,M){let H,ne,Ee,Le=0,J,He={leakWarningThreshold:v,onWillAddFirstListener(){H=m(Xe=>{Le++,ne=g(ne,Xe),S&&!Ee&&(le.fire(ne),ne=void 0),J=()=>{let dt=ne;ne=void 0,Ee=void 0,(!S||Le>1)&&le.fire(dt),Le=0},typeof C=="number"?(clearTimeout(Ee),Ee=setTimeout(J,C)):Ee===void 0&&(Ee=0,queueMicrotask(J))})},onWillRemoveListener(){k&&Le>0&&J?.()},onDidRemoveLastListener(){J=void 0,H.dispose()}};M||t(He);let le=new se(He);return M?.add(le),le.event}e.debounce=u;function f(m,g=0,C){return e.debounce(m,(S,k)=>S?(S.push(k),S):[k],g,void 0,!0,void 0,C)}e.accumulate=f;function _(m,g=(S,k)=>S===k,C){let S=!0,k;return o(m,v=>{let M=S||!g(v,k);return S=!1,k=v,M},C)}e.latch=_;function p(m,g,C){return[e.filter(m,g,C),e.filter(m,S=>!g(S),C)]}e.split=p;function y(m,g=!1,C=[],S){let k=C.slice(),v=m(ne=>{k?k.push(ne):H.fire(ne)});S&&S.add(v);let M=()=>{k?.forEach(ne=>H.fire(ne)),k=null},H=new se({onWillAddFirstListener(){v||(v=m(ne=>H.fire(ne)),S&&S.add(v))},onDidAddFirstListener(){k&&(g?setTimeout(M):M())},onDidRemoveLastListener(){v&&v.dispose(),v=null}});return S&&S.add(H),H.event}e.buffer=y;function E(m,g){return(C,S,k)=>{let v=g(new R);return m(function(M){let H=v.evaluate(M);H!==A&&C.call(S,H)},void 0,k)}}e.chain=E;let A=Symbol("HaltChainable");class R{constructor(){this.steps=[]}map(g){return this.steps.push(g),this}forEach(g){return this.steps.push(C=>(g(C),C)),this}filter(g){return this.steps.push(C=>g(C)?C:A),this}reduce(g,C){let S=C;return this.steps.push(k=>(S=g(S,k),S)),this}latch(g=(C,S)=>C===S){let C=!0,S;return this.steps.push(k=>{let v=C||!g(k,S);return C=!1,S=k,v?k:A}),this}evaluate(g){for(let C of this.steps)if(g=C(g),g===A)break;return g}}function x(m,g,C=S=>S){let S=(...H)=>M.fire(C(...H)),k=()=>m.on(g,S),v=()=>m.removeListener(g,S),M=new se({onWillAddFirstListener:k,onDidRemoveLastListener:v});return M.event}e.fromNodeEventEmitter=x;function L(m,g,C=S=>S){let S=(...H)=>M.fire(C(...H)),k=()=>m.addEventListener(g,S),v=()=>m.removeEventListener(g,S),M=new se({onWillAddFirstListener:k,onDidRemoveLastListener:v});return M.event}e.fromDOMEventEmitter=L;function P(m){return new Promise(g=>s(m)(g))}e.toPromise=P;function re(m){let g=new se;return m.then(C=>{g.fire(C)},()=>{g.fire(void 0)}).finally(()=>{g.dispose()}),g.event}e.fromPromise=re;function ye(m,g){return m(C=>g.fire(C))}e.forward=ye;function xe(m,g,C){return g(C),m(S=>g(S))}e.runAndSubscribe=xe;class Ot{constructor(g,C){this._observable=g,this._counter=0,this._hasChanged=!1;let S={onWillAddFirstListener:()=>{g.addObserver(this)},onDidRemoveLastListener:()=>{g.removeObserver(this)}};C||t(S),this.emitter=new se(S),C&&C.add(this.emitter)}beginUpdate(g){this._counter++}handlePossibleChange(g){}handleChange(g,C){this._hasChanged=!0}endUpdate(g){this._counter--,this._counter===0&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}function F(m,g){return new Ot(m,g).emitter.event}e.fromObservable=F;function _i(m){return(g,C,S)=>{let k=0,v=!1,M={beginUpdate(){k++},endUpdate(){k--,k===0&&(m.reportChanges(),v&&(v=!1,g.call(C)))},handlePossibleChange(){},handleChange(){v=!0}};m.addObserver(M),m.reportChanges();let H={dispose(){m.removeObserver(M)}};return S instanceof et?S.add(H):Array.isArray(S)&&S.push(H),H}}e.fromObservableLight=_i})(li||(li={}));var Ui=class qi{constructor(t){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${t}_${qi._idPool++}`,qi.all.add(this)}start(t){this._stopWatch=new To,this.listenerCount=t}stop(){if(this._stopWatch){let t=this._stopWatch.elapsed();this.durations.push(t),this.elapsedOverall+=t,this.invocationCount+=1,this._stopWatch=void 0}}};Ui.all=new Set,Ui._idPool=0;var Io=Ui,Ys=-1,ln=class cn{constructor(t,i,s=(cn._idPool++).toString(16).padStart(3,"0")){this._errorHandler=t,this.threshold=i,this.name=s,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(t,i){let s=this.threshold;if(s<=0||i{let n=this._stacks.get(t.value)||0;this._stacks.set(t.value,n-1)}}getMostFrequentStack(){if(!this._stacks)return;let t,i=0;for(let[s,r]of this._stacks)(!t||i{if(e instanceof $t)t(e);else for(let i=0;i{e.length!==0&&(console.warn("[LEAKING LISTENERS] GC'ed these listeners that were NOT yet disposed:"),console.warn(e.join(` +`)),e.length=0)},3e3),Ut=new FinalizationRegistry(t=>{typeof t=="string"&&e.push(t)})}var se=class{constructor(e){this._size=0,this._options=e,this._leakageMon=Ys>0||this._options?.leakWarningThreshold?new No(e?.onListenerError??Kt,this._options?.leakWarningThreshold??Ys):void 0,this._perfMon=this._options?._profName?new Io(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){if(!this._disposed){if(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners){if(Vs){let e=this._listeners;queueMicrotask(()=>{Ko(e,t=>t.stack?.print())})}this._listeners=void 0,this._size=0}this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose()}}get event(){return this._event??(this._event=(e,t,i)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){let h=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(h);let l=this._leakageMon.getMostFrequentStack()??["UNKNOWN stack",-1],a=new Ho(`${h}. HINT: Stack shows most frequent listener (${l[1]}-times)`,l[0]);return(this._options?.onListenerError||Kt)(a),Se.None}if(this._disposed)return Se.None;t&&(e=e.bind(t));let s=new $t(e),r,n;this._leakageMon&&this._size>=Math.ceil(this._leakageMon.threshold*.2)&&(s.stack=Vi.create(),r=this._leakageMon.check(s.stack,this._size+1)),Vs&&(s.stack=n??Vi.create()),this._listeners?this._listeners instanceof $t?(this._deliveryQueue??(this._deliveryQueue=new $o),this._listeners=[this._listeners,s]):this._listeners.push(s):(this._options?.onWillAddFirstListener?.(this),this._listeners=s,this._options?.onDidAddFirstListener?.(this)),this._size++;let o=Ye(()=>{Ut?.unregister(o),r?.(),this._removeListener(s)});if(i instanceof et?i.add(o):Array.isArray(i)&&i.push(o),Ut){let h=new Error().stack.split(` +`).slice(2,3).join(` +`).trim(),l=/(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(h);Ut.register(o,l?.[2]??h,o)}return o}),this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(this._size===1){this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),this._size=0;return}let t=this._listeners,i=t.indexOf(e);if(i===-1)throw console.log("disposed?",this._disposed),console.log("size?",this._size),console.log("arr?",JSON.stringify(this._listeners)),new Error("Attempted to dispose unknown listener");this._size--,t[i]=void 0;let s=this._deliveryQueue.current===this;if(this._size*zo<=t.length){let r=0;for(let n=0;n0}},$o=class{constructor(){this.i=-1,this.end=0}enqueue(e,t,i){this.i=0,this.end=i,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}},Yi=class{constructor(){this.mapWindowIdToZoomLevel=new Map,this._onDidChangeZoomLevel=new se,this.onDidChangeZoomLevel=this._onDidChangeZoomLevel.event,this.mapWindowIdToZoomFactor=new Map,this._onDidChangeFullscreen=new se,this.onDidChangeFullscreen=this._onDidChangeFullscreen.event,this.mapWindowIdToFullScreen=new Map}getZoomLevel(t){return this.mapWindowIdToZoomLevel.get(this.getWindowId(t))??0}setZoomLevel(t,i){if(this.getZoomLevel(i)===t)return;let s=this.getWindowId(i);this.mapWindowIdToZoomLevel.set(s,t),this._onDidChangeZoomLevel.fire(s)}getZoomFactor(t){return this.mapWindowIdToZoomFactor.get(this.getWindowId(t))??1}setZoomFactor(t,i){this.mapWindowIdToZoomFactor.set(this.getWindowId(i),t)}setFullscreen(t,i){if(this.isFullscreen(i)===t)return;let s=this.getWindowId(i);this.mapWindowIdToFullScreen.set(s,t),this._onDidChangeFullscreen.fire(s)}isFullscreen(t){return!!this.mapWindowIdToFullScreen.get(this.getWindowId(t))}getWindowId(t){return t.vscodeWindowId}};Yi.INSTANCE=new Yi;var ks=Yi;function Uo(e,t,i){typeof t=="string"&&(t=e.matchMedia(t)),t.addEventListener("change",i)}var $l=ks.INSTANCE.onDidChangeZoomLevel;function qo(e){return ks.INSTANCE.getZoomFactor(e)}var Ul=ks.INSTANCE.onDidChangeFullscreen,lt=typeof navigator=="object"?navigator.userAgent:"",ji=lt.indexOf("Firefox")>=0,qt=lt.indexOf("AppleWebKit")>=0,xs=lt.indexOf("Chrome")>=0,un=!xs&<.indexOf("Safari")>=0,ql=lt.indexOf("Electron/")>=0,Vl=lt.indexOf("Android")>=0,Vt=!1;if(typeof me.matchMedia=="function"){let e=me.matchMedia("(display-mode: standalone) or (display-mode: window-controls-overlay)"),t=me.matchMedia("(display-mode: fullscreen)");Vt=e.matches,Uo(me,e,({matches:i})=>{Vt&&t.matches||(Vt=i)})}function Vo(){return Vt}var nt="en",ti=!1,ii=!1,Et=!1,Yo=!1,_n=!1,fn=!1,jo=!1,Xo=!1,Go=!1,Jo=!1,Nt,Yt=nt,js=nt,Zo,Te,Oe=globalThis,ve;typeof Oe.vscode<"u"&&typeof Oe.vscode.process<"u"?ve=Oe.vscode.process:typeof process<"u"&&typeof process?.versions?.node=="string"&&(ve=process);var pn=typeof ve?.versions?.electron=="string",Qo=pn&&ve?.type==="renderer";if(typeof ve=="object"){ti=ve.platform==="win32",ii=ve.platform==="darwin",Et=ve.platform==="linux",Yo=Et&&!!ve.env.SNAP&&!!ve.env.SNAP_REVISION,jo=pn,Go=!!ve.env.CI||!!ve.env.BUILD_ARTIFACTSTAGINGDIRECTORY,Nt=nt,Yt=nt;let e=ve.env.VSCODE_NLS_CONFIG;if(e)try{let t=JSON.parse(e);Nt=t.userLocale,js=t.osLocale,Yt=t.resolvedLanguage||nt,Zo=t.languagePack?.translationsConfigFile}catch{}_n=!0}else typeof navigator=="object"&&!Qo?(Te=navigator.userAgent,ti=Te.indexOf("Windows")>=0,ii=Te.indexOf("Macintosh")>=0,Xo=(Te.indexOf("Macintosh")>=0||Te.indexOf("iPad")>=0||Te.indexOf("iPhone")>=0)&&!!navigator.maxTouchPoints&&navigator.maxTouchPoints>0,Et=Te.indexOf("Linux")>=0,Jo=Te?.indexOf("Mobi")>=0,fn=!0,Yt=globalThis._VSCODE_NLS_LANGUAGE||nt,Nt=navigator.language.toLowerCase(),js=Nt):console.error("Unable to resolve platform.");var gi=0;ii?gi=1:ti?gi=3:Et&&(gi=2);var gn=ti,Re=ii,ea=Et,vi=_n,ta=fn&&typeof Oe.importScripts=="function",Yl=ta?Oe.origin:void 0,Me=Te,Ke=Yt,ia;(e=>{function t(){return Ke}e.value=t;function i(){return Ke.length===2?Ke==="en":Ke.length>=3?Ke[0]==="e"&&Ke[1]==="n"&&Ke[2]==="-":!1}e.isDefaultVariant=i;function s(){return Ke==="en"}e.isDefault=s})(ia||(ia={}));var sa=typeof Oe.postMessage=="function"&&!Oe.importScripts,ra=(()=>{if(sa){let e=[];Oe.addEventListener("message",i=>{if(i.data&&i.data.vscodeScheduleAsyncWork)for(let s=0,r=e.length;s{let s=++t;e.push({id:s,callback:i}),Oe.postMessage({vscodeScheduleAsyncWork:s},"*")}}return e=>setTimeout(e)})(),na=!!(Me&&Me.indexOf("Chrome")>=0),jl=!!(Me&&Me.indexOf("Firefox")>=0),Xl=!!(!na&&Me&&Me.indexOf("Safari")>=0),Gl=!!(Me&&Me.indexOf("Edg/")>=0),Jl=!!(Me&&Me.indexOf("Android")>=0),$e=typeof navigator=="object"?navigator:{},Zl={clipboard:{writeText:vi||document.queryCommandSupported&&document.queryCommandSupported("copy")||!!($e&&$e.clipboard&&$e.clipboard.writeText),readText:vi||!!($e&&$e.clipboard&&$e.clipboard.readText)},keyboard:vi||Vo()?0:$e.keyboard||un?1:2,touch:"ontouchstart"in me||$e.maxTouchPoints>0,pointerEvents:me.PointerEvent&&("ontouchstart"in me||navigator.maxTouchPoints>0)},Es=class{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||0}},mi=new Es,Xs=new Es,Gs=new Es,oa=new Array(230),vn;(e=>{function t(h){return mi.keyCodeToStr(h)}e.toString=t;function i(h){return mi.strToKeyCode(h)}e.fromString=i;function s(h){return Xs.keyCodeToStr(h)}e.toUserSettingsUS=s;function r(h){return Gs.keyCodeToStr(h)}e.toUserSettingsGeneral=r;function n(h){return Xs.strToKeyCode(h)||Gs.strToKeyCode(h)}e.fromUserSettings=n;function o(h){if(h>=98&&h<=113)return null;switch(h){case 16:return"Up";case 18:return"Down";case 15:return"Left";case 17:return"Right"}return mi.keyCodeToStr(h)}e.toElectronAccelerator=o})(vn||(vn={}));var aa=class mn{constructor(t,i,s,r,n){this.ctrlKey=t,this.shiftKey=i,this.altKey=s,this.metaKey=r,this.keyCode=n}equals(t){return t instanceof mn&&this.ctrlKey===t.ctrlKey&&this.shiftKey===t.shiftKey&&this.altKey===t.altKey&&this.metaKey===t.metaKey&&this.keyCode===t.keyCode}getHashCode(){let t=this.ctrlKey?"1":"0",i=this.shiftKey?"1":"0",s=this.altKey?"1":"0",r=this.metaKey?"1":"0";return`K${t}${i}${s}${r}${this.keyCode}`}isModifierKey(){return this.keyCode===0||this.keyCode===5||this.keyCode===57||this.keyCode===6||this.keyCode===4}toKeybinding(){return new ha([this])}isDuplicateModifierCase(){return this.ctrlKey&&this.keyCode===5||this.shiftKey&&this.keyCode===4||this.altKey&&this.keyCode===6||this.metaKey&&this.keyCode===57}},ha=class{constructor(e){if(e.length===0)throw So("chords");this.chords=e}getHashCode(){let e="";for(let t=0,i=this.chords.length;t{function t(i){return i===e.None||i===e.Cancelled||i instanceof va?!0:!i||typeof i!="object"?!1:typeof i.isCancellationRequested=="boolean"&&typeof i.onCancellationRequested=="function"}e.isCancellationToken=t,e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:li.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:Sn})})(ga||(ga={}));var va=class{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?Sn:(this._emitter||(this._emitter=new se),this._emitter.event)}dispose(){this._emitter&&(this._emitter.dispose(),this._emitter=null)}};var Bs=class{constructor(e,t){this._isDisposed=!1,this._token=-1,typeof e=="function"&&typeof t=="number"&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._token!==-1&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Hi("Calling 'cancelAndSet' on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Hi("Calling 'setIfNotSet' on a disposed TimeoutTimer");this._token===-1&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},ma=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this.isDisposed)throw new Hi("Calling 'cancelAndSet' on a disposed IntervalTimer");this.cancel();let s=i.setInterval(()=>{e()},t);this.disposable=Ye(()=>{i.clearInterval(s),this.disposable=void 0})}dispose(){this.cancel(),this.isDisposed=!0}},Sa,Si;(function(){typeof globalThis.requestIdleCallback!="function"||typeof globalThis.cancelIdleCallback!="function"?Si=(e,t)=>{ra(()=>{if(i)return;let s=Date.now()+15;t(Object.freeze({didTimeout:!0,timeRemaining(){return Math.max(0,s-Date.now())}}))});let i=!1;return{dispose(){i||(i=!0)}}}:Si=(e,t,i)=>{let s=e.requestIdleCallback(t,typeof i=="number"?{timeout:i}:void 0),r=!1;return{dispose(){r||(r=!0,e.cancelIdleCallback(s))}}},Sa=e=>Si(globalThis,e)})();var wa;(e=>{async function t(s){let r,n=await Promise.all(s.map(o=>o.then(h=>h,h=>{r||(r=h)})));if(typeof r<"u")throw r;return n}e.settled=t;function i(s){return new Promise(async(r,n)=>{try{await s(r,n)}catch(o){n(o)}})}e.withAsyncBody=i})(wa||(wa={}));var Qs=class we{static fromArray(t){return new we(i=>{i.emitMany(t)})}static fromPromise(t){return new we(async i=>{i.emitMany(await t)})}static fromPromises(t){return new we(async i=>{await Promise.all(t.map(async s=>i.emitOne(await s)))})}static merge(t){return new we(async i=>{await Promise.all(t.map(async s=>{for await(let r of s)i.emitOne(r)}))})}constructor(t,i){this._state=0,this._results=[],this._error=null,this._onReturn=i,this._onStateChanged=new se,queueMicrotask(async()=>{let s={emitOne:r=>this.emitOne(r),emitMany:r=>this.emitMany(r),reject:r=>this.reject(r)};try{await Promise.resolve(t(s)),this.resolve()}catch(r){this.reject(r)}finally{s.emitOne=void 0,s.emitMany=void 0,s.reject=void 0}})}[Symbol.asyncIterator](){let t=0;return{next:async()=>{do{if(this._state===2)throw this._error;if(t(this._onReturn?.(),{done:!0,value:void 0})}}static map(t,i){return new we(async s=>{for await(let r of t)s.emitOne(i(r))})}map(t){return we.map(this,t)}static filter(t,i){return new we(async s=>{for await(let r of t)i(r)&&s.emitOne(r)})}filter(t){return we.filter(this,t)}static coalesce(t){return we.filter(t,i=>!!i)}coalesce(){return we.coalesce(this)}static async toPromise(t){let i=[];for await(let s of t)i.push(s);return i}toPromise(){return we.toPromise(this)}emitOne(t){this._state===0&&(this._results.push(t),this._onStateChanged.fire())}emitMany(t){this._state===0&&(this._results=this._results.concat(t),this._onStateChanged.fire())}resolve(){this._state===0&&(this._state=1,this._onStateChanged.fire())}reject(t){this._state===0&&(this._state=2,this._error=t,this._onStateChanged.fire())}};Qs.EMPTY=Qs.fromArray([]);function ba(e){return 55296<=e&&e<=56319}function er(e){return 56320<=e&&e<=57343}function ya(e,t){return(e-55296<<10)+(t-56320)+65536}function Ca(e){return Ds(e,0)}function Ds(e,t){switch(typeof e){case"object":return e===null?Ae(349,t):Array.isArray(e)?xa(e,t):Ea(e,t);case"string":return wn(e,t);case"boolean":return ka(e,t);case"number":return Ae(e,t);case"undefined":return Ae(937,t);default:return Ae(617,t)}}function Ae(e,t){return(t<<5)-t+e|0}function ka(e,t){return Ae(e?433:863,t)}function wn(e,t){t=Ae(149417,t);for(let i=0,s=e.length;iDs(s,i),t)}function Ea(e,t){return t=Ae(181387,t),Object.keys(e).sort().reduce((i,s)=>(i=wn(s,i),Ds(e[s],i)),t)}function wi(e,t,i=32){let s=i-t,r=~((1<>>s)>>>0}function tr(e,t=0,i=e.byteLength,s=0){for(let r=0;ri.toString(16).padStart(2,"0")).join(""):Ba((e>>>0).toString(16),t/4)}var Da=class bn{constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(67),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(t){let i=t.length;if(i===0)return;let s=this._buff,r=this._buffLen,n=this._leftoverHighSurrogate,o,h;for(n!==0?(o=n,h=-1,n=0):(o=t.charCodeAt(0),h=0);;){let l=o;if(ba(o))if(h+1>>6,t[i++]=128|(s&63)>>>0):s<65536?(t[i++]=224|(s&61440)>>>12,t[i++]=128|(s&4032)>>>6,t[i++]=128|(s&63)>>>0):(t[i++]=240|(s&1835008)>>>18,t[i++]=128|(s&258048)>>>12,t[i++]=128|(s&4032)>>>6,t[i++]=128|(s&63)>>>0),i>=64&&(this._step(),i-=64,this._totalLen+=64,t[0]=t[64],t[1]=t[65],t[2]=t[66]),i}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,65533)),this._totalLen+=this._buffLen,this._wrapUp()),_t(this._h0)+_t(this._h1)+_t(this._h2)+_t(this._h3)+_t(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,tr(this._buff,this._buffLen),this._buffLen>56&&(this._step(),tr(this._buff));let t=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(t/4294967296),!1),this._buffDV.setUint32(60,t%4294967296,!1),this._step()}_step(){let t=bn._bigBlock32,i=this._buffDV;for(let c=0;c<64;c+=4)t.setUint32(c,i.getUint32(c,!1),!1);for(let c=64;c<320;c+=4)t.setUint32(c,wi(t.getUint32(c-12,!1)^t.getUint32(c-32,!1)^t.getUint32(c-56,!1)^t.getUint32(c-64,!1),1),!1);let s=this._h0,r=this._h1,n=this._h2,o=this._h3,h=this._h4,l,a,d;for(let c=0;c<80;c++)c<20?(l=r&n|~r&o,a=1518500249):c<40?(l=r^n^o,a=1859775393):c<60?(l=r&n|r&o|n&o,a=2400959708):(l=r^n^o,a=3395469782),d=wi(s,5)+l+h+a+t.getUint32(c*4,!1)&4294967295,h=o,o=n,n=wi(r,30),r=s,s=d;this._h0=this._h0+s&4294967295,this._h1=this._h1+r&4294967295,this._h2=this._h2+n&4294967295,this._h3=this._h3+o&4294967295,this._h4=this._h4+h&4294967295}};Da._bigBlock32=new DataView(new ArrayBuffer(320));var{registerWindow:Ql,getWindow:be,getDocument:ec,getWindows:tc,getWindowsCount:ic,getWindowId:ir,getWindowById:sc,hasWindow:rc,onDidRegisterWindow:Ra,onWillUnregisterWindow:nc,onDidUnregisterWindow:oc}=(function(){let e=new Map,t={window:me,disposables:new et};e.set(me.vscodeWindowId,t);let i=new se,s=new se,r=new se;function n(o,h){return(typeof o=="number"?e.get(o):void 0)??(h?t:void 0)}return{onDidRegisterWindow:i.event,onWillUnregisterWindow:r.event,onDidUnregisterWindow:s.event,registerWindow(o){if(e.has(o.vscodeWindowId))return Se.None;let h=new et,l={window:o,disposables:h.add(new et)};return e.set(o.vscodeWindowId,l),h.add(Ye(()=>{e.delete(o.vscodeWindowId),s.fire(o)})),h.add(B(o,Z.BEFORE_UNLOAD,()=>{r.fire(o)})),i.fire(l),h},getWindows(){return e.values()},getWindowsCount(){return e.size},getWindowId(o){return o.vscodeWindowId},hasWindow(o){return e.has(o)},getWindowById:n,getWindow(o){let h=o;if(h?.ownerDocument?.defaultView)return h.ownerDocument.defaultView.window;let l=o;return l?.view?l.view.window:me},getDocument(o){return be(o).document}}})(),Ma=class{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}};function B(e,t,i,s){return new Ma(e,t,i,s)}function La(e,t){return function(i){return t(new wt(e,i))}}function Pa(e){return function(t){return e(new Xi(t))}}var sr=function(e,t,i,s){let r=i;return t==="click"||t==="mousedown"||t==="contextmenu"?r=La(be(e),i):(t==="keydown"||t==="keypress"||t==="keyup")&&(r=Pa(i)),B(e,t,r,s)},Ta,si,Aa=class extends ma{constructor(e){super(),this.defaultTarget=e&&be(e)}cancelAndSet(e,t,i){return super.cancelAndSet(e,t,i??this.defaultTarget)}},bi=class{constructor(e,t=0){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){Kt(e)}}static sort(e,t){return t.priority-e.priority}};(function(){let e=new Map,t=new Map,i=new Map,s=new Map,r=n=>{i.set(n,!1);let o=e.get(n)??[];for(t.set(n,o),e.set(n,[]),s.set(n,!0);o.length>0;)o.sort(bi.sort),o.shift().execute();s.set(n,!1)};si=(n,o,h=0)=>{let l=ir(n),a=new bi(o,h),d=e.get(l);return d||(d=[],e.set(l,d)),d.push(a),i.get(l)||(i.set(l,!0),n.requestAnimationFrame(()=>r(l))),a},Ta=(n,o,h)=>{let l=ir(n);if(s.get(l)){let a=new bi(o,h),d=t.get(l);return d||(d=[],t.set(l,d)),d.push(a),a}else return si(n,o,h)}})();var rr=class jt{constructor(t,i){this.width=t,this.height=i}with(t=this.width,i=this.height){return t!==this.width||i!==this.height?new jt(t,i):this}static is(t){return typeof t=="object"&&typeof t.height=="number"&&typeof t.width=="number"}static lift(t){return t instanceof jt?t:new jt(t.width,t.height)}static equals(t,i){return t===i?!0:!t||!i?!1:t.width===i.width&&t.height===i.height}};rr.None=new rr(0,0);function Oa(e){let t=e.getBoundingClientRect(),i=be(e);return{left:t.left+i.scrollX,top:t.top+i.scrollY,width:t.width,height:t.height}}var ac=new class{constructor(){this.mutationObservers=new Map}observe(e,t,i){let s=this.mutationObservers.get(e);s||(s=new Map,this.mutationObservers.set(e,s));let r=Ca(i),n=s.get(r);if(n)n.users+=1;else{let o=new se,h=new MutationObserver(a=>o.fire(a));h.observe(e,i);let l=n={users:1,observer:h,onDidMutate:o.event};t.add(Ye(()=>{l.users-=1,l.users===0&&(o.dispose(),h.disconnect(),s?.delete(r),s?.size===0&&this.mutationObservers.delete(e))})),s.set(r,n)}return n.onDidMutate}},Z={CLICK:"click",AUXCLICK:"auxclick",DBLCLICK:"dblclick",MOUSE_UP:"mouseup",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_MOVE:"mousemove",MOUSE_OUT:"mouseout",MOUSE_ENTER:"mouseenter",MOUSE_LEAVE:"mouseleave",MOUSE_WHEEL:"wheel",POINTER_UP:"pointerup",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_LEAVE:"pointerleave",CONTEXT_MENU:"contextmenu",WHEEL:"wheel",KEY_DOWN:"keydown",KEY_PRESS:"keypress",KEY_UP:"keyup",LOAD:"load",BEFORE_UNLOAD:"beforeunload",UNLOAD:"unload",PAGE_SHOW:"pageshow",PAGE_HIDE:"pagehide",PASTE:"paste",ABORT:"abort",ERROR:"error",RESIZE:"resize",SCROLL:"scroll",FULLSCREEN_CHANGE:"fullscreenchange",WK_FULLSCREEN_CHANGE:"webkitfullscreenchange",SELECT:"select",CHANGE:"change",SUBMIT:"submit",RESET:"reset",FOCUS:"focus",FOCUS_IN:"focusin",FOCUS_OUT:"focusout",BLUR:"blur",INPUT:"input",STORAGE:"storage",DRAG_START:"dragstart",DRAG:"drag",DRAG_ENTER:"dragenter",DRAG_LEAVE:"dragleave",DRAG_OVER:"dragover",DROP:"drop",DRAG_END:"dragend",ANIMATION_START:qt?"webkitAnimationStart":"animationstart",ANIMATION_END:qt?"webkitAnimationEnd":"animationend",ANIMATION_ITERATION:qt?"webkitAnimationIteration":"animationiteration"},Ia=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;function yn(e,t,i,...s){let r=Ia.exec(t);if(!r)throw new Error("Bad use of emmet");let n=r[1]||"div",o;return e!=="http://www.w3.org/1999/xhtml"?o=document.createElementNS(e,n):o=document.createElement(n),r[3]&&(o.id=r[3]),r[4]&&(o.className=r[4].replace(/\./g," ").trim()),i&&Object.entries(i).forEach(([h,l])=>{typeof l>"u"||(/^on\w+$/.test(h)?o[h]=l:h==="selected"?l&&o.setAttribute(h,"true"):o.setAttribute(h,l))}),o.append(...s),o}function Na(e,t,...i){return yn("http://www.w3.org/1999/xhtml",e,t,...i)}Na.SVG=function(e,t,...i){return yn("http://www.w3.org/2000/svg",e,t,...i)};var Fa=class{constructor(e){this.domNode=e,this._maxWidth="",this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._paddingTop="",this._paddingLeft="",this._paddingBottom="",this._paddingRight="",this._fontFamily="",this._fontWeight="",this._fontSize="",this._fontStyle="",this._fontFeatureSettings="",this._fontVariationSettings="",this._textDecoration="",this._lineHeight="",this._letterSpacing="",this._className="",this._display="",this._position="",this._visibility="",this._color="",this._backgroundColor="",this._layerHint=!1,this._contain="none",this._boxShadow=""}setMaxWidth(e){let t=de(e);this._maxWidth!==t&&(this._maxWidth=t,this.domNode.style.maxWidth=this._maxWidth)}setWidth(e){let t=de(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){let t=de(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){let t=de(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){let t=de(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){let t=de(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){let t=de(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setPaddingTop(e){let t=de(e);this._paddingTop!==t&&(this._paddingTop=t,this.domNode.style.paddingTop=this._paddingTop)}setPaddingLeft(e){let t=de(e);this._paddingLeft!==t&&(this._paddingLeft=t,this.domNode.style.paddingLeft=this._paddingLeft)}setPaddingBottom(e){let t=de(e);this._paddingBottom!==t&&(this._paddingBottom=t,this.domNode.style.paddingBottom=this._paddingBottom)}setPaddingRight(e){let t=de(e);this._paddingRight!==t&&(this._paddingRight=t,this.domNode.style.paddingRight=this._paddingRight)}setFontFamily(e){this._fontFamily!==e&&(this._fontFamily=e,this.domNode.style.fontFamily=this._fontFamily)}setFontWeight(e){this._fontWeight!==e&&(this._fontWeight=e,this.domNode.style.fontWeight=this._fontWeight)}setFontSize(e){let t=de(e);this._fontSize!==t&&(this._fontSize=t,this.domNode.style.fontSize=this._fontSize)}setFontStyle(e){this._fontStyle!==e&&(this._fontStyle=e,this.domNode.style.fontStyle=this._fontStyle)}setFontFeatureSettings(e){this._fontFeatureSettings!==e&&(this._fontFeatureSettings=e,this.domNode.style.fontFeatureSettings=this._fontFeatureSettings)}setFontVariationSettings(e){this._fontVariationSettings!==e&&(this._fontVariationSettings=e,this.domNode.style.fontVariationSettings=this._fontVariationSettings)}setTextDecoration(e){this._textDecoration!==e&&(this._textDecoration=e,this.domNode.style.textDecoration=this._textDecoration)}setLineHeight(e){let t=de(e);this._lineHeight!==t&&(this._lineHeight=t,this.domNode.style.lineHeight=this._lineHeight)}setLetterSpacing(e){let t=de(e);this._letterSpacing!==t&&(this._letterSpacing=t,this.domNode.style.letterSpacing=this._letterSpacing)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setDisplay(e){this._display!==e&&(this._display=e,this.domNode.style.display=this._display)}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this.domNode.style.visibility=this._visibility)}setColor(e){this._color!==e&&(this._color=e,this.domNode.style.color=this._color)}setBackgroundColor(e){this._backgroundColor!==e&&(this._backgroundColor=e,this.domNode.style.backgroundColor=this._backgroundColor)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=this._layerHint?"translate3d(0px, 0px, 0px)":"")}setBoxShadow(e){this._boxShadow!==e&&(this._boxShadow=e,this.domNode.style.boxShadow=e)}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}removeAttribute(e){this.domNode.removeAttribute(e)}appendChild(e){this.domNode.appendChild(e.domNode)}removeChild(e){this.domNode.removeChild(e.domNode)}};function de(e){return typeof e=="number"?`${e}px`:e}function Bt(e){return new Fa(e)}var Cn=class{constructor(){this._hooks=new et,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e,t){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;let i=this._onStopCallback;this._onStopCallback=null,e&&i&&i(t)}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,i,s,r){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=s,this._onStopCallback=r;let n=e;try{e.setPointerCapture(t),this._hooks.add(Ye(()=>{try{e.releasePointerCapture(t)}catch{}}))}catch{n=be(e)}this._hooks.add(B(n,Z.POINTER_MOVE,o=>{if(o.buttons!==i){this.stopMonitoring(!0);return}o.preventDefault(),this._pointerMoveCallback(o)})),this._hooks.add(B(n,Z.POINTER_UP,o=>this.stopMonitoring(!0)))}};function Ha(e,t,i){let s=null,r=null;if(typeof i.value=="function"?(s="value",r=i.value,r.length!==0&&console.warn("Memoize should only be used in functions with zero parameters")):typeof i.get=="function"&&(s="get",r=i.get),!r)throw new Error("not supported");let n=`$memoize$${t}`;i[s]=function(...o){return this.hasOwnProperty(n)||Object.defineProperty(this,n,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,o)}),this[n]}}var Ce;(e=>(e.Tap="-xterm-gesturetap",e.Change="-xterm-gesturechange",e.Start="-xterm-gesturestart",e.End="-xterm-gesturesend",e.Contextmenu="-xterm-gesturecontextmenu"))(Ce||(Ce={}));var bt=class ae extends Se{constructor(){super(),this.dispatched=!1,this.targets=new qs,this.ignoreTargets=new qs,this.activeTouches={},this.handle=null,this._lastSetTapCountTime=0,this._register(li.runAndSubscribe(Ra,({window:t,disposables:i})=>{i.add(B(t.document,"touchstart",s=>this.onTouchStart(s),{passive:!1})),i.add(B(t.document,"touchend",s=>this.onTouchEnd(t,s))),i.add(B(t.document,"touchmove",s=>this.onTouchMove(s),{passive:!1}))},{window:me,disposables:this._store}))}static addTarget(t){if(!ae.isTouchDevice())return Se.None;ae.INSTANCE||(ae.INSTANCE=Us(new ae));let i=ae.INSTANCE.targets.push(t);return Ye(i)}static ignoreTarget(t){if(!ae.isTouchDevice())return Se.None;ae.INSTANCE||(ae.INSTANCE=Us(new ae));let i=ae.INSTANCE.ignoreTargets.push(t);return Ye(i)}static isTouchDevice(){return"ontouchstart"in me||navigator.maxTouchPoints>0}dispose(){this.handle&&(this.handle.dispose(),this.handle=null),super.dispose()}onTouchStart(t){let i=Date.now();this.handle&&(this.handle.dispose(),this.handle=null);for(let s=0,r=t.targetTouches.length;s=ae.HOLD_DELAY&&Math.abs(l.initialPageX-fe(l.rollingPageX))<30&&Math.abs(l.initialPageY-fe(l.rollingPageY))<30){let d=this.newGestureEvent(Ce.Contextmenu,l.initialTarget);d.pageX=fe(l.rollingPageX),d.pageY=fe(l.rollingPageY),this.dispatchEvent(d)}else if(r===1){let d=fe(l.rollingPageX),c=fe(l.rollingPageY),u=fe(l.rollingTimestamps)-l.rollingTimestamps[0],f=d-l.rollingPageX[0],_=c-l.rollingPageY[0],p=[...this.targets].filter(y=>l.initialTarget instanceof Node&&y.contains(l.initialTarget));this.inertia(t,p,s,Math.abs(f)/u,f>0?1:-1,d,Math.abs(_)/u,_>0?1:-1,c)}this.dispatchEvent(this.newGestureEvent(Ce.End,l.initialTarget)),delete this.activeTouches[h.identifier]}this.dispatched&&(i.preventDefault(),i.stopPropagation(),this.dispatched=!1)}newGestureEvent(t,i){let s=document.createEvent("CustomEvent");return s.initEvent(t,!1,!0),s.initialTarget=i,s.tapCount=0,s}dispatchEvent(t){if(t.type===Ce.Tap){let i=new Date().getTime(),s=0;i-this._lastSetTapCountTime>ae.CLEAR_TAP_COUNT_TIME?s=1:s=2,this._lastSetTapCountTime=i,t.tapCount=s}else(t.type===Ce.Change||t.type===Ce.Contextmenu)&&(this._lastSetTapCountTime=0);if(t.initialTarget instanceof Node){for(let s of this.ignoreTargets)if(s.contains(t.initialTarget))return;let i=[];for(let s of this.targets)if(s.contains(t.initialTarget)){let r=0,n=t.initialTarget;for(;n&&n!==s;)r++,n=n.parentElement;i.push([r,s])}i.sort((s,r)=>s[0]-r[0]);for(let[s,r]of i)r.dispatchEvent(t),this.dispatched=!0}}inertia(t,i,s,r,n,o,h,l,a){this.handle=si(t,()=>{let d=Date.now(),c=d-s,u=0,f=0,_=!0;r+=ae.SCROLL_FRICTION*c,h+=ae.SCROLL_FRICTION*c,r>0&&(_=!1,u=n*r*c),h>0&&(_=!1,f=l*h*c);let p=this.newGestureEvent(Ce.Change);p.translationX=u,p.translationY=f,i.forEach(y=>y.dispatchEvent(p)),_||this.inertia(t,i,d,r,n,o+u,h,l,a+f)})}onTouchMove(t){let i=Date.now();for(let s=0,r=t.changedTouches.length;s3&&(o.rollingPageX.shift(),o.rollingPageY.shift(),o.rollingTimestamps.shift()),o.rollingPageX.push(n.pageX),o.rollingPageY.push(n.pageY),o.rollingTimestamps.push(i)}this.dispatched&&(t.preventDefault(),t.stopPropagation(),this.dispatched=!1)}};bt.SCROLL_FRICTION=-.005,bt.HOLD_DELAY=700,bt.CLEAR_TAP_COUNT_TIME=400,K([Ha],bt,"isTouchDevice",1);var kn=bt,Rs=class extends Se{onclick(e,t){this._register(B(e,Z.CLICK,i=>t(new wt(be(e),i))))}onmousedown(e,t){this._register(B(e,Z.MOUSE_DOWN,i=>t(new wt(be(e),i))))}onmouseover(e,t){this._register(B(e,Z.MOUSE_OVER,i=>t(new wt(be(e),i))))}onmouseleave(e,t){this._register(B(e,Z.MOUSE_LEAVE,i=>t(new wt(be(e),i))))}onkeydown(e,t){this._register(B(e,Z.KEY_DOWN,i=>t(new Xi(i))))}onkeyup(e,t){this._register(B(e,Z.KEY_UP,i=>t(new Xi(i))))}oninput(e,t){this._register(B(e,Z.INPUT,t))}onblur(e,t){this._register(B(e,Z.BLUR,t))}onfocus(e,t){this._register(B(e,Z.FOCUS,t))}onchange(e,t){this._register(B(e,Z.CHANGE,t))}ignoreGesture(e){return kn.ignoreTarget(e)}},nr=11,Wa=class extends Rs{constructor(e){super(),this._onActivate=e.onActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=e.bgWidth+"px",this.bgDomNode.style.height=e.bgHeight+"px",typeof e.top<"u"&&(this.bgDomNode.style.top="0px"),typeof e.left<"u"&&(this.bgDomNode.style.left="0px"),typeof e.bottom<"u"&&(this.bgDomNode.style.bottom="0px"),typeof e.right<"u"&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=e.className,this.domNode.style.position="absolute",this.domNode.style.width=nr+"px",this.domNode.style.height=nr+"px",typeof e.top<"u"&&(this.domNode.style.top=e.top+"px"),typeof e.left<"u"&&(this.domNode.style.left=e.left+"px"),typeof e.bottom<"u"&&(this.domNode.style.bottom=e.bottom+"px"),typeof e.right<"u"&&(this.domNode.style.right=e.right+"px"),this._pointerMoveMonitor=this._register(new Cn),this._register(sr(this.bgDomNode,Z.POINTER_DOWN,t=>this._arrowPointerDown(t))),this._register(sr(this.domNode,Z.POINTER_DOWN,t=>this._arrowPointerDown(t))),this._pointerdownRepeatTimer=this._register(new Aa),this._pointerdownScheduleRepeatTimer=this._register(new Bs)}_arrowPointerDown(e){if(!e.target||!(e.target instanceof Element))return;let t=()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._onActivate(),1e3/24,be(e))};this._onActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(t,200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,i=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault()}},za=class Gi{constructor(t,i,s,r,n,o,h){this._forceIntegerValues=t,this._scrollStateBrand=void 0,this._forceIntegerValues&&(i=i|0,s=s|0,r=r|0,n=n|0,o=o|0,h=h|0),this.rawScrollLeft=r,this.rawScrollTop=h,i<0&&(i=0),r+i>s&&(r=s-i),r<0&&(r=0),n<0&&(n=0),h+n>o&&(h=o-n),h<0&&(h=0),this.width=i,this.scrollWidth=s,this.scrollLeft=r,this.height=n,this.scrollHeight=o,this.scrollTop=h}equals(t){return this.rawScrollLeft===t.rawScrollLeft&&this.rawScrollTop===t.rawScrollTop&&this.width===t.width&&this.scrollWidth===t.scrollWidth&&this.scrollLeft===t.scrollLeft&&this.height===t.height&&this.scrollHeight===t.scrollHeight&&this.scrollTop===t.scrollTop}withScrollDimensions(t,i){return new Gi(this._forceIntegerValues,typeof t.width<"u"?t.width:this.width,typeof t.scrollWidth<"u"?t.scrollWidth:this.scrollWidth,i?this.rawScrollLeft:this.scrollLeft,typeof t.height<"u"?t.height:this.height,typeof t.scrollHeight<"u"?t.scrollHeight:this.scrollHeight,i?this.rawScrollTop:this.scrollTop)}withScrollPosition(t){return new Gi(this._forceIntegerValues,this.width,this.scrollWidth,typeof t.scrollLeft<"u"?t.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,typeof t.scrollTop<"u"?t.scrollTop:this.rawScrollTop)}createScrollEvent(t,i){let s=this.width!==t.width,r=this.scrollWidth!==t.scrollWidth,n=this.scrollLeft!==t.scrollLeft,o=this.height!==t.height,h=this.scrollHeight!==t.scrollHeight,l=this.scrollTop!==t.scrollTop;return{inSmoothScrolling:i,oldWidth:t.width,oldScrollWidth:t.scrollWidth,oldScrollLeft:t.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:t.height,oldScrollHeight:t.scrollHeight,oldScrollTop:t.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:s,scrollWidthChanged:r,scrollLeftChanged:n,heightChanged:o,scrollHeightChanged:h,scrollTopChanged:l}}},Ka=class extends Se{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new se),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new za(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){let i=this._state.withScrollDimensions(e,t);this._setState(i,!!this._smoothScrolling),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){let t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(this._smoothScrollDuration===0)return this.setScrollPositionNow(e);if(this._smoothScrolling){e={scrollLeft:typeof e.scrollLeft>"u"?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:typeof e.scrollTop>"u"?this._smoothScrolling.to.scrollTop:e.scrollTop};let i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;t?s=new ar(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):s=this._smoothScrolling.combine(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{let i=this._state.withScrollPosition(e);this._smoothScrolling=ar.start(this._state,i,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}hasPendingScrollAnimation(){return!!this._smoothScrolling}_performSmoothScrolling(){if(!this._smoothScrolling)return;let e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);if(this._setState(t,!0),!!this._smoothScrolling){if(e.isDone){this._smoothScrolling.dispose(),this._smoothScrolling=null;return}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}}_setState(e,t){let i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}},or=class{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}};function yi(e,t){let i=t-e;return function(s){return e+i*qa(s)}}function $a(e,t,i){return function(s){return s2.5*s){let r,n;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" fade":"")))}},Ya=140,xn=class extends Rs{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new Va(e.visibility,"visible scrollbar "+e.extraScrollbarClassName,"invisible scrollbar "+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new Cn),this._shouldRender=!0,this.domNode=Bt(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(B(this.domNode.domNode,Z.POINTER_DOWN,t=>this._domNodePointerDown(t)))}_createArrow(e){let t=this._register(new Wa(e));this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode)}_createSlider(e,t,i,s){this.slider=Bt(document.createElement("div")),this.slider.setClassName("slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),typeof i=="number"&&this.slider.setWidth(i),typeof s=="number"&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(B(this.slider.domNode,Z.POINTER_DOWN,r=>{r.button===0&&(r.preventDefault(),this._sliderPointerDown(r))})),this.onclick(this.slider.domNode,r=>{r.leftButton&&r.stopPropagation()})}_onElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._onPointerDown(e)}delegatePointerDown(e){let t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?e.button===0&&(e.preventDefault(),this._sliderPointerDown(e)):this._onPointerDown(e)}_onPointerDown(e){let t,i;if(e.target===this.domNode.domNode&&typeof e.offsetX=="number"&&typeof e.offsetY=="number")t=e.offsetX,i=e.offsetY;else{let r=Oa(this.domNode.domNode);t=e.pageX-r.left,i=e.pageY-r.top}let s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),e.button===0&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!e.target||!(e.target instanceof Element))return;let t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,r=>{let n=this._sliderOrthogonalPointerPosition(r),o=Math.abs(n-i);if(gn&&o>Ya){this._setDesiredScrollPositionNow(s.getScrollPosition());return}let h=this._sliderPointerPosition(r)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(h))},()=>{this.slider.toggleClassName("active",!1),this._host.onDragEnd()}),this._host.onDragStart()}_setDesiredScrollPositionNow(e){let t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}},En=class Zi{constructor(t,i,s,r,n,o){this._scrollbarSize=Math.round(i),this._oppositeScrollbarSize=Math.round(s),this._arrowSize=Math.round(t),this._visibleSize=r,this._scrollSize=n,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new Zi(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(t){let i=Math.round(t);return this._visibleSize!==i?(this._visibleSize=i,this._refreshComputedValues(),!0):!1}setScrollSize(t){let i=Math.round(t);return this._scrollSize!==i?(this._scrollSize=i,this._refreshComputedValues(),!0):!1}setScrollPosition(t){let i=Math.round(t);return this._scrollPosition!==i?(this._scrollPosition=i,this._refreshComputedValues(),!0):!1}setScrollbarSize(t){this._scrollbarSize=Math.round(t)}setOppositeScrollbarSize(t){this._oppositeScrollbarSize=Math.round(t)}static _computeValues(t,i,s,r,n){let o=Math.max(0,s-t),h=Math.max(0,o-2*i),l=r>0&&r>s;if(!l)return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(h),computedSliderRatio:0,computedSliderPosition:0};let a=Math.round(Math.max(20,Math.floor(s*h/r))),d=(h-a)/(r-s),c=n*d;return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(a),computedSliderRatio:d,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){let t=Zi._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=t.computedAvailableSize,this._computedIsNeeded=t.computedIsNeeded,this._computedSliderSize=t.computedSliderSize,this._computedSliderRatio=t.computedSliderRatio,this._computedSliderPosition=t.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(t){if(!this._computedIsNeeded)return 0;let i=t-this._arrowSize-this._computedSliderSize/2;return Math.round(i/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(t){if(!this._computedIsNeeded)return 0;let i=t-this._arrowSize,s=this._scrollPosition;return i0&&Math.abs(t.deltaY)>0)return 1;let s=.5;if((!this._isAlmostInt(t.deltaX)||!this._isAlmostInt(t.deltaY))&&(s+=.25),i){let r=Math.abs(t.deltaX),n=Math.abs(t.deltaY),o=Math.abs(i.deltaX),h=Math.abs(i.deltaY),l=Math.max(Math.min(r,o),1),a=Math.max(Math.min(n,h),1),d=Math.max(r,o),c=Math.max(n,h);d%l===0&&c%a===0&&(s-=.5)}return Math.min(Math.max(s,0),1)}_isAlmostInt(t){return Math.abs(Math.round(t)-t)<.01}};Qi.INSTANCE=new Qi;var Za=Qi,Qa=class extends Rs{constructor(e,t,i){super(),this._onScroll=this._register(new se),this.onScroll=this._onScroll.event,this._onWillScroll=this._register(new se),this.onWillScroll=this._onWillScroll.event,this._options=th(t),this._scrollable=i,this._register(this._scrollable.onScroll(r=>{this._onWillScroll.fire(r),this._onDidScroll(r),this._onScroll.fire(r)}));let s={onMouseWheel:r=>this._onMouseWheel(r),onDragStart:()=>this._onDragStart(),onDragEnd:()=>this._onDragEnd()};this._verticalScrollbar=this._register(new Xa(this._scrollable,this._options,s)),this._horizontalScrollbar=this._register(new ja(this._scrollable,this._options,s)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=Bt(document.createElement("div")),this._leftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=Bt(document.createElement("div")),this._topShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=Bt(document.createElement("div")),this._topLeftShadowDomNode.setClassName("shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode||this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this.onmouseover(this._listenOnDomNode,r=>this._onMouseOver(r)),this.onmouseleave(this._listenOnDomNode,r=>this._onMouseLeave(r)),this._hideTimeout=this._register(new Bs),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}get options(){return this._options}dispose(){this._mouseWheelToDispose=ei(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getOverviewRulerLayoutInfo(){return{parent:this._domNode,insertBefore:this._verticalScrollbar.domNode.domNode}}delegateVerticalScrollbarPointerDown(e){this._verticalScrollbar.delegatePointerDown(e)}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}updateClassName(e){this._options.className=e,Re&&(this._options.className+=" mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){typeof e.handleMouseWheel<"u"&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),typeof e.mouseWheelScrollSensitivity<"u"&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),typeof e.fastScrollSensitivity<"u"&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),typeof e.scrollPredominantAxis<"u"&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),typeof e.horizontal<"u"&&(this._options.horizontal=e.horizontal),typeof e.vertical<"u"&&(this._options.vertical=e.vertical),typeof e.horizontalScrollbarSize<"u"&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),typeof e.verticalScrollbarSize<"u"&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),typeof e.scrollByPage<"u"&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}setRevealOnScroll(e){this._revealOnScroll=e}delegateScrollFromMouseWheelEvent(e){this._onMouseWheel(new Zs(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=ei(this._mouseWheelToDispose),e)){let t=i=>{this._onMouseWheel(new Zs(i))};this._mouseWheelToDispose.push(B(this._listenOnDomNode,Z.MOUSE_WHEEL,t,{passive:!1}))}}_onMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;let t=Za.INSTANCE;lr&&t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let r=e.deltaY*this._options.mouseWheelScrollSensitivity,n=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&n+r===0?n=r=0:Math.abs(r)>=Math.abs(n)?n=0:r=0),this._options.flipAxes&&([r,n]=[n,r]);let o=!Re&&e.browserEvent&&e.browserEvent.shiftKey;(this._options.scrollYToX||o)&&!n&&(n=r,r=0),e.browserEvent&&e.browserEvent.altKey&&(n=n*this._options.fastScrollSensitivity,r=r*this._options.fastScrollSensitivity);let h=this._scrollable.getFutureScrollPosition(),l={};if(r){let a=hr*r,d=h.scrollTop-(a<0?Math.floor(a):Math.ceil(a));this._verticalScrollbar.writeScrollPosition(l,d)}if(n){let a=hr*n,d=h.scrollLeft-(a<0?Math.floor(a):Math.ceil(a));this._horizontalScrollbar.writeScrollPosition(l,d)}l=this._scrollable.validateScrollPosition(l),(h.scrollLeft!==l.scrollLeft||h.scrollTop!==l.scrollTop)&&(lr&&this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(l):this._scrollable.setScrollPositionNow(l),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_onDidScroll(e){this._shouldRender=this._horizontalScrollbar.onDidScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.onDidScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){let e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" left":"",r=t?" top":"",n=i||t?" top-left-corner":"";this._leftShadowDomNode.setClassName(`shadow${s}`),this._topShadowDomNode.setClassName(`shadow${r}`),this._topLeftShadowDomNode.setClassName(`shadow${n}${r}${s}`)}}_onDragStart(){this._isDragging=!0,this._reveal()}_onDragEnd(){this._isDragging=!1,this._hide()}_onMouseLeave(e){this._mouseIsOver=!1,this._hide()}_onMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){!this._mouseIsOver&&!this._isDragging&&(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){!this._mouseIsOver&&!this._isDragging&&this._hideTimeout.cancelAndSet(()=>this._hide(),Ga)}},eh=class extends Qa{constructor(e,t,i){super(e,t,i)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}};function th(e){let t={lazyRender:typeof e.lazyRender<"u"?e.lazyRender:!1,className:typeof e.className<"u"?e.className:"",useShadows:typeof e.useShadows<"u"?e.useShadows:!0,handleMouseWheel:typeof e.handleMouseWheel<"u"?e.handleMouseWheel:!0,flipAxes:typeof e.flipAxes<"u"?e.flipAxes:!1,consumeMouseWheelIfScrollbarIsNeeded:typeof e.consumeMouseWheelIfScrollbarIsNeeded<"u"?e.consumeMouseWheelIfScrollbarIsNeeded:!1,alwaysConsumeMouseWheel:typeof e.alwaysConsumeMouseWheel<"u"?e.alwaysConsumeMouseWheel:!1,scrollYToX:typeof e.scrollYToX<"u"?e.scrollYToX:!1,mouseWheelScrollSensitivity:typeof e.mouseWheelScrollSensitivity<"u"?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:typeof e.fastScrollSensitivity<"u"?e.fastScrollSensitivity:5,scrollPredominantAxis:typeof e.scrollPredominantAxis<"u"?e.scrollPredominantAxis:!0,mouseWheelSmoothScroll:typeof e.mouseWheelSmoothScroll<"u"?e.mouseWheelSmoothScroll:!0,arrowSize:typeof e.arrowSize<"u"?e.arrowSize:11,listenOnDomNode:typeof e.listenOnDomNode<"u"?e.listenOnDomNode:null,horizontal:typeof e.horizontal<"u"?e.horizontal:1,horizontalScrollbarSize:typeof e.horizontalScrollbarSize<"u"?e.horizontalScrollbarSize:10,horizontalSliderSize:typeof e.horizontalSliderSize<"u"?e.horizontalSliderSize:0,horizontalHasArrows:typeof e.horizontalHasArrows<"u"?e.horizontalHasArrows:!1,vertical:typeof e.vertical<"u"?e.vertical:1,verticalScrollbarSize:typeof e.verticalScrollbarSize<"u"?e.verticalScrollbarSize:10,verticalHasArrows:typeof e.verticalHasArrows<"u"?e.verticalHasArrows:!1,verticalSliderSize:typeof e.verticalSliderSize<"u"?e.verticalSliderSize:0,scrollByPage:typeof e.scrollByPage<"u"?e.scrollByPage:!1};return t.horizontalSliderSize=typeof e.horizontalSliderSize<"u"?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=typeof e.verticalSliderSize<"u"?e.verticalSliderSize:t.verticalScrollbarSize,Re&&(t.className+=" mac"),t}var b=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event?this._event:(this._event=(e,t,i)=>{if(this._disposed)return q(()=>{});let s={fn:e,thisArgs:t};this._listeners.push(s);let r=q(()=>{let n=this._listeners.indexOf(s);n!==-1&&this._listeners.splice(n,1)});return i&&(Array.isArray(i)?i.push(r):i.add(r)),r},this._event)}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:t,thisArgs:i}=this._listeners[0];t.call(i,e);return}default:{let t=this._listeners.slice();for(let{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},_e;(e=>{function t(n,o){return n(h=>o.fire(h))}e.forward=t;function i(n,o){return(h,l,a)=>n(d=>h.call(l,o(d)),void 0,a)}e.map=i;function s(...n){return(o,h,l)=>{let a=new bs;for(let d of n)a.add(d(c=>o.call(h,c)));return l&&(Array.isArray(l)?l.push(a):l.add(a)),a}}e.any=s;function r(n,o){return o(void 0),n(h=>o(h))}e.runAndSubscribe=r})(_e||(_e={}));var es=class extends I{constructor(e,t,i,s,r,n,o,h){super(),this._bufferService=i,this._optionsService=o,this._renderService=h,this._onRequestScrollLines=this._register(new b),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1;let l=this._register(new Ka({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:a=>si(s.window,a)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{l.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new eh(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,...this._getChangeOptions()},l)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","overviewRuler"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(r.onProtocolChange(a=>{this._scrollableElement.updateOptions({handleMouseWheel:!(a&16)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(_e.runAndSubscribe(n.onChangeColors,()=>{e.style.backgroundColor=n.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=n.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register(q(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register(q(()=>this._styleElement.remove())),this._register(_e.runAndSubscribe(n.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .scrollbar > .slider {",` background: ${n.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider:hover {",` background: ${n.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .scrollbar > .slider.active {",` background: ${n.colors.scrollbarSliderActiveBackground.css};`,"}"].join(` +`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._scrollableElement.onScroll(a=>this._handleScroll(a))),this._register(kn.addTarget(t)),this._register(B(t,Ce.Change,a=>this._handleGestureChange(a)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,verticalScrollbarSize:this._optionsService.rawOptions.overviewRuler?.width||14}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){!this._renderService||this._isSyncing||(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1)}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;i!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}_handleGestureChange(e){e.preventDefault(),e.stopPropagation();let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e.translationY})}};es=K([w(2,ce),w(3,Ie),w(4,Xr),w(5,ht),w(6,he),w(7,Ne)],es);var ts=class extends I{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(n=>this._removeDecoration(n))),this._register(q(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",e?.options?.layer==="top"),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,i.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;let i=e.options.x??0;(e.options.anchor||"left")==="right"?t.style.right=i?`${i*this._renderService.dimensions.css.cell.width}px`:"":t.style.left=i?`${i*this._renderService.dimensions.css.cell.width}px`:""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};ts=K([w(1,ce),w(2,Ie),w(3,Tt),w(4,Ne)],ts);var ih=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(let t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position)){this._addLineToZone(t,e.marker.line);return}}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}},Be={full:0,left:0,center:0,right:0},Ue={full:0,left:0,center:0,right:0},ft={full:0,left:0,center:0,right:0},ri=class extends I{constructor(e,t,i,s,r,n,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=o,this._coreBrowserService=h,this._colorZoneStore=new ih,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(q(()=>this._canvas?.remove()));let l=this._canvas.getContext("2d");if(l)this._ctx=l;else throw new Error("Ctx cannot be null");this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onRender(()=>{(!this._containerHeight||this._containerHeight!==this._screenElement.clientHeight)&&(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("overviewRuler",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._queueRefresh(!0)}get _width(){return this._optionsService.options.overviewRuler?.width||0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);Ue.full=this._canvas.width,Ue.left=e,Ue.center=t,Ue.right=e,this._refreshDrawHeightConstants(),ft.full=1,ft.left=1,ft.center=1+Ue.left,ft.right=1+Ue.left+Ue.center}_refreshDrawHeightConstants(){Be.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);Be.left=t,Be.center=t,Be.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Be.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Be.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Be.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Be.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let t of this._decorationService.decorations)this._colorZoneStore.addDecoration(t);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!=="full"&&this._renderColorZone(t);for(let t of e)t.position==="full"&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.overviewRuler.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.overviewRuler.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(ft[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-Be[e.position||"full"]/2),Ue[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+Be[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._refreshDecorations(),this._animationFrame=void 0}))}};ri=K([w(2,ce),w(3,Tt),w(4,Ne),w(5,he),w(6,ht),w(7,Ie)],ri);var is=class{constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout(()=>{this._compositionPosition.end=this._textarea.value.length},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(e.keyCode===20||e.keyCode===229||e.keyCode===16||e.keyCode===17||e.keyCode===18)return!1;this._finalizeComposition(!1)}return e.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){let t={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let i;t.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(t.start,this._compositionPosition.start):i=this._textarea.value.substring(t.start),i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}else{this._isSendingComposition=!1;let t=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(t,!0)}}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;let e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){let t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};is=K([w(2,ce),w(3,he),w(4,je),w(5,Ne)],is);var ee=0,te=0,ie=0,$=0,cr={css:"#00000000",rgba:0},V;(e=>{function t(r,n,o,h){return h!==void 0?`#${Ge(r)}${Ge(n)}${Ge(o)}${Ge(h)}`:`#${Ge(r)}${Ge(n)}${Ge(o)}`}e.toCss=t;function i(r,n,o,h=255){return(r<<24|n<<16|o<<8|h)>>>0}e.toRgba=i;function s(r,n,o,h){return{css:e.toCss(r,n,o,h),rgba:e.toRgba(r,n,o,h)}}e.toColor=s})(V||(V={}));var N;(e=>{function t(l,a){if($=(a.rgba&255)/255,$===1)return{css:a.css,rgba:a.rgba};let d=a.rgba>>24&255,c=a.rgba>>16&255,u=a.rgba>>8&255,f=l.rgba>>24&255,_=l.rgba>>16&255,p=l.rgba>>8&255;ee=f+Math.round((d-f)*$),te=_+Math.round((c-_)*$),ie=p+Math.round((u-p)*$);let y=V.toCss(ee,te,ie),E=V.toRgba(ee,te,ie);return{css:y,rgba:E}}e.blend=t;function i(l){return(l.rgba&255)===255}e.isOpaque=i;function s(l,a,d){let c=Xt.ensureContrastRatio(l.rgba,a.rgba,d);if(c)return V.toColor(c>>24&255,c>>16&255,c>>8&255)}e.ensureContrastRatio=s;function r(l){let a=(l.rgba|255)>>>0;return[ee,te,ie]=Xt.toChannels(a),{css:V.toCss(ee,te,ie),rgba:a}}e.opaque=r;function n(l,a){return $=Math.round(a*255),[ee,te,ie]=Xt.toChannels(l.rgba),{css:V.toCss(ee,te,ie,$),rgba:V.toRgba(ee,te,ie,$)}}e.opacity=n;function o(l,a){return $=l.rgba&255,n(l,$*a/255)}e.multiplyOpacity=o;function h(l){return[l.rgba>>24&255,l.rgba>>16&255,l.rgba>>8&255]}e.toColorRGB=h})(N||(N={}));var z;(e=>{let t,i;try{let r=document.createElement("canvas");r.width=1,r.height=1;let n=r.getContext("2d",{willReadFrequently:!0});n&&(t=n,t.globalCompositeOperation="copy",i=t.createLinearGradient(0,0,1,1))}catch{}function s(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return ee=parseInt(r.slice(1,2).repeat(2),16),te=parseInt(r.slice(2,3).repeat(2),16),ie=parseInt(r.slice(3,4).repeat(2),16),V.toColor(ee,te,ie);case 5:return ee=parseInt(r.slice(1,2).repeat(2),16),te=parseInt(r.slice(2,3).repeat(2),16),ie=parseInt(r.slice(3,4).repeat(2),16),$=parseInt(r.slice(4,5).repeat(2),16),V.toColor(ee,te,ie,$);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let n=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(n)return ee=parseInt(n[1]),te=parseInt(n[2]),ie=parseInt(n[3]),$=Math.round((n[5]===void 0?1:parseFloat(n[5]))*255),V.toColor(ee,te,ie,$);if(r==="transparent")return{css:"transparent",rgba:0};if(!t||!i)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=i,t.fillStyle=r,typeof t.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[ee,te,ie,$]=t.getImageData(0,0,1,1).data,$!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:V.toRgba(ee,te,ie,$),css:r}}e.toColor=s})(z||(z={}));var oe;(e=>{function t(s){return i(s>>16&255,s>>8&255,s&255)}e.relativeLuminance=t;function i(s,r,n){let o=s/255,h=r/255,l=n/255,a=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),d=h<=.03928?h/12.92:Math.pow((h+.055)/1.055,2.4),c=l<=.03928?l/12.92:Math.pow((l+.055)/1.055,2.4);return a*.2126+d*.7152+c*.0722}e.relativeLuminance2=i})(oe||(oe={}));var Xt;(e=>{function t(o,h){if($=(h&255)/255,$===1)return h;let l=h>>24&255,a=h>>16&255,d=h>>8&255,c=o>>24&255,u=o>>16&255,f=o>>8&255;return ee=c+Math.round((l-c)*$),te=u+Math.round((a-u)*$),ie=f+Math.round((d-f)*$),V.toRgba(ee,te,ie)}e.blend=t;function i(o,h,l){let a=oe.relativeLuminance(o>>8),d=oe.relativeLuminance(h>>8);if(Pe(a,d)>8));if(_>8));return _>y?f:p}return f}let c=r(o,h,l),u=Pe(a,oe.relativeLuminance(c>>8));if(u>8));return u>_?c:f}return c}}e.ensureContrastRatio=i;function s(o,h,l){let a=o>>24&255,d=o>>16&255,c=o>>8&255,u=h>>24&255,f=h>>16&255,_=h>>8&255,p=Pe(oe.relativeLuminance2(u,f,_),oe.relativeLuminance2(a,d,c));for(;p0||f>0||_>0);)u-=Math.max(0,Math.ceil(u*.1)),f-=Math.max(0,Math.ceil(f*.1)),_-=Math.max(0,Math.ceil(_*.1)),p=Pe(oe.relativeLuminance2(u,f,_),oe.relativeLuminance2(a,d,c));return(u<<24|f<<16|_<<8|255)>>>0}e.reduceLuminance=s;function r(o,h,l){let a=o>>24&255,d=o>>16&255,c=o>>8&255,u=h>>24&255,f=h>>16&255,_=h>>8&255,p=Pe(oe.relativeLuminance2(u,f,_),oe.relativeLuminance2(a,d,c));for(;p>>0}e.increaseLuminance=r;function n(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}e.toChannels=n})(Xt||(Xt={}));function Ge(e){let t=e.toString(16);return t.length<2?"0"+t:t}function Pe(e,t){return e1){let d=this._getJoinedRanges(s,o,n,t,r);for(let c=0;c1){let a=this._getJoinedRanges(s,o,n,t,r);for(let d=0;d=Ot,k=m,v=this._workCell;if(u.length>0&&m===u[0][0]&&S){let T=u.shift(),fi=this._isCellInSelection(T[0],t);for(A=T[0]+1;A=T[1]),S?(C=!0,v=new sh(this._workCell,e.translateToString(!0,T[0],T[1]),T[1]-T[0]),k=T[1]-1,g=v.getWidth()):Ot=T[1]}let M=this._isCellInSelection(m,t),H=i&&m===n,ne=_i&&m>=a&&m<=d,Ee=!1;this._decorationService.forEachDecorationAtCell(m,t,void 0,T=>{Ee=!0});let Le=v.getChars()||" ";if(Le===" "&&(v.isUnderline()||v.isOverline())&&(Le="\xA0"),xe=g*h-l.get(Le,v.isBold(),v.isItalic()),!p)p=this._document.createElement("span");else if(y&&(M&&ye||!M&&!ye&&v.bg===R)&&(M&&ye&&f.selectionForeground||v.fg===x)&&v.extended.ext===L&&ne===P&&xe===re&&!H&&!C&&!Ee&&S){v.isInvisible()?E+=" ":E+=Le,y++;continue}else y&&(p.textContent=E),p=this._document.createElement("span"),y=0,E="";if(R=v.bg,x=v.fg,L=v.extended.ext,P=ne,re=xe,ye=M,C&&n>=m&&n<=k&&(n=m),!this._coreService.isCursorHidden&&H&&this._coreService.isCursorInitialized){if(F.push("xterm-cursor"),this._coreBrowserService.isFocused)o&&F.push("xterm-cursor-blink"),F.push(s==="bar"?"xterm-cursor-bar":s==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":F.push("xterm-cursor-outline");break;case"block":F.push("xterm-cursor-block");break;case"bar":F.push("xterm-cursor-bar");break;case"underline":F.push("xterm-cursor-underline");break;default:break}}if(v.isBold()&&F.push("xterm-bold"),v.isItalic()&&F.push("xterm-italic"),v.isDim()&&F.push("xterm-dim"),v.isInvisible()?E=" ":E=v.getChars()||" ",v.isUnderline()&&(F.push(`xterm-underline-${v.extended.underlineStyle}`),E===" "&&(E="\xA0"),!v.isUnderlineColorDefault()))if(v.isUnderlineColorRGB())p.style.textDecorationColor=`rgb(${Pt.toColorRGB(v.getUnderlineColor()).join(",")})`;else{let T=v.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&v.isBold()&&T<8&&(T+=8),p.style.textDecorationColor=f.ansi[T].css}v.isOverline()&&(F.push("xterm-overline"),E===" "&&(E="\xA0")),v.isStrikethrough()&&F.push("xterm-strikethrough"),ne&&(p.style.textDecoration="underline");let J=v.getFgColor(),He=v.getFgColorMode(),le=v.getBgColor(),Xe=v.getBgColorMode(),dt=!!v.isInverse();if(dt){let T=J;J=le,le=T;let fi=He;He=Xe,Xe=fi}let We,It,ut=!1;this._decorationService.forEachDecorationAtCell(m,t,void 0,T=>{T.options.layer!=="top"&&ut||(T.backgroundColorRGB&&(Xe=50331648,le=T.backgroundColorRGB.rgba>>8&16777215,We=T.backgroundColorRGB),T.foregroundColorRGB&&(He=50331648,J=T.foregroundColorRGB.rgba>>8&16777215,It=T.foregroundColorRGB),ut=T.options.layer==="top")}),!ut&&M&&(We=this._coreBrowserService.isFocused?f.selectionBackgroundOpaque:f.selectionInactiveBackgroundOpaque,le=We.rgba>>8&16777215,Xe=50331648,ut=!0,f.selectionForeground&&(He=50331648,J=f.selectionForeground.rgba>>8&16777215,It=f.selectionForeground)),ut&&F.push("xterm-decoration-top");let ze;switch(Xe){case 16777216:case 33554432:ze=f.ansi[le],F.push(`xterm-bg-${le}`);break;case 50331648:ze=V.toColor(le>>16,le>>8&255,le&255),this._addStyle(p,`background-color:#${(le>>>0).toString(16).padStart(6,"0")}`);break;default:dt?(ze=f.foreground,F.push("xterm-bg-257")):ze=f.background}switch(We||v.isDim()&&(We=N.multiplyOpacity(ze,.5)),He){case 16777216:case 33554432:v.isBold()&&J<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(J+=8),this._applyMinimumContrast(p,ze,f.ansi[J],v,We,void 0)||F.push(`xterm-fg-${J}`);break;case 50331648:let T=V.toColor(J>>16&255,J>>8&255,J&255);this._applyMinimumContrast(p,ze,T,v,We,It)||this._addStyle(p,`color:#${J.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(p,ze,f.foreground,v,We,It)||dt&&F.push("xterm-fg-257")}F.length&&(p.className=F.join(" "),F.length=0),!H&&!C&&!Ee&&S?y++:p.textContent=E,xe!==this.defaultSpacing&&(p.style.letterSpacing=`${xe}px`),c.push(p),m=k}return p&&y&&(p.textContent=E),c}_applyMinimumContrast(e,t,i,s,r,n){if(this._optionsService.rawOptions.minimumContrastRatio===1||oh(s.getCode()))return!1;let o=this._getContrastCache(s),h;if(!r&&!n&&(h=o.getColor(t.rgba,i.rgba)),h===void 0){let l=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);h=N.ensureContrastRatio(r??t,n??i,l),o.setColor((r??t).rgba,(n??i).rgba,h??null)}return h?(this._addStyle(e,`color:${h.css}`),!0):!1}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){let i=this._selectionStart,s=this._selectionEnd;return!i||!s?!1:this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0]}};ss=K([w(1,Zr),w(2,he),w(3,Ie),w(4,je),w(5,Tt),w(6,ht)],ss);var hh=class{constructor(e=()=>new lh){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s=0;if(!t&&!i&&e.length===1&&(s=e.charCodeAt(0))<256){if(this._flat[s]!==-9999)return this._flat[s];let o=this._measure(e,0);return o>0&&(this._flat[s]=o),o}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(n===void 0){let o=0;t&&(o|=1),i&&(o|=2),n=this._measure(e,o),n>0&&this._holey.set(r,n)}return n}_measure(e,t){return this._canvasElements[t].measure(e)}},lh=class{constructor(){typeof OffscreenCanvas<"u"?(this._canvas=new OffscreenCanvas(1,1),this._ctx=dr(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=dr(this._canvas.getContext("2d")))}setFont(e,t,i,s){let r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}},ch=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1]){this.clear();return}let r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,h=Math.max(n,0),l=Math.min(o,e.rows-1);if(h>=e.rows||l<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=h,this.viewportCappedEndRow=l,this.startCol=t[0],this.endCol=i[0]}isCellSelected(e,t,i){return this.hasSelection?(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol):!1}};function dh(){return new ch}var Ci="xterm-dom-renderer-owner-",pe="xterm-rows",Ht="xterm-fg-",ur="xterm-bg-",pt="xterm-focus",Wt="xterm-selection",Gt="xterm-cursor-blink-idle",uh=1,rs=class extends I{constructor(e,t,i,s,r,n,o,h,l,a,d,c,u,f){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=n,this._linkifier2=o,this._charSizeService=l,this._optionsService=a,this._bufferService=d,this._coreService=c,this._coreBrowserService=u,this._themeService=f,this._terminalClass=uh++,this._rowElements=[],this._selectionRenderModel=dh(),this.onRequestRedraw=this._register(new b).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(pe),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(Wt),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=ah(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(_=>this._injectCss(_))),this._injectCss(this._themeService.colors),this._rowFactory=h.createInstance(ss,document),this._element.classList.add(Ci+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(_=>this._handleLinkHover(_))),this._register(this._linkifier2.onHideLinkUnderline(_=>this._handleLinkLeave(_))),this._cursorBlinkStateManager=new _h(this._rowContainer,this._coreBrowserService),this._register(B(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register(q(()=>this._cursorBlinkStateManager.dispose())),this._register(q(()=>{this._element.classList.remove(Ci+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new hh,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let i of this._rowElements)i.style.width=`${this.dimensions.css.canvas.width}px`,i.style.height=`${this.dimensions.css.cell.height}px`,i.style.lineHeight=`${this.dimensions.css.cell.height}px`,i.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .${pe} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${pe} { pointer-events: none; color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${pe} .xterm-dim { color: ${N.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;let i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${pe}.${pt} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${pe}.${pt} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${pe}.${pt} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${pe}.${Gt} .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .${pe} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${pe} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${pe} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${pe} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${pe} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${Wt} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${Wt} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${Wt} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[n,o]of e.ansi.entries())t+=`${this._terminalSelector} .${Ht}${n} { color: ${o.css}; }${this._terminalSelector} .${Ht}${n}.xterm-dim { color: ${N.multiplyOpacity(o,.5).css}; }${this._terminalSelector} .${ur}${n} { background-color: ${o.css}; }`;t+=`${this._terminalSelector} .${Ht}257 { color: ${N.opaque(e.background).css}; }${this._terminalSelector} .${Ht}257.xterm-dim { color: ${N.multiplyOpacity(N.opaque(e.background),.5).css}; }${this._terminalSelector} .${ur}257 { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let i=this._rowElements.length;i<=t;i++){let s=this._document.createElement("div");this._rowContainer.appendChild(s),this._rowElements.push(s)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(pt),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(pt),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t||(this._selectionRenderModel.update(this._terminal,e,t,i),!this._selectionRenderModel.hasSelection))return;let s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow,h=this._document.createDocumentFragment();if(i){let l=e[0]>t[0];h.appendChild(this._createSelectionElement(n,l?t[0]:e[0],l?e[0]:t[0],o-n+1))}else{let l=s===n?e[0]:0,a=n===r?t[0]:this._bufferService.cols;h.appendChild(this._createSelectionElement(n,l,a));let d=o-n-1;if(h.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,d)),n!==o){let c=r===o?t[0]:this._bufferService.cols;h.appendChild(this._createSelectionElement(o,0,c))}}this._selectionContainer.appendChild(h)}_createSelectionElement(e,t,i,s=1){let r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width,o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=`${s*this.dimensions.css.cell.height}px`,r.style.top=`${e*this.dimensions.css.cell.height}px`,r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren()}renderRows(e,t){let i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,h=this._optionsService.rawOptions.cursorInactiveStyle;for(let l=e;l<=t;l++){let a=l+i.ydisp,d=this._rowElements[l],c=i.lines.get(a);if(!d||!c)break;d.replaceChildren(...this._rowFactory.createRow(c,a,a===s,o,h,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${Ci}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);let o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);let h=this._bufferService.buffer,l=h.ybase+h.y,a=Math.min(h.x,r-1),d=this._optionsService.rawOptions.cursorBlink,c=this._optionsService.rawOptions.cursorStyle,u=this._optionsService.rawOptions.cursorInactiveStyle;for(let f=i;f<=s;++f){let _=f+h.ydisp,p=this._rowElements[f],y=h.lines.get(_);if(!p||!y)break;p.replaceChildren(...this._rowFactory.createRow(y,_,_===l,c,u,a,d,this.dimensions.css.cell.width,this._widthCache,n?f===i?e:0:-1,n?(f===s?t:r)-1:-1))}}};rs=K([w(7,Ss),w(8,hi),w(9,he),w(10,ce),w(11,je),w(12,Ie),w(13,ht)],rs);var _h=class{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove(Gt),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove(Gt),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add(Gt),this._isIdlePaused=!0,this._idleTimeout=void 0}},ns=class extends I{constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new b),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new ph(this._optionsService))}catch{this._measureStrategy=this._register(new fh(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};ns=K([w(2,he)],ns);var Bn=class extends I{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},fh=class extends Bn{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},ph=class extends Bn{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");let t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}},gh=class extends I{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=this._register(new vh(this._window)),this._onDprChange=this._register(new b),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new b),this.onWindowChange=this._onWindowChange.event,this._register(this.onWindowChange(s=>this._screenDprMonitor.setWindow(s))),this._register(_e.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(B(this._textarea,"focus",()=>this._isFocused=!0)),this._register(B(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},vh=class extends I{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new ot),this._onDprChange=this._register(new b),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(q(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=B(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}},mh=class extends I{constructor(){super(),this.linkProviders=[],this._register(q(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function Ms(e,t,i){let s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}function Sh(e,t,i,s,r,n,o,h,l){if(!n)return;let a=Ms(e,t,i);if(a)return a[0]=Math.ceil((a[0]+(l?o/2:0))/o),a[1]=Math.ceil(a[1]/h),a[0]=Math.min(Math.max(a[0],1),s+(l?1:0)),a[1]=Math.min(Math.max(a[1],1),r),a}var os=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return Sh(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){let i=Ms(be(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};os=K([w(0,Ne),w(1,hi)],os);var wh=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh())),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,e):e,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,t):t,!this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}},Dn={};eo(Dn,{getSafariVersion:()=>yh,isChromeOS:()=>Pn,isFirefox:()=>Rn,isLegacyEdge:()=>bh,isLinux:()=>Ps,isMac:()=>Dt,isNode:()=>ci,isSafari:()=>Mn,isWindows:()=>Ln});var ci=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),At=ci?"node":navigator.userAgent,Ls=ci?"node":navigator.platform,Rn=At.includes("Firefox"),bh=At.includes("Edge"),Mn=/^((?!chrome|android).)*safari/i.test(At);function yh(){if(!Mn)return 0;let e=At.match(/Version\/(\d+)/);return e===null||e.length<2?0:parseInt(e[1])}var Dt=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(Ls),Ln=["Windows","Win16","Win32","WinCE"].includes(Ls),Ps=Ls.indexOf("Linux")>=0,Pn=/\bCrOS\b/.test(At),Tn=class{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir){s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),this._start();return}s=r}this.clear()}},Ch=class extends Tn{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},kh=class extends Tn{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},oi=!ci&&"requestIdleCallback"in window?kh:Ch,xh=class{constructor(){this._queue=new oi}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}},as=class extends I{constructor(e,t,i,s,r,n,o,h,l){super(),this._rowCount=e,this._optionsService=i,this._charSizeService=s,this._coreService=r,this._coreBrowserService=h,this._renderer=this._register(new ot),this._pausedResizeTask=new xh,this._observerDisposable=this._register(new ot),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new b),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new b),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new b),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new b),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new wh((a,d)=>this._renderRows(a,d),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new Eh(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(q(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(o.onResize(()=>this._fullRefresh())),this._register(o.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(n.onDecorationRegistered(()=>this._fullRefresh())),this._register(n.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(o.buffer.y,o.buffer.y,void 0,!0))),this._register(l.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(a=>this._registerIntersectionObserver(a,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){let i=new e.IntersectionObserver(s=>this._handleIntersectionChange(s[s.length-1]),{threshold:0});i.observe(t),this._observerDisposable.value=q(()=>i.disconnect())}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(t=>this.refreshRows(t.start,t.end,t.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};as=K([w(2,he),w(3,hi),w(4,je),w(5,Tt),w(6,ce),w(7,Ie),w(8,ht)],as);var Eh=class{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??(this._timeout=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3))}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function Bh(e,t,i,s){let r=i.buffer.x,n=i.buffer.y;if(!i.buffer.hasScrollback)return Mh(r,n,e,t,i,s)+di(n,t,i,s)+Lh(r,n,e,t,i,s);let o;if(n===t)return o=r>e?"D":"C",Mt(Math.abs(r-e),Rt(o,s));o=n>t?"D":"C";let h=Math.abs(n-t),l=Rh(n>t?e:r,i)+(h-1)*i.cols+1+Dh(n>t?r:e,i);return Mt(l,Rt(o,s))}function Dh(e,t){return e-1}function Rh(e,t){return t.cols-e}function Mh(e,t,i,s,r,n){return di(t,s,r,n).length===0?"":Mt(On(e,t,e,t-it(t,r),!1,r).length,Rt("D",n))}function di(e,t,i,s){let r=e-it(e,i),n=t-it(t,i),o=Math.abs(r-n)-Ph(e,t,i);return Mt(o,Rt(An(e,t),s))}function Lh(e,t,i,s,r,n){let o;di(t,s,r,n).length>0?o=s-it(s,r):o=t;let h=s,l=Th(e,t,i,s,r,n);return Mt(On(e,o,i,h,l==="C",r).length,Rt(l,n))}function Ph(e,t,i){let s=0,r=e-it(e,i),n=t-it(t,i);for(let o=0;o=0&&e0?o=s-it(s,r):o=t,e=i&&ot?"A":"B"}function On(e,t,i,s,r,n){let o=e,h=t,l="";for(;(o!==i||h!==s)&&h>=0&&hn.cols-1?(l+=n.buffer.translateBufferLineToString(h,!1,e,o),o=0,e=0,h++):!r&&o<0&&(l+=n.buffer.translateBufferLineToString(h,!1,0,e+1),o=n.cols-1,e=o,h--);return l+n.buffer.translateBufferLineToString(h,!1,e,o)}function Rt(e,t){return"\x1B"+(t?"O":"[")+e}function Mt(e,t){e=Math.floor(e);let i="";for(let s=0;sthis._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let e=this.selectionStart,t=this.selectionEnd;return!e||!t?!1:e[1]>t[1]||e[1]===t[1]&&e[0]>t[0]}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}};function _r(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}var ki=50,Oh=15,Ih=50,Nh=500,Fh="\xA0",Hh=new RegExp(Fh,"g"),hs=class extends I{constructor(e,t,i,s,r,n,o,h,l){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=l,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new ke,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new b),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new b),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new b),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new b),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=a=>this._handleMouseMove(a),this._mouseUpListener=a=>this._handleMouseUp(a),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener=this._bufferService.buffer.lines.onTrim(a=>this._handleTrim(a)),this._register(this._bufferService.buffers.onBufferActivate(a=>this._handleBufferActivate(a))),this.enable(),this._model=new Ah(this._bufferService),this._activeSelectionMode=0,this._register(q(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(a=>{a.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";let i=this._bufferService.buffer,s=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return"";let r=e[0]r.replace(Hh," ")).join(Ln?`\r +`:` +`)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),Ps&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(e){let t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!i||!s||!t?!1:this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){let i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!i||!s?!1:this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){let i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=_r(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;let s=this._getMouseBufferCoords(e);return s?(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0):!1}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){let t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=Ms(this._coreBrowserService.window,e,this._screenElement)[1],i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-ki),ki),t/=ki,t/Math.abs(t)+Math.round(t*(Oh-1)))}shouldForceSelection(e){return Dt?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(e.button===2&&this.hasSelection)&&e.button===0){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):e.detail===1?this._handleSingleClick(e):e.detail===2?this._handleDoubleClick(e):e.detail===3&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),Ih)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){let t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);let i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&i.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){let t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(Dt&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;let t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd){this.refresh(!0);return}this._activeSelectionMode===2?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));let i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){let t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&tthis._handleTrim(t))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){let r=e.loadCell(s,this._workCell).getChars().length;this._workCell.getWidth()===0?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;let r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;let o=r.translateBufferLineToString(e[1],!1),h=this._convertViewportColToCharacterIndex(n,e[0]),l=h,a=e[0]-h,d=0,c=0,u=0,f=0;if(o.charAt(h)===" "){for(;h>0&&o.charAt(h-1)===" ";)h--;for(;l1&&(f+=A-1,l+=A-1);y>0&&h>0&&!this._isCharWordSeparator(n.loadCell(y-1,this._workCell));){n.loadCell(y-1,this._workCell);let R=this._workCell.getChars().length;this._workCell.getWidth()===0?(d++,y--):R>1&&(u+=R-1,h-=R-1),h--,y--}for(;E1&&(f+=R-1,l+=R-1),l++,E++}}l++;let _=h+a-d+u,p=Math.min(this._bufferService.cols,l-h+d+c-u-f);if(!(!t&&o.slice(h,l).trim()==="")){if(i&&_===0&&n.getCodePoint(0)!==32){let y=r.lines.get(e[1]-1);if(y&&n.isWrapped&&y.getCodePoint(this._bufferService.cols-1)!==32){let E=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(E){let A=this._bufferService.cols-E.start;_-=A,p+=A}}}if(s&&_+p===this._bufferService.cols&&n.getCodePoint(this._bufferService.cols-1)!==32){let y=r.lines.get(e[1]+1);if(y?.isWrapped&&y.getCodePoint(0)!==32){let E=this._getWordAt([0,e[1]+1],!1,!1,!0);E&&(p+=E.length)}}return{start:_,length:p}}}_selectWordAt(e,t){let i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){let t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return e.getWidth()===0?!1:this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){let t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=_r(i,this._bufferService.cols)}};hs=K([w(3,ce),w(4,je),w(5,ws),w(6,he),w(7,Ne),w(8,Ie)],hs);var fr=class{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}},pr=class{constructor(){this._color=new fr,this._css=new fr}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}},Y=Object.freeze((()=>{let e=[z.toColor("#2e3436"),z.toColor("#cc0000"),z.toColor("#4e9a06"),z.toColor("#c4a000"),z.toColor("#3465a4"),z.toColor("#75507b"),z.toColor("#06989a"),z.toColor("#d3d7cf"),z.toColor("#555753"),z.toColor("#ef2929"),z.toColor("#8ae234"),z.toColor("#fce94f"),z.toColor("#729fcf"),z.toColor("#ad7fa8"),z.toColor("#34e2e2"),z.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){let s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:V.toCss(s,r,n),rgba:V.toRgba(s,r,n)})}for(let i=0;i<24;i++){let s=8+i*10;e.push({css:V.toCss(s,s,s),rgba:V.toRgba(s,s,s)})}return e})()),Je=z.toColor("#ffffff"),yt=z.toColor("#000000"),gr=z.toColor("#ffffff"),vr=yt,gt={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},Wh=Je,ls=class extends I{constructor(e){super(),this._optionsService=e,this._contrastCache=new pr,this._halfContrastCache=new pr,this._onChangeColors=this._register(new b),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:Je,background:yt,cursor:gr,cursorAccent:vr,selectionForeground:void 0,selectionBackgroundTransparent:gt,selectionBackgroundOpaque:N.blend(yt,gt),selectionInactiveBackgroundTransparent:gt,selectionInactiveBackgroundOpaque:N.blend(yt,gt),scrollbarSliderBackground:N.opacity(Je,.2),scrollbarSliderHoverBackground:N.opacity(Je,.4),scrollbarSliderActiveBackground:N.opacity(Je,.5),overviewRulerBorder:Je,ansi:Y.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}get colors(){return this._colors}_setTheme(e={}){let t=this._colors;if(t.foreground=O(e.foreground,Je),t.background=O(e.background,yt),t.cursor=N.blend(t.background,O(e.cursor,gr)),t.cursorAccent=N.blend(t.background,O(e.cursorAccent,vr)),t.selectionBackgroundTransparent=O(e.selectionBackground,gt),t.selectionBackgroundOpaque=N.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=O(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=N.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?O(e.selectionForeground,cr):void 0,t.selectionForeground===cr&&(t.selectionForeground=void 0),N.isOpaque(t.selectionBackgroundTransparent)&&(t.selectionBackgroundTransparent=N.opacity(t.selectionBackgroundTransparent,.3)),N.isOpaque(t.selectionInactiveBackgroundTransparent)&&(t.selectionInactiveBackgroundTransparent=N.opacity(t.selectionInactiveBackgroundTransparent,.3)),t.scrollbarSliderBackground=O(e.scrollbarSliderBackground,N.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=O(e.scrollbarSliderHoverBackground,N.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=O(e.scrollbarSliderActiveBackground,N.opacity(t.foreground,.5)),t.overviewRulerBorder=O(e.overviewRulerBorder,Wh),t.ansi=Y.slice(),t.ansi[0]=O(e.black,Y[0]),t.ansi[1]=O(e.red,Y[1]),t.ansi[2]=O(e.green,Y[2]),t.ansi[3]=O(e.yellow,Y[3]),t.ansi[4]=O(e.blue,Y[4]),t.ansi[5]=O(e.magenta,Y[5]),t.ansi[6]=O(e.cyan,Y[6]),t.ansi[7]=O(e.white,Y[7]),t.ansi[8]=O(e.brightBlack,Y[8]),t.ansi[9]=O(e.brightRed,Y[9]),t.ansi[10]=O(e.brightGreen,Y[10]),t.ansi[11]=O(e.brightYellow,Y[11]),t.ansi[12]=O(e.brightBlue,Y[12]),t.ansi[13]=O(e.brightMagenta,Y[13]),t.ansi[14]=O(e.brightCyan,Y[14]),t.ansi[15]=O(e.brightWhite,Y[15]),e.extendedAnsi){let i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};function Kh(e,t,i,s){let r={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:e.key==="UIKeyInputUpArrow"?t?r.key="\x1BOA":r.key="\x1B[A":e.key==="UIKeyInputLeftArrow"?t?r.key="\x1BOD":r.key="\x1B[D":e.key==="UIKeyInputRightArrow"?t?r.key="\x1BOC":r.key="\x1B[C":e.key==="UIKeyInputDownArrow"&&(t?r.key="\x1BOB":r.key="\x1B[B");break;case 8:r.key=e.ctrlKey?"\b":"\x7F",e.altKey&&(r.key="\x1B"+r.key);break;case 9:if(e.shiftKey){r.key="\x1B[Z";break}r.key=" ",r.cancel=!0;break;case 13:r.key=e.altKey?"\x1B\r":"\r",r.cancel=!0;break;case 27:r.key="\x1B",e.altKey&&(r.key="\x1B\x1B"),r.cancel=!0;break;case 37:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"D":t?r.key="\x1BOD":r.key="\x1B[D";break;case 39:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"C":t?r.key="\x1BOC":r.key="\x1B[C";break;case 38:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"A":t?r.key="\x1BOA":r.key="\x1B[A";break;case 40:if(e.metaKey)break;n?r.key="\x1B[1;"+(n+1)+"B":t?r.key="\x1BOB":r.key="\x1B[B";break;case 45:!e.shiftKey&&!e.ctrlKey&&(r.key="\x1B[2~");break;case 46:n?r.key="\x1B[3;"+(n+1)+"~":r.key="\x1B[3~";break;case 36:n?r.key="\x1B[1;"+(n+1)+"H":t?r.key="\x1BOH":r.key="\x1B[H";break;case 35:n?r.key="\x1B[1;"+(n+1)+"F":t?r.key="\x1BOF":r.key="\x1B[F";break;case 33:e.shiftKey?r.type=2:e.ctrlKey?r.key="\x1B[5;"+(n+1)+"~":r.key="\x1B[5~";break;case 34:e.shiftKey?r.type=3:e.ctrlKey?r.key="\x1B[6;"+(n+1)+"~":r.key="\x1B[6~";break;case 112:n?r.key="\x1B[1;"+(n+1)+"P":r.key="\x1BOP";break;case 113:n?r.key="\x1B[1;"+(n+1)+"Q":r.key="\x1BOQ";break;case 114:n?r.key="\x1B[1;"+(n+1)+"R":r.key="\x1BOR";break;case 115:n?r.key="\x1B[1;"+(n+1)+"S":r.key="\x1BOS";break;case 116:n?r.key="\x1B[15;"+(n+1)+"~":r.key="\x1B[15~";break;case 117:n?r.key="\x1B[17;"+(n+1)+"~":r.key="\x1B[17~";break;case 118:n?r.key="\x1B[18;"+(n+1)+"~":r.key="\x1B[18~";break;case 119:n?r.key="\x1B[19;"+(n+1)+"~":r.key="\x1B[19~";break;case 120:n?r.key="\x1B[20;"+(n+1)+"~":r.key="\x1B[20~";break;case 121:n?r.key="\x1B[21;"+(n+1)+"~":r.key="\x1B[21~";break;case 122:n?r.key="\x1B[23;"+(n+1)+"~":r.key="\x1B[23~";break;case 123:n?r.key="\x1B[24;"+(n+1)+"~":r.key="\x1B[24~";break;default:if(e.ctrlKey&&!e.shiftKey&&!e.altKey&&!e.metaKey)e.keyCode>=65&&e.keyCode<=90?r.key=String.fromCharCode(e.keyCode-64):e.keyCode===32?r.key="\0":e.keyCode>=51&&e.keyCode<=55?r.key=String.fromCharCode(e.keyCode-51+27):e.keyCode===56?r.key="\x7F":e.key==="/"?r.key="":e.keyCode===219?r.key="\x1B":e.keyCode===220?r.key="":e.keyCode===221&&(r.key="");else if((!i||s)&&e.altKey&&!e.metaKey){let o=zh[e.keyCode]?.[e.shiftKey?1:0];if(o)r.key="\x1B"+o;else if(e.keyCode>=65&&e.keyCode<=90){let h=e.ctrlKey?e.keyCode-64:e.keyCode+32,l=String.fromCharCode(h);e.shiftKey&&(l=l.toUpperCase()),r.key="\x1B"+l}else if(e.keyCode===32)r.key="\x1B"+(e.ctrlKey?"\0":" ");else if(e.key==="Dead"&&e.code.startsWith("Key")){let h=e.code.slice(3,4);e.shiftKey||(h=h.toLowerCase()),r.key="\x1B"+h,r.cancel=!0}}else if(i&&!e.altKey&&!e.ctrlKey&&!e.shiftKey&&e.metaKey)e.keyCode===65&&(r.type=1);else if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&e.key.length===1)r.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":r.key="";break;case"Digit2":r.key="\0";break;case"Digit6":r.key="";break}break}return r}var mr=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){let t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e){let t=this._getNumpadKeyCode(e);if(t!==void 0)return t;let i=this._getModifierKeyCode(e);if(i!==void 0)return i;let s=this._functionalKeyCodes[e.key];if(s!==void 0)return s;if(e.shiftKey&&e.code){if(e.code.startsWith("Digit")&&e.code.length===6){let r=e.code.charAt(5);if(r>="0"&&r<="9")return r.charCodeAt(0)}if(e.code.startsWith("Key")&&e.code.length===4)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(e.key.length===1){let r=e.key.codePointAt(0);return r>=65&&r<=90?r+32:r}}_isModifierKey(e){return e.key==="Shift"||e.key==="Control"||e.key==="Alt"||e.key==="Meta"}_buildCsiLetterSequence(e,t,i,s){let r=s&&i!==1;if(t>0||r){let n="\x1B[1;"+(t>0?t:"1");return r&&(n+=":"+i),n+=e,n}return"\x1B["+e}_buildSs3Sequence(e,t,i,s){let r=s&&i!==1;if(t>0||r){let n="\x1B[1;"+(t>0?t:"1");return r&&(n+=":"+i),n+=e,n}return"\x1BO"+e}_buildCsiTildeSequence(e,t,i,s){let r=s&&i!==1,n="\x1B["+e;return(t>0||r)&&(n+=";"+(t>0?t:"1"),r&&(n+=":"+i)),n+="~",n}_buildCsiUSequence(e,t,i,s,r,n,o){let h=!!(r&2),l=!!(r&4),a="\x1B["+t,d;l&&e.shiftKey&&e.key.length===1&&!n&&!o&&(d=e.key.codePointAt(0),a+=":"+d);let c=r&16&&s!==3&&e.key.length===1&&!n&&!o&&!e.ctrlKey?e.key.codePointAt(0):void 0,u=h&&s!==1&&(s===3||c===void 0);return(i>0||u||c!==void 0)&&(a+=";",i>0?a+=i:u&&(a+="1"),u&&(a+=":"+s)),c!==void 0&&(a+=";"+c),a+="u",a}evaluate(e,t,i=1){let s={type:0,cancel:!1,key:void 0},r=this._encodeModifiers(e),n=this._isModifierKey(e),o=!!(t&2);if(!o&&i===3||n&&!(t&8)&&!o)return s;let h=this._csiLetterKeys[e.key];if(h)return s.key=this._buildCsiLetterSequence(h,r,i,o),s.cancel=!0,s;let l=this._ss3FunctionKeys[e.key];if(l)return s.key=this._buildSs3Sequence(l,r,i,o),s.cancel=!0,s;let a=this._csiTildeKeys[e.key];if(a!==void 0)return s.key=this._buildCsiTildeSequence(a,r,i,o),s.cancel=!0,s;let d=this._getKeyCode(e);if(d===void 0)return s;let c=this._functionalKeyCodes[e.key]!==void 0||this._getNumpadKeyCode(e)!==void 0,u=!1;return t&8||o?u=!0:t&1&&(d===27||d===127||d===13||d===9||d===32||c?u=!0:r>0&&(e.shiftKey&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1?u=!1:u=!0)),u?(s.key=this._buildCsiUSequence(e,d,r,i,t,c,n),s.cancel=!0):e.key.length===1&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&(s.key=e.key),s}static shouldUseProtocol(e){return e>0}},$h=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){let t=this._codeToVk[e.code];return t!==void 0?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(e.key==="Enter")return 10;if(e.key==="Backspace")return 127}let t=this._keyToControlChar[e.key];if(t!==void 0)return t;if(e.key.length===1){let i=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(i>=65&&i<=90)return i-64;if(i>=97&&i<=122)return i-96}return i}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&(e.code==="ControlRight"?t|=4:t|=8),e.altKey&&(e.code==="AltRight"?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){let i=this._getVirtualKeyCode(e),s=this._getScanCode(e),r=this._getUnicodeChar(e),n=t?1:0,o=this._getControlKeyState(e);return{type:0,cancel:!0,key:`\x1B[${i};${s};${r};${n};${o};1_`}}},cs=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??(this._win32InputMode=new $h),this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??(this._kittyKeyboard=new mr),this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);let t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1):Kh(e,this._coreService.decPrivateModes.applicationCursorKeys,Dt,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);let t=this._coreService.kittyKeyboard.flags;if(this.useKitty&&t&2)return this._getKittyKeyboard().evaluate(e,t,3)}get useKitty(){let e=this._coreService.kittyKeyboard.flags;return!!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard&&mr.shouldUseProtocol(e))}get useWin32InputMode(){return!!(this._optionsService.rawOptions.vtExtensions?.win32InputMode&&this._coreService.decPrivateModes.win32InputMode)}};cs=K([w(0,je),w(1,he)],cs);var Uh=class{constructor(...e){this._entries=new Map;for(let[t,i]of e)this.set(t,i)}set(e,t){let i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(let[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}},qh=class{constructor(){this._services=new Uh,this._services.set(Ss,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){let i=ao(e).sort((n,o)=>n.index-o.index),s=[];for(let n of i){let o=this._services.get(n.id);if(!o)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${n.id._id}.`);s.push(o)}let r=i.length>0?i[0].index:t.length;if(t.length!==r)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${r+1} conflicts with ${t.length} static arguments`);return new e(...t,...s)}},Vh={trace:0,debug:1,info:2,warn:3,error:4,off:5},Yh="xterm.js: ",ds=class extends I{constructor(e){super(),this._optionsService=e,this._logLevel=5,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}get logLevel(){return this._logLevel}_updateLogLevel(){this._logLevel=Vh[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tthis._length)for(let t=this._length;t=e;s--)this._array[this._getCyclicIndex(s+i.length)]=this._array[this._getCyclicIndex(s)];for(let s=0;sthis._maxLength){let s=this._length+i.length-this._maxLength;this._startIndex+=s,this._length=this._maxLength,this.onTrimEmitter.fire(s)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let r=t-1;r>=0;r--)this.set(e+r+i,this.get(e+r));let s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>22,i&2097152?this._combined[t].charCodeAt(this._combined[t].length-1):s]}set(t,i){this._data[t*D+1]=i[0],i[1].length>1?(this._combined[t]=i[1],this._data[t*D+0]=t|2097152|i[2]<<22):this._data[t*D+0]=i[1].charCodeAt(0)|i[2]<<22}getWidth(t){return this._data[t*D+0]>>22}hasWidth(t){return this._data[t*D+0]&12582912}getFg(t){return this._data[t*D+1]}getBg(t){return this._data[t*D+2]}hasContent(t){return this._data[t*D+0]&4194303}getCodePoint(t){let i=this._data[t*D+0];return i&2097152?this._combined[t].charCodeAt(this._combined[t].length-1):i&2097151}isCombined(t){return this._data[t*D+0]&2097152}getString(t){let i=this._data[t*D+0];return i&2097152?this._combined[t]:i&2097151?Ve(i&2097151):""}isProtected(t){return this._data[t*D+2]&536870912}loadCell(t,i){return zt=t*D,i.content=this._data[zt+0],i.fg=this._data[zt+1],i.bg=this._data[zt+2],i.content&2097152&&(i.combinedData=this._combined[t]),i.bg&268435456&&(i.extended=this._extendedAttrs[t]),i}setCell(t,i){i.content&2097152&&(this._combined[t]=i.combinedData),i.bg&268435456&&(this._extendedAttrs[t]=i.extended),this._data[t*D+0]=i.content,this._data[t*D+1]=i.fg,this._data[t*D+2]=i.bg}setCellFromCodepoint(t,i,s,r){r.bg&268435456&&(this._extendedAttrs[t]=r.extended),this._data[t*D+0]=i|s<<22,this._data[t*D+1]=r.fg,this._data[t*D+2]=r.bg}addCodepointToCell(t,i,s){let r=this._data[t*D+0];r&2097152?this._combined[t]+=Ve(i):r&2097151?(this._combined[t]=Ve(r&2097151)+Ve(i),r&=-2097152,r|=2097152):r=i|1<<22,s&&(r&=-12582913,r|=s<<22),this._data[t*D+0]=r}insertCells(t,i,s){if(t%=this.length,t&&this.getWidth(t-1)===2&&this.setCellFromCodepoint(t-1,0,1,s),i=0;--r)this.setCell(t+i+r,this.loadCell(t+r,wr));for(let r=0;rthis.length){if(this._data.buffer.byteLength>=s*4)this._data=new Uint32Array(this._data.buffer,0,s);else{let r=new Uint32Array(s);r.set(this._data),this._data=r}for(let r=this.length;r=t&&delete this._combined[h]}let n=Object.keys(this._extendedAttrs);for(let o=0;o=t&&delete this._extendedAttrs[h]}}return this.length=t,s*4*xi=0;--t)if(this._data[t*D+0]&4194303)return t+(this._data[t*D+0]>>22);return 0}getNoBgTrimmedLength(){for(let t=this.length-1;t>=0;--t)if(this._data[t*D+0]&4194303||this._data[t*D+2]&50331648)return t+(this._data[t*D+0]>>22);return 0}copyCellsFrom(t,i,s,r,n){let o=t._data;if(n)for(let l=r-1;l>=0;l--){for(let a=0;a=i&&(this._combined[a-i+s]=t._combined[a])}}translateToString(t,i,s,r){i=i??0,s=s??this.length,t&&(s=Math.min(s,this.getTrimmedLength())),r&&(r.length=0);let n="";for(;i>22||1}return r&&r.push(i),n}};function jh(e,t,i,s,r,n){let o=[];for(let h=0;h=h&&s0&&(y>c||d[y].getTrimmedLength()===0);y--)p++;p>0&&(o.push(h+d.length-p),o.push(p)),h+=d.length-1}return o}function Xh(e,t){let i=[],s=0,r=t[s],n=0;for(let o=0;oLt(e,a,t)).reduce((l,a)=>l+a),n=0,o=0,h=0;for(;hl&&(n-=l,o++);let a=e[o].getWidth(n-1)===2;a&&n--;let d=a?i-1:i;s.push(d),h+=d}return s}function Lt(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();let s=!e[t].hasContent(i-1)&&e[t].getWidth(i-1)===1,r=e[t+1].getWidth(0)===2;return s&&r?i-1:i}var Nn=class Fn{constructor(t){this.line=t,this.isDisposed=!1,this._disposables=[],this._id=Fn._nextId++,this._onDispose=this.register(new b),this.onDispose=this._onDispose.event}get id(){return this._id}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),Ii(this._disposables),this._disposables.length=0)}register(t){return this._disposables.push(t),t}};Nn._nextId=1;var Zh=Nn,X={},Ze=X.B;X[0]={"`":"\u25C6",a:"\u2592",b:"\u2409",c:"\u240C",d:"\u240D",e:"\u240A",f:"\xB0",g:"\xB1",h:"\u2424",i:"\u240B",j:"\u2518",k:"\u2510",l:"\u250C",m:"\u2514",n:"\u253C",o:"\u23BA",p:"\u23BB",q:"\u2500",r:"\u23BC",s:"\u23BD",t:"\u251C",u:"\u2524",v:"\u2534",w:"\u252C",x:"\u2502",y:"\u2264",z:"\u2265","{":"\u03C0","|":"\u2260","}":"\xA3","~":"\xB7"};X.A={"#":"\xA3"};X.B=void 0;X[4]={"#":"\xA3","@":"\xBE","[":"ij","\\":"\xBD","]":"|","{":"\xA8","|":"f","}":"\xBC","~":"\xB4"};X.C=X[5]={"[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};X.R={"#":"\xA3","@":"\xE0","[":"\xB0","\\":"\xE7","]":"\xA7","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xA8"};X.Q={"@":"\xE0","[":"\xE2","\\":"\xE7","]":"\xEA","^":"\xEE","`":"\xF4","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xFB"};X.K={"@":"\xA7","[":"\xC4","\\":"\xD6","]":"\xDC","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xDF"};X.Y={"#":"\xA3","@":"\xA7","[":"\xB0","\\":"\xE7","]":"\xE9","`":"\xF9","{":"\xE0","|":"\xF2","}":"\xE8","~":"\xEC"};X.E=X[6]={"@":"\xC4","[":"\xC6","\\":"\xD8","]":"\xC5","^":"\xDC","`":"\xE4","{":"\xE6","|":"\xF8","}":"\xE5","~":"\xFC"};X.Z={"#":"\xA3","@":"\xA7","[":"\xA1","\\":"\xD1","]":"\xBF","{":"\xB0","|":"\xF1","}":"\xE7"};X.H=X[7]={"@":"\xC9","[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};X["="]={"#":"\xF9","@":"\xE0","[":"\xE9","\\":"\xE7","]":"\xEA","^":"\xEE",_:"\xE8","`":"\xF4","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xFB"};var br=4294967295,yr=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=Q.clone(),this.savedCharset=Ze,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=ke.fromCharData([0,"",1,0]),this._whitespaceCell=ke.fromCharData([0," ",1,32]),this._isClearing=!1,this._memoryCleanupQueue=new oi,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new Sr(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new Qt),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new Qt),this._whitespaceCell}getBlankLine(e,t){return new Ct(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){let e=this.ybase+this.y-this.ydisp;return e>=0&&ebr?br:t}fillViewportRows(e){if(this.lines.length===0){e??(e=Q);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new Sr(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){let i=this.getNullCell(Q),s=0,r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new Ct(e,i)));else for(let o=this._rows;o>t;o--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(o),this.ybase=Math.max(this.ybase-o,0),this.ydisp=Math.max(this.ydisp-o,0),this.savedY=Math.max(this.savedY-o,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let n=0;n0){let n=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,n)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){let e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend==="conpty"&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){let i=this._optionsService.rawOptions.reflowCursorLine,s=jh(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(Q),i);if(s.length>0){let r=Xh(this.lines,s);Gh(this.lines,r.layout),this._reflowLargerAdjustViewport(e,t,r.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){let s=this.getNullCell(Q),r=i;for(;r-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length=0;o--){let h=this.lines.get(o);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;let l=[h];for(;h.isWrapped&&o>0;)h=this.lines.get(--o),l.unshift(h);if(!i){let R=this.ybase+this.y;if(R>=o&&R0&&(r.push({start:o+l.length+n,newLines:f}),n+=f.length),l.push(...f);let _=d.length-1,p=d[_];p===0&&(_--,p=d[_]);let y=l.length-c-1,E=a;for(;y>=0;){let R=Math.min(E,p);if(l[_]===void 0)break;if(l[_].copyCellsFrom(l[y],E-R,p-R,R,!0),p-=R,p===0&&(_--,p=d[_]),E-=R,E===0){y--;let x=Math.max(y,0);E=Lt(l,x,this._cols)}}for(let R=0;R0;)this.ybase===0?this.y0){let o=[],h=[];for(let p=0;p=0;p--)if(c&&c.start>a+u){for(let y=c.newLines.length-1;y>=0;y--)this.lines.set(p--,c.newLines[y]);p++,o.push({index:a+1,amount:c.newLines.length}),u+=c.newLines.length,c=r[++d]}else this.lines.set(p,h[a--]);let f=0;for(let p=o.length-1;p>=0;p--)o[p].index+=f,this.lines.onInsertEmitter.fire(o[p]),f+=o[p].amount;let _=Math.max(0,l+n-this.lines.maxLength);_>0&&this.lines.onTrimEmitter.fire(_)}}translateBufferLineToString(e,t,i=0,s){let r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=i,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(i=>{t.line>=i.index&&(t.line+=i.amount)})),t.register(this.lines.onDelete(i=>{t.line>=i.index&&t.linei.index&&(t.line-=i.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}},Qh=class extends I{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this._register(new b),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new yr(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new yr(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}},Hn=2,Wn=1,us=class extends I{constructor(e){super(),this.isUserScrolling=!1,this._onResize=this._register(new b),this.onResize=this._onResize.event,this._onScroll=this._register(new b),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,Hn),this.rows=Math.max(e.rawOptions.rows||0,Wn),this.buffers=this._register(new Qh(e,this)),this._register(this.buffers.onBufferActivate(t=>{this._onScroll.fire(t.activeBuffer.ydisp)}))}get buffer(){return this.buffers.active}resize(e,t){let i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){let i=this.buffer,s;s=this._cachedBlankLine,(!s||s.length!==this.cols||s.getFg(0)!==e.fg||s.getBg(0)!==e.bg)&&(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;let r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(i.scrollTop===0){let o=i.lines.isFull;n===i.lines.length-1?o?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),o?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{let o=n-r+1;i.lines.shiftElements(r+1,o-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){let i=this.buffer;if(e<0){if(i.ydisp===0)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);let s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};us=K([w(0,he)],us);var st={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:Dt,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",overviewRuler:{},quirks:{},vtExtensions:{}},el=["normal","bold","100","200","300","400","500","600","700","800","900"],tl=class extends I{constructor(e){super(),this._onOptionChange=this._register(new b),this.onOptionChange=this._onOptionChange.event;let t={...st};for(let i in e)if(i in t)try{let s=e[i];t[i]=this._sanitizeAndValidateOption(i,s)}catch(s){console.error(s)}this.rawOptions=t,this.options={...t},this._setupOptions(),this._register(q(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{e.indexOf(i)!==-1&&t()})}_setupOptions(){let e=i=>{if(!(i in st))throw new Error(`No option with key "${i}"`);return this.rawOptions[i]},t=(i,s)=>{if(!(i in st))throw new Error(`No option with key "${i}"`);s=this._sanitizeAndValidateOption(i,s),this.rawOptions[i]!==s&&(this.rawOptions[i]=s,this._onOptionChange.fire(i))};for(let i in this.rawOptions){let s={get:e.bind(this,i),set:t.bind(this,i)};Object.defineProperty(this.options,i,s)}}_sanitizeAndValidateOption(e,t){switch(e){case"cursorStyle":if(t||(t=st[e]),!il(t))throw new Error(`"${t}" is not a valid value for ${e}`);break;case"wordSeparator":t||(t=st[e]);break;case"fontWeight":case"fontWeightBold":if(typeof t=="number"&&1<=t&&t<=1e3)break;t=el.includes(t)?t:st[e];break;case"cursorWidth":t=Math.floor(t);case"lineHeight":case"tabStopWidth":if(t<1)throw new Error(`${e} cannot be less than 1, value: ${t}`);break;case"minimumContrastRatio":t=Math.max(1,Math.min(21,Math.round(t*10)/10));break;case"scrollback":if(t=Math.min(t,4294967295),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(t<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${t}`);break;case"rows":case"cols":if(!t&&t!==0)throw new Error(`${e} must be numeric, value: ${t}`);break;case"windowsPty":t=t??{};break}return t}};function il(e){return e==="block"||e==="underline"||e==="bar"}function kt(e,t=5){if(typeof e!="object")return e;let i=Array.isArray(e)?[]:{};for(let s in e)i[s]=t<=1?e[s]:e[s]&&kt(e[s],t-1);return i}var Cr=Object.freeze({insertMode:!1}),kr=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0}),xr=()=>({flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}),_s=class extends I{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new b),this.onData=this._onData.event,this._onUserInput=this._register(new b),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new b),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new b),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=kt(Cr),this.decPrivateModes=kt(kr),this.kittyKeyboard=xr()}reset(){this.modes=kt(Cr),this.decPrivateModes=kt(kr),this.kittyKeyboard=xr()}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;let i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(s=>s.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(t=>t.charCodeAt(0))),this._onBinary.fire(e))}};_s=K([w(0,ce),w(1,Gr),w(2,he)],_s);var Er={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>e.button===4||e.action!==1?!1:(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>e.action!==32},DRAG:{events:23,restrict:e=>!(e.action===32&&e.button===3)},ANY:{events:31,restrict:e=>!0}};function Ei(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return e.button===4?(i|=64,i|=e.action):(i|=e.button&3,e.button&4&&(i|=64),e.button&8&&(i|=128),e.action===32?i|=32:e.action===0&&!t&&(i|=3)),i}var Bi=String.fromCharCode,Br={DEFAULT:e=>{let t=[Ei(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`\x1B[M${Bi(t[0])}${Bi(t[1])}${Bi(t[2])}`},SGR:e=>{let t=e.action===0&&e.button!==4?"m":"M";return`\x1B[<${Ei(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{let t=e.action===0&&e.button!==4?"m":"M";return`\x1B[<${Ei(e,!0)};${e.x};${e.y}${t}`}},fs=class extends I{constructor(e,t,i){super(),this._bufferService=e,this._coreService=t,this._optionsService=i,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._wheelPartialScroll=0,this._onProtocolChange=this._register(new b),this.onProtocolChange=this._onProtocolChange.event;for(let s of Object.keys(Er))this.addProtocol(s,Er[s]);for(let s of Object.keys(Br))this.addEncoding(s,Br[s]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null,this._wheelPartialScroll=0}consumeWheelEvent(e,t,i){if(e.deltaY===0||e.shiftKey||t===void 0||i===void 0)return 0;let s=t/i,r=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(r/=s+0,Math.abs(e.deltaY)<50&&(r*=.3),this._wheelPartialScroll+=r,r=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows||e.button===4&&e.action===32||e.button===3&&e.action!==32||e.button!==4&&(e.action===2||e.action===3)||(e.col++,e.row++,e.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._activeEncoding==="SGR_PIXELS"))||!this._protocols[this._activeProtocol].restrict(e))return!1;let t=this._encodings[this._activeEncoding](e);return t&&(this._activeEncoding==="DEFAULT"?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(e&1),up:!!(e&2),drag:!!(e&4),move:!!(e&8),wheel:!!(e&16)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x||e.y!==t.y)return!1}else if(e.col!==t.col||e.row!==t.row)return!1;return!(e.button!==t.button||e.action!==t.action||e.ctrl!==t.ctrl||e.alt!==t.alt||e.shift!==t.shift)}};fs=K([w(0,ce),w(1,je),w(2,he)],fs);var Di=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],sl=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],j;function rl(e,t){let i=0,s=t.length-1,r;if(et[s][1])return!1;for(;s>=i;)if(r=i+s>>1,e>t[r][1])i=r+1;else if(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),s=i===0&&t!==0;if(s){let r=Qe.extractWidth(t);r===0?s=!1:r>i&&(i=r)}return Qe.createPropertyValue(0,i,s)}},Qe=class Jt{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new b,this.onChange=this._onChange.event;let t=new nl;this.register(t),this._active=t.version,this._activeProvider=t}static extractShouldJoin(t){return(t&1)!==0}static extractWidth(t){return t>>1&3}static extractCharKind(t){return t>>3}static createPropertyValue(t,i,s=!1){return(t&16777215)<<3|(i&3)<<1|(s?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(t){if(!this._providers[t])throw new Error(`unknown Unicode version "${t}"`);this._active=t,this._activeProvider=this._providers[t],this._onChange.fire(t)}register(t){this._providers[t.version]=t}wcwidth(t){return this._activeProvider.wcwidth(t)}getStringCellWidth(t){let i=0,s=0,r=t.length;for(let n=0;n=r)return i+this.wcwidth(o);let a=t.charCodeAt(n);56320<=a&&a<=57343?o=(o-55296)*1024+a-56320+65536:i+=this.wcwidth(a)}let h=this.charProperties(o,s),l=Jt.extractWidth(h);Jt.extractShouldJoin(h)&&(l-=Jt.extractWidth(s)),i+=l,s=h}return i}charProperties(t,i){return this._activeProvider.charProperties(t,i)}},ol=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}};function Dr(e){let t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1)?.get(e.cols-1),i=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);i&&t&&(i.isWrapped=t[3]!==0&&t[3]!==32)}var vt=2147483647,al=256,zn=class ps{constructor(t=32,i=32){if(this.maxLength=t,this.maxSubParamsLength=i,i>al)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(t),this.length=0,this._subParams=new Int32Array(i),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(t),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}static fromArray(t){let i=new ps;if(!t.length)return i;for(let s=Array.isArray(t[0])?1:0;s>8,r=this._subParamsIdx[i]&255;r-s>0&&t.push(Array.prototype.slice.call(this._subParams,s,r))}return t}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(t){if(this._digitIsSub=!1,this.length>=this.maxLength){this._rejectDigits=!0;return}if(t<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=t>vt?vt:t}addSubParam(t){if(this._digitIsSub=!0,!!this.length){if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength){this._rejectSubDigits=!0;return}if(t<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=t>vt?vt:t,this._subParamsIdx[this.length-1]++}}hasSubParams(t){return(this._subParamsIdx[t]&255)-(this._subParamsIdx[t]>>8)>0}getSubParams(t){let i=this._subParamsIdx[t]>>8,s=this._subParamsIdx[t]&255;return s-i>0?this._subParams.subarray(i,s):null}getSubParamsAll(){let t={};for(let i=0;i>8,r=this._subParamsIdx[i]&255;r-s>0&&(t[i]=this._subParams.slice(s,r))}return t}addDigit(t){let i;if(this._rejectDigits||!(i=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;let s=this._digitIsSub?this._subParams:this.params,r=s[i-1];s[i-1]=~r?Math.min(r*10+t,vt):t}},mt=[],hl=class{constructor(){this._state=0,this._active=mt,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){var s;(s=this._handlers)[e]??(s[e]=[]);let i=this._handlers[e];return i.push(t),{dispose:()=>{let r=i.indexOf(t);r!==-1&&i.splice(r,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=mt}reset(){if(this._state===2)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=mt,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||mt,!this._active.length)this._handlerFb(this._id,"START");else for(let e=this._active.length-1;e>=0;e--)this._active[e].start()}_put(e,t,i){if(!this._active.length)this._handlerFb(this._id,"PUT",at(e,t,i));else for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i)}start(){this.reset(),this._state=1}put(e,t,i){if(this._state!==3){if(this._state===1)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),!this._active.length)this._handlerFb(this._id,"END",e);else{let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&i===!1){for(;s>=0&&(i=this._active[s].end(e),i!==!0);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}this._active=mt,this._id=-1,this._state=0}}},Kn=class $n{constructor(t){this._handler=t,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(t,i,s){this._hitLimit||(this._data+=at(t,i,s),this._data.length>$n._payloadLimit&&(this._data="",this._hitLimit=!0))}end(t){let i=!1;if(this._hitLimit)i=!1;else if(t&&(i=this._handler(this._data),i instanceof Promise))return i.then(s=>(this._data="",this._hitLimit=!1,s));return this._data="",this._hitLimit=!1,i}};Kn._payloadLimit=1e7;var ge=Kn,St=[],ll=class{constructor(){this._handlers=Object.create(null),this._active=St,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=St}registerHandler(e,t){var s;(s=this._handlers)[e]??(s[e]=[]);let i=this._handlers[e];return i.push(t),{dispose:()=>{let r=i.indexOf(t);r!==-1&&i.splice(r,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=St,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||St,!this._active.length)this._handlerFb(this._ident,"HOOK",t);else for(let i=this._active.length-1;i>=0;i--)this._active[i].hook(t)}put(e,t,i){if(!this._active.length)this._handlerFb(this._ident,"PUT",at(e,t,i));else for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i)}unhook(e,t=!0){if(!this._active.length)this._handlerFb(this._ident,"UNHOOK",e);else{let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&i===!1){for(;s>=0&&(i=this._active[s].unhook(e),i!==!0);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}this._active=St,this._ident=0}},xt=new zn;xt.addParam(0);var Un=class qn{constructor(t){this._handler=t,this._data="",this._params=xt,this._hitLimit=!1}hook(t){this._params=t.length>1||t.params[0]?t.clone():xt,this._data="",this._hitLimit=!1}put(t,i,s){this._hitLimit||(this._data+=at(t,i,s),this._data.length>qn._payloadLimit&&(this._data="",this._hitLimit=!0))}unhook(t){let i=!1;if(this._hitLimit)i=!1;else if(t&&(i=this._handler(this._data,this._params),i instanceof Promise))return i.then(s=>(this._params=xt,this._data="",this._hitLimit=!1,s));return this._params=xt,this._data="",this._hitLimit=!1,i}};Un._payloadLimit=1e7;var Rr=Un,rt=[],cl=class{constructor(){this._state=0,this._active=rt,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){var s;(s=this._handlers)[e]??(s[e]=[]);let i=this._handlers[e];return i.push(t),{dispose:()=>{let r=i.indexOf(t);r!==-1&&i.splice(r,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=rt}reset(){if(this._state===2)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=rt,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||rt,!this._active.length)this._handlerFb(this._id,"START");else for(let e=this._active.length-1;e>=0;e--)this._active[e].start()}_put(e,t,i){if(!this._active.length)this._handlerFb(this._id,"PUT",at(e,t,i));else for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i)}start(){this.reset(),this._state=1}put(e,t,i){this._state!==3&&(this._state===1&&t0&&this._put(e,t,i))}end(e,t=!0){if(this._state!==0){if(this._state!==3){if(this._state===1){this._active=rt,this._id=-1,this._state=0;return}if(!this._active.length)this._handlerFb(this._id,"END",e);else{let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&i===!1){for(;s>=0&&(i=this._active[s].end(e),i!==!0);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}}this._active=rt,this._id=-1,this._state=0}}},Vn=class Yn{constructor(t){this._handler=t,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(t,i,s){this._hitLimit||(this._data+=at(t,i,s),this._data.length>Yn._payloadLimit&&(this._data="",this._hitLimit=!0))}end(t){let i=!1;if(this._hitLimit)i=!1;else if(t&&(i=this._handler(this._data),i instanceof Promise))return i.then(s=>(this._data="",this._hitLimit=!1,s));return this._data="",this._hitLimit=!1,i}};Vn._payloadLimit=1e7;var dl=Vn,ul=class{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rh),i=(o,h)=>t.slice(o,h),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));let n=i(0,15);e.setDefault(1,0),e.addMany(s,0,2,0);for(let o of n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158],o,0,7),e.add(159,o,15,14),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,15,14),e.addMany(s,14,16,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany([27,156,24,26],14,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(ue,0,2,0),e.add(ue,8,5,8),e.add(ue,6,0,6),e.add(ue,11,0,11),e.add(ue,13,13,13),e.add(ue,14,16,14),e})(),fl=class extends I{constructor(e=_l){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new zn,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(t,i,s)=>{},this._executeHandlerFb=t=>{},this._csiHandlerFb=(t,i)=>{},this._escHandlerFb=t=>{},this._errorHandlerFb=t=>t,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register(q(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new hl),this._dcsParser=this._register(new ll),this._apcParser=this._register(new cl),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let r=0;rn||n>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=n}}if(e.final.length!==1)throw new Error("final must be a single byte");let s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){let t=[];for(;e;)t.push(String.fromCharCode(e&255)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){var r;let i=this._identifier(e,[48,126]);(r=this._escHandlers)[i]??(r[i]=[]);let s=this._escHandlers[i];return s.push(t),{dispose:()=>{let n=s.indexOf(t);n!==-1&&s.splice(n,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){var r;let i=this._identifier(e);(r=this._csiHandlers)[i]??(r[i]=[]);let s=this._csiHandlers[i];return s.push(t),{dispose:()=>{let n=s.indexOf(t);n!==-1&&s.splice(n,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return this._apcParser.registerHandler(e,t)}clearApcHandler(e){this._apcParser.clearHandler(e)}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s=0,r=0,n=0,o;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(i===void 0||this._parseStack.state===1)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");let h=this._parseStack.handlers,l=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(i===!1&&l>-1){for(;l>=0&&(o=h[l](this._params),o!==!0);l--)if(o instanceof Promise)return this._parseStack.handlerPos=l,o}this._parseStack.handlers=[];break;case 4:if(i===!1&&l>-1){for(;l>=0&&(o=h[l](),o!==!0);l--)if(o instanceof Promise)return this._parseStack.handlerPos=l,o}this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(s!==24&&s!==26,i),o)return o;s===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(s!==24&&s!==26,i),o)return o;s===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(s!==24&&s!==26,i),o)return o;s===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=this._parseStack.transition&255}for(let h=n;h>8){case 2:for(let u=h+1;;++u){if(u>=t||(s=e[u])<32||s>126&&s=t||(s=e[u])<32||s>126&&s=t||(s=e[u])<32||s>126&&s=t||(s=e[u])<32||s>126&&s=0&&(o=l[a](this._params),o!==!0);a--)if(o instanceof Promise)return this._preserveStack(3,l,a,r,h),o;a<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}while(++h47&&s<60);h--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:let d=this._escHandlers[this._collect<<8|s],c=d?d.length-1:-1;for(;c>=0&&(o=d[c](),o!==!0);c--)if(o instanceof Promise)return this._preserveStack(4,d,c,r,h),o;c<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let u=h+1;;++u)if(u>=t||(s=e[u])===24||s===26||s===27||s>127&&s=t||(s=e[u])<32||s>127&&s=t||(s=e[u])===24||s===26||s===27||s===156||s>127&&s>4:n>>8}return s}}function Ri(e,t){let i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}function vl(e,t=16){let[i,s,r]=e;return`rgb:${Ri(i,t)}/${Ri(s,t)}/${Ri(r,t)}`}var ml="6.1.0-beta.141",Sl={"(":0,")":1,"*":2,"+":3,"-":1,".":2},qe=131072,Lr=10;function Pr(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var Tr=5e3,Ar=0,wl=class extends I{constructor(e,t,i,s,r,n,o,h,l=new fl){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=n,this._coreMouseService=o,this._unicodeService=h,this._parser=l,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new no,this._utf8Decoder=new oo,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=Q.clone(),this._eraseAttrDataInternal=Q.clone(),this._onRequestBell=this._register(new b),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new b),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new b),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new b),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new b),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new b),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new b),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new b),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new b),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new b),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new b),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new b),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new b),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new b),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new gs(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(a=>this._activeBuffer=a.activeBuffer)),this._parser.setCsiHandlerFallback((a,d)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(a),params:d.toArray()})}),this._parser.setEscHandlerFallback(a=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(a)})}),this._parser.setExecuteHandlerFallback(a=>{this._logService.debug("Unknown EXECUTE code: ",{code:a})}),this._parser.setOscHandlerFallback((a,d,c)=>{this._logService.debug("Unknown OSC code: ",{identifier:a,action:d,data:c})}),this._parser.setDcsHandlerFallback((a,d,c)=>{d==="HOOK"&&(c=c.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(a),action:d,payload:c})}),this._parser.setPrintHandler((a,d,c)=>this.print(a,d,c)),this._parser.registerCsiHandler({final:"@"},a=>this.insertChars(a)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},a=>this.scrollLeft(a)),this._parser.registerCsiHandler({final:"A"},a=>this.cursorUp(a)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},a=>this.scrollRight(a)),this._parser.registerCsiHandler({final:"B"},a=>this.cursorDown(a)),this._parser.registerCsiHandler({final:"C"},a=>this.cursorForward(a)),this._parser.registerCsiHandler({final:"D"},a=>this.cursorBackward(a)),this._parser.registerCsiHandler({final:"E"},a=>this.cursorNextLine(a)),this._parser.registerCsiHandler({final:"F"},a=>this.cursorPrecedingLine(a)),this._parser.registerCsiHandler({final:"G"},a=>this.cursorCharAbsolute(a)),this._parser.registerCsiHandler({final:"H"},a=>this.cursorPosition(a)),this._parser.registerCsiHandler({final:"I"},a=>this.cursorForwardTab(a)),this._parser.registerCsiHandler({final:"J"},a=>this.eraseInDisplay(a,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},a=>this.eraseInDisplay(a,!0)),this._parser.registerCsiHandler({final:"K"},a=>this.eraseInLine(a,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},a=>this.eraseInLine(a,!0)),this._parser.registerCsiHandler({final:"L"},a=>this.insertLines(a)),this._parser.registerCsiHandler({final:"M"},a=>this.deleteLines(a)),this._parser.registerCsiHandler({final:"P"},a=>this.deleteChars(a)),this._parser.registerCsiHandler({final:"S"},a=>this.scrollUp(a)),this._parser.registerCsiHandler({final:"T"},a=>this.scrollDown(a)),this._parser.registerCsiHandler({final:"X"},a=>this.eraseChars(a)),this._parser.registerCsiHandler({final:"Z"},a=>this.cursorBackwardTab(a)),this._parser.registerCsiHandler({final:"^"},a=>this.scrollDown(a)),this._parser.registerCsiHandler({final:"`"},a=>this.charPosAbsolute(a)),this._parser.registerCsiHandler({final:"a"},a=>this.hPositionRelative(a)),this._parser.registerCsiHandler({final:"b"},a=>this.repeatPrecedingCharacter(a)),this._parser.registerCsiHandler({final:"c"},a=>this.sendDeviceAttributesPrimary(a)),this._parser.registerCsiHandler({prefix:">",final:"c"},a=>this.sendDeviceAttributesSecondary(a)),this._parser.registerCsiHandler({final:"d"},a=>this.linePosAbsolute(a)),this._parser.registerCsiHandler({final:"e"},a=>this.vPositionRelative(a)),this._parser.registerCsiHandler({final:"f"},a=>this.hVPosition(a)),this._parser.registerCsiHandler({final:"g"},a=>this.tabClear(a)),this._parser.registerCsiHandler({final:"h"},a=>this.setMode(a)),this._parser.registerCsiHandler({prefix:"?",final:"h"},a=>this.setModePrivate(a)),this._parser.registerCsiHandler({final:"l"},a=>this.resetMode(a)),this._parser.registerCsiHandler({prefix:"?",final:"l"},a=>this.resetModePrivate(a)),this._parser.registerCsiHandler({final:"m"},a=>this.charAttributes(a)),this._parser.registerCsiHandler({final:"n"},a=>this.deviceStatus(a)),this._parser.registerCsiHandler({prefix:"?",final:"n"},a=>this.deviceStatusPrivate(a)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},a=>this.softReset(a)),this._parser.registerCsiHandler({prefix:">",final:"q"},a=>this.sendXtVersion(a)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},a=>this.setCursorStyle(a)),this._parser.registerCsiHandler({final:"r"},a=>this.setScrollRegion(a)),this._parser.registerCsiHandler({final:"s"},a=>this.saveCursor(a)),this._parser.registerCsiHandler({final:"t"},a=>this.windowOptions(a)),this._parser.registerCsiHandler({final:"u"},a=>this.restoreCursor(a)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},a=>this.insertColumns(a)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},a=>this.deleteColumns(a)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},a=>this.selectProtected(a)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},a=>this.requestMode(a,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},a=>this.requestMode(a,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},a=>this.kittyKeyboardSet(a)),this._parser.registerCsiHandler({prefix:"?",final:"u"},a=>this.kittyKeyboardQuery(a)),this._parser.registerCsiHandler({prefix:">",final:"u"},a=>this.kittyKeyboardPush(a)),this._parser.registerCsiHandler({prefix:"<",final:"u"},a=>this.kittyKeyboardPop(a)),this._parser.setExecuteHandler("\x07",()=>this.bell()),this._parser.setExecuteHandler(` +`,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ge(a=>(this.setTitle(a),this.setIconName(a),!0))),this._parser.registerOscHandler(1,new ge(a=>this.setIconName(a))),this._parser.registerOscHandler(2,new ge(a=>this.setTitle(a))),this._parser.registerOscHandler(4,new ge(a=>this.setOrReportIndexedColor(a))),this._parser.registerOscHandler(8,new ge(a=>this.setHyperlink(a))),this._parser.registerOscHandler(10,new ge(a=>this.setOrReportFgColor(a))),this._parser.registerOscHandler(11,new ge(a=>this.setOrReportBgColor(a))),this._parser.registerOscHandler(12,new ge(a=>this.setOrReportCursorColor(a))),this._parser.registerOscHandler(104,new ge(a=>this.restoreIndexedColor(a))),this._parser.registerOscHandler(110,new ge(a=>this.restoreFgColor(a))),this._parser.registerOscHandler(111,new ge(a=>this.restoreBgColor(a))),this._parser.registerOscHandler(112,new ge(a=>this.restoreCursorColor(a))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let a in X)this._parser.registerEscHandler({intermediates:"(",final:a},()=>this.selectCharset("("+a)),this._parser.registerEscHandler({intermediates:")",final:a},()=>this.selectCharset(")"+a)),this._parser.registerEscHandler({intermediates:"*",final:a},()=>this.selectCharset("*"+a)),this._parser.registerEscHandler({intermediates:"+",final:a},()=>this.selectCharset("+"+a)),this._parser.registerEscHandler({intermediates:"-",final:a},()=>this.selectCharset("-"+a)),this._parser.registerEscHandler({intermediates:".",final:a},()=>this.selectCharset("."+a)),this._parser.registerEscHandler({intermediates:"/",final:a},()=>this.selectCharset("/"+a));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(a=>(this._logService.error("Parsing error: ",a),a)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new Rr((a,d)=>this.requestStatusString(a,d)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,i=new Promise((s,r)=>{t=setTimeout(()=>r("#SLOW_TIMEOUT"),Tr)});Promise.race([e,i]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn(`async parser handler taking longer than ${Tr} ms`)})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0,o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>qe&&(n=this._parseStack.position+qe)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,a=>String.fromCharCode(a)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(a=>a.charCodeAt(0)):e),this._parseBuffer.lengthqe)for(let a=n;a0&&c.getWidth(this._activeBuffer.x-1)===2&&c.setCellFromCodepoint(this._activeBuffer.x-1,0,1,d);let u=this._parser.precedingJoinState;for(let f=t;fh){if(l){let E=c,A=this._activeBuffer.x-y;if(this._activeBuffer.x=y,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),c=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!c)return;for(y>0&&c instanceof Ct&&c.copyCellsFrom(E,A,0,y,!1);A=0;)c.setCellFromCodepoint(this._activeBuffer.x++,0,0,d);continue}if(a&&(c.insertCells(this._activeBuffer.x,r-y,this._activeBuffer.getNullCell(d)),c.getWidth(h-1)===2&&c.setCellFromCodepoint(h-1,0,1,d)),c.setCellFromCodepoint(this._activeBuffer.x++,s,r,d),r>0)for(;--r;)c.setCellFromCodepoint(this._activeBuffer.x++,0,0,d)}this._parser.precedingJoinState=u,this._activeBuffer.x0&&c.getWidth(this._activeBuffer.x)===0&&!c.hasContent(this._activeBuffer.x)&&c.setCellFromCodepoint(this._activeBuffer.x,0,1,d),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,i=>Pr(i.params[0],this._optionsService.rawOptions.windowOptions)?t(i):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new Rr(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ge(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new dl(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){let n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n&&(n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1))}_resetBufferLine(e,t=!1){let i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let i;switch(e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);i=this._bufferService.cols){let r=this._activeBuffer.lines.get(i+1);r&&(r.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+i)?.getTrimmedLength(););for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let l=h;for(let a=1;a0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${ml})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(p[p.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",p[p.SET=1]="SET",p[p.RESET=2]="RESET",p[p.PERMANENTLY_SET=3]="PERMANENTLY_SET",p[p.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(void 0||(i={}));let s=this._coreService.decPrivateModes,{activeProtocol:r,activeEncoding:n}=this._coreMouseService,o=this._coreService,{buffers:h,cols:l}=this._bufferService,{active:a,alt:d}=h,c=this._optionsService.rawOptions,u=(p,y)=>(o.triggerDataEvent(`\x1B[${t?"":"?"}${p};${y}$y`),!0),f=p=>p?1:2,_=e.params[0];return t?_===2?u(_,4):_===4?u(_,f(o.modes.insertMode)):_===12?u(_,3):_===20?u(_,f(c.convertEol)):u(_,0):_===1?u(_,f(s.applicationCursorKeys)):_===3?u(_,c.windowOptions.setWinLines?l===80?2:l===132?1:0:0):_===6?u(_,f(s.origin)):_===7?u(_,f(s.wraparound)):_===8?u(_,3):_===9?u(_,f(r==="X10")):_===12?u(_,f(c.cursorBlink)):_===25?u(_,f(!o.isCursorHidden)):_===45?u(_,f(s.reverseWraparound)):_===66?u(_,f(s.applicationKeypad)):_===67?u(_,4):_===1e3?u(_,f(r==="VT200")):_===1002?u(_,f(r==="DRAG")):_===1003?u(_,f(r==="ANY")):_===1004?u(_,f(s.sendFocus)):_===1005?u(_,4):_===1006?u(_,f(n==="SGR")):_===1015?u(_,4):_===1016?u(_,f(n==="SGR_PIXELS")):_===1048?u(_,1):_===47||_===1047||_===1049?u(_,f(a===d)):_===2004?u(_,f(s.bracketedPasteMode)):_===2026?u(_,f(s.synchronizedOutput)):_===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?u(_,f(s.win32InputMode)):u(_,0)}_updateAttrColor(e,t,i,s,r){return t===2?(e|=50331648,e&=-16777216,e|=Pt.fromColorRGB([i,s,r])):t===5&&(e&=-50331904,e|=33554432|i&255),e}_extractColor(e,t,i){let s=[0,0,-1,0,0,0],r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){let o=e.getSubParams(t+n),h=0;do s[1]===5&&(r=1),s[n+h+1+r]=o[h];while(++h=2||s[1]===2&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=Q.fg,e.bg=Q.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,i,s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777216|i-90|8):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777216|i-100|8):i===0?this._processSGR0(s):i===1?s.fg|=134217728:i===3?s.bg|=67108864:i===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):i===5?s.fg|=536870912:i===7?s.fg|=67108864:i===8?s.fg|=1073741824:i===9?s.fg|=2147483648:i===2?s.bg|=134217728:i===21?this._processUnderline(2,s):i===22?(s.fg&=-134217729,s.bg&=-134217729):i===23?s.bg&=-67108865:i===24?(s.fg&=-268435457,this._processUnderline(0,s)):i===25?s.fg&=-536870913:i===27?s.fg&=-67108865:i===28?s.fg&=-1073741825:i===29?s.fg&=2147483647:i===39?(s.fg&=-67108864,s.fg|=Q.fg&16777215):i===49?(s.bg&=-67108864,s.bg|=Q.bg&16777215):i===38||i===48||i===58?r+=this._extractColor(e,r,s):i===53?s.bg|=1073741824:i===55?s.bg&=-1073741825:i===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:i===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:i===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,i=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${i}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,i=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${i}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=Q.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let i=t%2===1;this._coreService.decPrivateModes.cursorBlink=i}return!0}setScrollRegion(e){let t=e.params[0]||1,i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||i===0)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!Pr(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>Lr&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>Lr&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=i.shift(),r=i.shift();if(/^\d+$/.exec(s)){let n=parseInt(s);if(Or(n))if(r==="?")t.push({type:0,index:n});else{let o=Mr(r);o&&t.push({type:1,index:n,color:o})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):i.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let i=e.split(":"),s,r=i.findIndex(n=>n.startsWith("id="));return r!==-1&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let i=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(i[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let r=Mr(i[s]);r&&this._onColor.fire([{type:1,index:this._specialColors[t],color:r}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],i=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=Q.clone(),this._eraseAttrDataInternal=Q.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new ke;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${o}\x1B\\`),!0),s=this._bufferService.buffer,r=this._optionsService.rawOptions;return i(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${{block:2,underline:4,bar:6}[r.cursorStyle]-(r.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,i=e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let r=0;r0;r++)i.flags=s.pop();return s.length===0&&t>0&&(i.flags=0),!0}},gs=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(Ar=e,e=t,t=Ar),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};gs=K([w(0,ce)],gs);function Or(e){return 0<=e&&e<256}var bl=5e7,Ir=12,yl=50,Cl=class extends I{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this._register(new b),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._isSyncWriting)return;this._isSyncWriting=!0;let e;for(;e=this._writeBuffer.shift();){this._action(e);let t=this._callbacks.shift();t&&t()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1}writeSync(e,t){if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let i;for(;i=this._writeBuffer.shift();){this._action(i);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>bl)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}setTimeout(()=>this._innerWrite())}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){let i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],r=this._action(s,t);if(r){let o=h=>performance.now()-i>=Ir?setTimeout(()=>this._innerWrite(0,h)):this._innerWrite(i,h);r.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(o);return}let n=this._callbacks[this._bufferOffset];if(n&&n(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-i>=Ir)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>yl&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout(()=>this._innerWrite())):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}},vs=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){let t=this._bufferService.buffer;if(e.id===void 0){let h=t.addMarker(t.ybase+t.y),l={data:e,id:this._nextId++,lines:[h]};return h.onDispose(()=>this._removeMarkerFromLink(l,h)),this._dataByLinkId.set(l.id,l),l.id}let i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;let n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose(()=>this._removeMarkerFromLink(o,n)),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){let i=this._dataByLinkId.get(e);if(i&&i.lines.every(s=>s.line!==t)){let s=this._bufferService.buffer.addMarker(t);i.lines.push(s),s.onDispose(()=>this._removeMarkerFromLink(i,s))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){let i=e.lines.indexOf(t);i!==-1&&(e.lines.splice(i,1),e.lines.length===0&&(e.data.id!==void 0&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};vs=K([w(0,ce)],vs);var Nr=!1,kl=class extends I{constructor(e){super(),this._windowsWrappingHeuristics=this._register(new ot),this._onBinary=this._register(new b),this.onBinary=this._onBinary.event,this._onData=this._register(new b),this.onData=this._onData.event,this._onLineFeed=this._register(new b),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new b),this.onRender=this._onRender.event,this._onResize=this._register(new b),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new b),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new b),this._instantiationService=new qh,this.optionsService=this._register(new tl(e)),this._instantiationService.setService(he,this.optionsService),this._bufferService=this._register(this._instantiationService.createInstance(us)),this._instantiationService.setService(ce,this._bufferService),this._logService=this._register(this._instantiationService.createInstance(ds)),this._instantiationService.setService(Gr,this._logService),this.coreService=this._register(this._instantiationService.createInstance(_s)),this._instantiationService.setService(je,this.coreService),this.coreMouseService=this._register(this._instantiationService.createInstance(fs)),this._instantiationService.setService(Xr,this.coreMouseService),this.unicodeService=this._register(this._instantiationService.createInstance(Qe)),this._instantiationService.setService(co,this.unicodeService),this._charsetService=this._instantiationService.createInstance(ol),this._instantiationService.setService(lo,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(vs),this._instantiationService.setService(Jr,this._oscLinkService),this._inputHandler=this._register(new wl(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this._register(_e.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(_e.forward(this._bufferService.onResize,this._onResize)),this._register(_e.forward(this.coreService.onData,this._onData)),this._register(_e.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new Cl((t,i)=>this._inputHandler.parse(t,i))),this._register(_e.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Nr&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Nr=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,Hn),t=Math.max(t,Wn),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.buildNumber!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Dr.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Dr(this._bufferService),!1))),this._windowsWrappingHeuristics.value=q(()=>{for(let t of e)t.dispose()})}}},U=0,xl=class{constructor(e){this._getKey=e,this._array=[],this._insertedValues=[],this._flushInsertedTask=new oi,this._isFlushingInserted=!1,this._deletedIndices=[],this._flushDeletedTask=new oi,this._isFlushingDeleted=!1}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){let e=this._insertedValues.sort((r,n)=>this._getKey(r)-this._getKey(n)),t=0,i=0,s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),this._array.length===0)return!1;let t=this._getKey(e);if(t===void 0||(U=this._search(t),U===-1)||this._getKey(this._array[U])!==t)return!1;do if(this._array[U]===e)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(U),!0;while(++Ur-n),t=0,i=new Array(this._array.length-e.length),s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(U=this._search(e),!(U<0||U>=this._array.length)&&this._getKey(this._array[U])===e))do yield this._array[U];while(++U=this._array.length)&&this._getKey(this._array[U])===e))do t(this._array[U]);while(++U=t;){let s=t+i>>1,r=this._getKey(this._array[s]);if(r>e)i=s-1;else if(r0&&this._getKey(this._array[s-1])===e;)s--;return s}}return t}},Mi=0,Fr=0,Li=0,Hr=0,El=class extends I{constructor(){super(),this._decorations=new xl(e=>e?.marker.line),this._onDecorationRegistered=this._register(new b),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new b),this.onDecorationRemoved=this._onDecorationRemoved.event,this._register(q(()=>this.reset()))}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new Bl(e);if(t){let i=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),i.dispose())});this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0,n=0,o=0;for(let h of this._decorations.values())n=h.marker.line,o=n+(h.options.height??1),!(t=o)&&(s=h.options.x??0,r=s+(h.options.width??1),e>=s&&e=Hr)&&(Mi=r.options.x??0,Fr=Mi+(r.options.width??1),e>=Mi&&e=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){let r=s-this._lastRefreshMs,n=this._debounceThresholdMS-r;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},n)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}},Wr=20,zr=!1,ai=class extends I{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";let r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let n=0;nthis._handleBoundaryFocus(n,0),this._bottomBoundaryFocusListener=n=>this._handleBoundaryFocus(n,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Rl(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");zr?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=r.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(r.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(r.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(n=>this._handleResize(n.rows))),this._register(this._terminal.onRender(n=>this._refreshRows(n.start,n.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(n=>this._handleChar(n))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` +`))),this._register(this._terminal.onA11yTab(n=>this._handleTab(n))),this._register(this._terminal.onKey(n=>this._handleKey(n.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register(B(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register(q(()=>{zr?this._debugRootContainer.remove():this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` +`&&(this._liveRegionLineCount++,this._liveRegionLineCount===Wr+1&&(this._liveRegion.textContent=Zt.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){let n=i.lines.get(i.ydisp+r),o=[],h=n?.translateToString(!0,void 0,void 0,o)||"",l=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(h.length===0?(a.textContent="\xA0",this._rowColumns.set(a,[0,1])):(a.textContent=h,this._rowColumns.set(a,o)),a.setAttribute("aria-posinset",l),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent===Zt.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){let i=e.target,s=this._rowElements[t===0?1:this._rowElements.length-2],r=i.getAttribute("aria-posinset"),n=t===0?"1":`${this._terminal.buffer.lines.length}`;if(r===n||e.relatedTarget!==s)return;let o,h;if(t===0?(o=i,h=this._rowElements.pop(),this._rowContainer.removeChild(h)):(o=this._rowElements.shift(),h=i,this._rowContainer.removeChild(o)),o.removeEventListener("focus",this._topBoundaryFocusListener),h.removeEventListener("focus",this._bottomBoundaryFocusListener),t===0){let l=this._createAccessibilityTreeNode();this._rowElements.unshift(l),this._rowContainer.insertAdjacentElement("afterbegin",l)}else{let l=this._createAccessibilityTreeNode();this._rowElements.push(l),this._rowContainer.appendChild(l)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error("anchorNode and/or focusNode are null");return}let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;let r=({node:h,offset:l})=>{let a=h instanceof Text?h.parentNode:h,d=parseInt(a?.getAttribute("aria-posinset"),10)-1;if(isNaN(d))return console.warn("row is invalid. Race condition?"),null;let c=this._rowColumns.get(a);if(!c)return console.warn("columns is null. Race condition?"),null;let u=l=this._terminal.cols&&(++d,u=0),{row:d,column:u}},n=r(t),o=r(i);if(!(!n||!o)){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let t=this._rowContainer.children.length;te;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{Ii(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(B(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(B(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register(B(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register(B(this._element,"mouseup",this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;let i=e.composedPath();for(let s=0;s{s?.forEach(r=>{r.link.dispose&&r.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(let[s,r]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(s)&&(i=this._checkLinkProviderResult(s,e,i)):r.provideLinks(e.y,n=>{if(this._isMouseOut)return;let o=n?.map(h=>({link:h}));this._activeProviderReplies?.set(s,o),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let i=new Set;for(let s=0;se?this._bufferService.cols:o.link.range.end.x;for(let a=h;a<=l;a++){if(i.has(a)){r.splice(n--,1);break}i.add(a)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;let s=this._activeProviderReplies.get(e),r=!1;for(let n=0;nthis._linkAtPosition(o.link,t));n&&(i=!0,this._handleNewLink(n))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let n=0;nthis._linkAtPosition(h.link,t));if(o){i=!0,this._handleNewLink(o);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink&&Ml(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,Ii(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:i=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==i&&(this._currentLink.state.decorations.pointerCursor=i,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",i))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:i=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==i&&(this._currentLink.state.decorations.underline=i,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,i))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(i=>{if(!this._currentLink)return;let s=i.start===0?0:i.start+1+this._bufferService.buffer.ydisp,r=this._bufferService.buffer.ydisp+1+i.end;if(this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=r&&(this._clearCurrentLink(s,r),this._lastMouseEvent)){let n=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);n&&this._askForLink(n,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){let i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){let i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){let s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};ms=K([w(1,ws),w(2,Ne),w(3,ce),w(4,Qr)],ms);function Ml(e,t){return e.text===t.text&&e.range.start.x===t.range.start.x&&e.range.start.y===t.range.start.y&&e.range.end.x===t.range.end.x&&e.range.end.y===t.range.end.y}var Ll=class extends kl{constructor(e={}){super(e),this._linkifier=this._register(new ot),this.browser=Dn,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new ot),this._onCursorMove=this._register(new b),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new b),this.onKey=this._onKey.event,this._onSelectionChange=this._register(new b),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new b),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new b),this.onBell=this._onBell.event,this._onFocus=this._register(new b),this._onBlur=this._register(new b),this._onA11yCharEmitter=this._register(new b),this._onA11yTabEmitter=this._register(new b),this._onWillOpen=this._register(new b),this._onDimensionsChange=this._register(new b),this.onDimensionsChange=this._onDimensionsChange.event,this._setup(),this._decorationService=this._instantiationService.createInstance(El),this._instantiationService.setService(Tt,this._decorationService),this._keyboardService=this._instantiationService.createInstance(cs),this._instantiationService.setService(fo,this._keyboardService),this._linkProviderService=this._instantiationService.createInstance(mh),this._instantiationService.setService(Qr,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(Oi)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(t=>this._reportWindowsOptions(t))),this._register(this._inputHandler.onColor(t=>this._handleColorEvent(t))),this._register(_e.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(_e.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(_e.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(_e.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(t=>this._afterResize(t.cols,t.rows))),this._register(q(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}get dimensions(){if(!this._renderService)return;let e=this._renderService.dimensions;return{css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}}_handleColorEvent(e){if(this._themeService)for(let t of e){let i,s="";switch(t.index){case 256:i="foreground",s="10";break;case 257:i="background",s="11";break;case 258:i="cursor",s="12";break;default:i="ansi",s="4;"+t.index}switch(t.type){case 0:let r=N.toColorRGB(i==="ansi"?this._themeService.colors.ansi[t.index]:this._themeService.colors[i]);this.coreService.triggerDataEvent(`\x1B]${s};${vl(r)}\x1B\\`);break;case 1:if(i==="ansi")this._themeService.modifyColors(n=>n.ansi[t.index]=V.toColor(...t.color));else{let n=i;this._themeService.modifyColors(o=>o[n]=V.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_reportColorScheme(){if(!this._themeService)return;let e=oe.relativeLuminance(this._themeService.colors.background.rgba>>8),t=oe.relativeLuminance(this._themeService.colors.foreground.rgba>>8),i=e{this.hasSelection()&&so(t,this._selectionService)}));let e=t=>ro(t,this.textarea,this.coreService,this.optionsService);this._register(B(this.textarea,"paste",e)),this._register(B(this.element,"paste",e)),Rn?this._register(B(this.element,"mousedown",t=>{t.button===2&&Fs(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(B(this.element,"contextmenu",t=>{Fs(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),Ps&&this._register(B(this.element,"auxclick",t=>{t.button===1&&qr(t,this.textarea,this.screenElement)}))}_bindKeys(){this._register(B(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register(B(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register(B(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register(B(this.textarea,"compositionstart",()=>this._compositionHelper.compositionstart())),this._register(B(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register(B(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register(B(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",r=>this.element.classList.toggle("allow-transparency",r))),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register(B(this.screenElement,"mousemove",r=>this.updateCursorStyle(r))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",Ti.get()),Pn||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(gh,this.textarea,e.ownerDocument.defaultView??window,this._document??typeof window<"u"?window.document:null)),this._instantiationService.setService(Ie,this._coreBrowserService),this._register(B(this.textarea,"focus",r=>this._handleTextAreaFocus(r))),this._register(B(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(ns,this._document,this._helperContainer),this._instantiationService.setService(hi,this._charSizeService),this._themeService=this._instantiationService.createInstance(ls),this._instantiationService.setService(ht,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(ni),this._instantiationService.setService(Zr,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(as,this.rows,this.screenElement)),this._instantiationService.setService(Ne,this._renderService),this._register(this._renderService.onRenderedViewportChange(r=>this._onRender.fire(r))),this._register(this._renderService.onDimensionsChange(r=>this._onDimensionsChange.fire({css:{canvas:{...r.css.canvas},cell:{...r.css.cell}},device:{canvas:{...r.device.canvas},cell:{...r.device.cell},char:{...r.device.char}}}))),this.onResize(r=>this._renderService.resize(r.cols,r.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(is,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(os),this._instantiationService.setService(ws,this._mouseService);let s=this._linkifier.value=this._register(this._instantiationService.createInstance(ms,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>this._renderService.handleResize(this.cols,this.rows))),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(es,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(r=>{super.scrollLines(r,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(hs,this.element,this.screenElement,s)),this._instantiationService.setService(_o,this._selectionService),this._register(this._selectionService.onRequestScrollLines(r=>this.scrollLines(r.amount,r.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(r=>this._renderService.handleSelectionChanged(r.start,r.end,r.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(r=>{this.textarea.value=r,this.textarea.focus(),this.textarea.select()})),this._register(_e.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(ts,this.screenElement)),this._register(B(this.element,"mousedown",r=>this._selectionService.handleMouseDown(r))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(ai,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",r=>this._handleScreenReaderModeOptionChange(r))),this.options.overviewRuler.width&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ri,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRuler",r=>{!this._overviewRulerRenderer&&r&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ri,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(rs,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){let e=this,t=this.element;function i(n){let o=e._mouseService?.getMouseReportCoords(n,e.screenElement);if(!o)return!1;let h,l;switch(n.overrideType||n.type){case"mousemove":l=32,n.buttons===void 0?(h=3,n.button!==void 0&&(h=n.button<3?n.button:3)):h=n.buttons&1?0:n.buttons&4?1:n.buttons&2?2:3;break;case"mouseup":l=0,h=n.button<3?n.button:3;break;case"mousedown":l=1,h=n.button<3?n.button:3;break;case"wheel":if(e._customWheelEventHandler&&e._customWheelEventHandler(n)===!1)return!1;let a=n.deltaY;if(a===0||e.coreMouseService.consumeWheelEvent(n,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr)===0)return!1;l=a<0?0:1,h=4;break;default:return!1}return l===void 0||h===void 0||h>4?!1:e.coreMouseService.triggerMouseEvent({col:o.col,row:o.row,x:o.x,y:o.y,button:h,action:l,ctrl:n.ctrlKey,alt:n.altKey,shift:n.shiftKey})}let s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},r={mouseup:n=>{i(n),n.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag))},wheel:n=>(i(n),n.preventDefault(),n.stopPropagation(),!1),mousedrag:n=>{n.buttons&&i(n)},mousemove:n=>{n.buttons||i(n)}};this._register(this.coreMouseService.onProtocolChange(n=>{n?(this.optionsService.rawOptions.logLevel==="debug"&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(n)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),n&8?s.mousemove||(t.addEventListener("mousemove",r.mousemove),s.mousemove=r.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),n&16?s.wheel||(t.addEventListener("wheel",r.wheel,{passive:!1}),s.wheel=r.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),n&2?s.mouseup??(s.mouseup=r.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),n&4?s.mousedrag??(s.mousedrag=r.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)})),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this._register(q(()=>{s.mouseup&&this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)})),this._register(B(t,"mousedown",n=>{n.preventDefault(),this.focus(),!(!this.coreMouseService.areMouseEventsActive||this._selectionService.shouldForceSelection(n))&&(i(n),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag))})),this._register(B(t,"wheel",n=>{if(!s.wheel){if(this._customWheelEventHandler&&this._customWheelEventHandler(n)===!1)return!1;if(!this.buffer.hasScrollback){if(n.deltaY===0)return!1;if(e.coreMouseService.consumeWheelEvent(n,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr)===0)return n.preventDefault(),n.stopPropagation(),!1;let o="\x1B"+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(n.deltaY<0?"A":"B");return this.coreService.triggerDataEvent(o,!0),n.preventDefault(),n.stopPropagation(),!1}}},{passive:!1}))}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){Ur(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key==="Dead"||e.key==="AltGraph")&&(this._unprocessedDeadKey=!0);let i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),i.type===3||i.type===2){let r=this.rows-1;return this.scrollLines(i.type===2?-r:r),e.preventDefault(),e.stopPropagation(),!1}if(i.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)||!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;(i.key===""||i.key==="\r")&&(this.textarea.value="");let s=this._keyboardService.useWin32InputMode&&Pi(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){let i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return t.type==="keypress"?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return;Pi(e)||this.focus();let t=this._keyboardService.evaluateKeyUp(e);if(t?.key){let i=this._keyboardService.useWin32InputMode&&Pi(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType==="insertText"&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){if(!(this.buffer.ybase===0&&this.buffer.y===0)){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){let i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new ke)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}},Kr=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){let t=this._buffer.lines.get(e);if(t)return new Tl(t)}getNullCell(){return new ke}},Al=class extends I{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new b),this.onBufferChange=this._onBufferChange.event,this._normal=new Kr(this._core.buffers.normal,"normal"),this._alternate=new Kr(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}},Ol=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,i=>t(i.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(i,s)=>t(i,s.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}},Il=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}},Nl=["cols","rows"],De=0,jn=class extends I{constructor(e){super(),this._core=this._register(new Ll(e)),this._addonManager=this._register(new Pl),this._publicOptions={...this._core.options};let t=s=>this._core.options[s],i=(s,r)=>{this._checkReadonlyOptions(s),this._core.options[s]=r};for(let s in this._core.options){let r={get:t.bind(this,s),set:i.bind(this,s)};Object.defineProperty(this._publicOptions,s,r)}}_checkReadonlyOptions(e){if(Nl.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get parser(){return this._parser??(this._parser=new Ol(this._core))}get unicode(){return this._checkProposedApi(),new Il(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??(this._buffer=this._register(new Al(this._core)))}get markers(){return this._core.markers}get modes(){let e=this._core.coreService.decPrivateModes,t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any";break}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(let t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write(`\r +`,t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return Ti.get()},set promptLabel(e){Ti.set(e)},get tooMuchOutput(){return Zt.get()},set tooMuchOutput(e){Zt.set(e)}}}_verifyIntegers(...e){for(De of e)if(De===1/0||isNaN(De)||De%1!==0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(De of e)if(De&&(De===1/0||isNaN(De)||De%1!==0||De<0))throw new Error("This API only accepts positive integers")}};/** + * Copyright (c) 2014-2024 The xterm.js authors. All rights reserved. + * @license MIT + * + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * @license MIT + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + */var Fl=2,Hl=1;function Wl(e){return e?.ownerDocument?.defaultView?e.ownerDocument.defaultView:window}function Xn(e){return Wl(e).getComputedStyle(e,null)}var Gn=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows)||(this._terminal.rows!==e.rows||this._terminal.cols!==e.cols)&&this._terminal.resize(e.cols,e.rows)}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let e=this._terminal.dimensions;if(!e||e.css.cell.width===0||e.css.cell.height===0)return;let t=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,i=Xn(this._terminal.element.parentElement),s=parseInt(i.getPropertyValue("height")),r=Math.max(0,parseInt(i.getPropertyValue("width"))),n=Xn(this._terminal.element),o={top:parseInt(n.getPropertyValue("padding-top")),bottom:parseInt(n.getPropertyValue("padding-bottom")),right:parseInt(n.getPropertyValue("padding-right")),left:parseInt(n.getPropertyValue("padding-left"))},h=o.top+o.bottom,l=o.right+o.left,a=s-h,d=r-l-t;return{cols:Math.max(Fl,Math.floor(d/e.css.cell.width)),rows:Math.max(Hl,Math.floor(a/e.css.cell.height))}}};var Fe=new jn({cursorBlink:!0,scrollback:5e3,convertEol:!1,fontSize:14}),Os=new Gn;Fe.loadAddon(Os);Fe.open(document.getElementById("terminal"));var Jn="",ui=-1,As=[],Ts=!1;function Zn(){if(Ts||!As.length)return;let e=As.shift();Ts=!0,e.reset&&Fe.reset(),e.theme&&(Fe.options.theme=e.theme),Fe.write(e.data,()=>{Ts=!1,Zn()})}function ct(e){window.OpenBitFunTerminalHost?window.OpenBitFunTerminalHost.postMessage(JSON.stringify(e)):window.webkit?.messageHandlers?.openbitfunTerminal?.postMessage(e)}Fe.onData(e=>ct({type:"input",data:e}));Fe.onResize(({cols:e,rows:t})=>ct({type:"resize",cols:e,rows:t}));new ResizeObserver(()=>Os.fit()).observe(document.getElementById("terminal"));window.OpenBitFunTerminal={connect(){Os.fit(),ct({type:"ready"}),ct({type:"resize",cols:Fe.cols,rows:Fe.rows})},accept(e){if(!(typeof e.epoch!="string"||!Number.isSafeInteger(e.revision)||typeof e.data!="string")){if(e.epoch!==Jn){if(!e.reset){ct({type:"resync"});return}Jn=e.epoch,ui=-1}if(!(e.revision<=ui)){if(!e.reset&&e.revision!==ui+1){ct({type:"resync"});return}As.push(e),ui=e.revision,Zn()}}}};window.OpenBitFunTerminal.connect();})(); diff --git a/src/shared/terminal/webview/index.ts b/src/shared/terminal/webview/index.ts new file mode 100644 index 0000000000..eda0374ce2 --- /dev/null +++ b/src/shared/terminal/webview/index.ts @@ -0,0 +1,39 @@ +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; + +type Event = {type:'ready'|'resync'} | {type:'input';data:string} | {type:'resize';cols:number;rows:number}; +type Frame = {epoch:string;revision:number;reset:boolean;data:string;theme?:{background:string;foreground:string;cursor?:string}}; +declare global { interface Window { + OpenBitFunTerminalHost?:{postMessage:(message:string)=>void}; + webkit?:{messageHandlers?:{openbitfunTerminal?:{postMessage:(event:Event)=>void}}}; + OpenBitFunTerminal:{connect:()=>void;accept:(frame:Frame)=>void}; +} } +const terminal = new Terminal({cursorBlink:true,scrollback:5000,convertEol:false,fontSize:14}); +const fit = new FitAddon();terminal.loadAddon(fit);terminal.open(document.getElementById('terminal')!); +let epoch='';let revision=-1; +const pending: Frame[]=[];let writing=false; +function drain(){ + if(writing||!pending.length)return; + const frame=pending.shift()!;writing=true; + if(frame.reset)terminal.reset();if(frame.theme)terminal.options.theme=frame.theme; + terminal.write(frame.data,()=>{writing=false;drain();}); +} +function send(event:Event) { + if(window.OpenBitFunTerminalHost)window.OpenBitFunTerminalHost.postMessage(JSON.stringify(event)); + else window.webkit?.messageHandlers?.openbitfunTerminal?.postMessage(event); +} +terminal.onData(data=>send({type:'input',data})); +terminal.onResize(({cols,rows})=>send({type:'resize',cols,rows})); +new ResizeObserver(()=>fit.fit()).observe(document.getElementById('terminal')!); +window.OpenBitFunTerminal={ + connect(){fit.fit();send({type:'ready'});send({type:'resize',cols:terminal.cols,rows:terminal.rows});}, + accept(frame){ + if(typeof frame.epoch!=='string'||!Number.isSafeInteger(frame.revision)||typeof frame.data!=='string')return; + if(frame.epoch!==epoch){if(!frame.reset){send({type:'resync'});return;}epoch=frame.epoch;revision=-1;} + if(frame.revision<=revision)return; + if(!frame.reset&&frame.revision!==revision+1){send({type:'resync'});return;} + pending.push(frame);revision=frame.revision;drain(); + }, +}; +window.OpenBitFunTerminal.connect(); diff --git a/third_party/eioc/Cargo.toml b/third_party/eioc/Cargo.toml new file mode 100644 index 0000000000..03de9f2fa1 --- /dev/null +++ b/third_party/eioc/Cargo.toml @@ -0,0 +1,129 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "eioc" +version = "0.5.0" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Async Engine.IO client implementation for Sioc" +documentation = "https://docs.rs/eioc" +readme = "README.md" +keywords = [ + "async", + "engine-io", + "networking", + "websocket", +] +categories = [ + "asynchronous", + "network-programming", + "web-programming", +] +license = "MIT OR Apache-2.0" +repository = "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/Stanley5249/sioc-rs" + +[features] +default = ["rustls-tls"] +native-tls = [ + "reqwest/native-tls", + "tokio-tungstenite/native-tls", +] +rustls-tls = [ + "reqwest/rustls-no-provider", + "tokio-tungstenite/rustls-tls-native-roots", +] + +[lib] +name = "eioc" +path = "src/lib.rs" + +[dependencies.base64] +version = "0.22" + +[dependencies.bytes] +version = "1" + +[dependencies.bytestring] +version = "1" +features = ["serde"] + +[dependencies.futures-util] +version = "0.3" +features = [ + "sink", + "std", +] +default-features = false + +[dependencies.http] +version = "1" + +[dependencies.miette] +version = "7" +features = ["derive"] +default-features = false + +[dependencies.reqwest] +version = "0.13" +features = ["charset"] +default-features = false + +[dependencies.serde] +version = "1" +features = ["derive"] + +[dependencies.serde_json] +version = "1" + +[dependencies.thiserror] +version = "2" + +[dependencies.tokio] +version = "1" +features = [ + "macros", + "net", + "rt", + "sync", + "time", +] +default-features = false + +[dependencies.tokio-tungstenite] +version = "0.30" +features = ["url"] + +[dependencies.tokio-util] +version = "0.7" + +[dependencies.tracing] +version = "0.1" + +[dependencies.url] +version = "2" + +[dev-dependencies.tokio] +version = "1" +features = [ + "macros", + "net", + "rt", + "sync", + "time", + "full", +] +default-features = false diff --git a/third_party/eioc/LICENSE-APACHE b/third_party/eioc/LICENSE-APACHE new file mode 100644 index 0000000000..1792bea5d0 --- /dev/null +++ b/third_party/eioc/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Stanley5249 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/eioc/LICENSE-MIT b/third_party/eioc/LICENSE-MIT new file mode 100644 index 0000000000..2c5d5dd460 --- /dev/null +++ b/third_party/eioc/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright 2026 Stanley5249 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/third_party/eioc/OPENBITFUN-PATCH.md b/third_party/eioc/OPENBITFUN-PATCH.md new file mode 100644 index 0000000000..655ee19ef5 --- /dev/null +++ b/third_party/eioc/OPENBITFUN-PATCH.md @@ -0,0 +1,11 @@ +# Engine.IO TLS provider selection + +Source: crates.io `eioc` 0.5.0, from . +This copy retains the upstream MIT / Apache-2.0 license choice. + +The sole source change is in Cargo.toml: `rustls-tls` enables +`reqwest/rustls-no-provider` instead of `reqwest/rustls`. +OpenBitFun selects ring through its existing TLS provider owner. Upstream's +Reqwest feature otherwise forces AWS-LC alongside ring, even when the Socket.IO +client receives OpenBitFun's configured HTTP client. Engine.IO wire behavior is +unchanged. Remove this patch when upstream exposes provider-neutral Rustls. diff --git a/third_party/eioc/README.md b/third_party/eioc/README.md new file mode 100644 index 0000000000..608bc1936a --- /dev/null +++ b/third_party/eioc/README.md @@ -0,0 +1,15 @@ +# eioc + +Async [Engine.IO protocol v4](https://socket.io/docs/v4/engine-io-protocol/) client for Rust. + +## What is the Engine.IO protocol? + +The [Engine.IO protocol v4](https://socket.io/docs/v4/engine-io-protocol/) enables full-duplex, low-overhead communication between a client and a server. It is based on the WebSocket protocol and uses HTTP long-polling as fallback if a WebSocket connection cannot be established. + +## Usage + +Most users will never touch this crate directly. The higher-level [`sioc`](https://docs.rs/sioc) crate builds on top of it. + +## License + +MIT OR Apache-2.0. diff --git a/third_party/eioc/src/engine.rs b/third_party/eioc/src/engine.rs new file mode 100644 index 0000000000..d0f8bc7499 --- /dev/null +++ b/third_party/eioc/src/engine.rs @@ -0,0 +1,552 @@ +//! Engine.IO protocol task. + +use crate::error::{EngineError, Error}; +use crate::packet::{Frame, Handshake, Message, Packet}; +use crate::transport::TransportStrategy; +use crate::websocket::WebSocketConnector; +use futures_util::{Sink, SinkExt, TryFutureExt}; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use url::Url; + +/// Data exchanged between the engine task and its producers +#[derive(Debug)] +pub enum EngineAction { + /// An inbound frame from the transport layer. + Transport(Frame), + /// An outbound message from the upper layer. + Sink(Message), +} + +impl From for EngineAction { + fn from(frame: Frame) -> Self { + Self::Transport(frame) + } +} + +impl From for EngineAction { + fn from(message: Message) -> Self { + Self::Sink(message) + } +} + +/// Sends [`Frame`]s to the engine task (transport layer use). +#[derive(Clone, Debug)] +pub struct FrameSender(pub mpsc::Sender); + +impl FrameSender { + /// Sends a [`Frame`] to the engine task. + /// + /// # Errors + /// + /// Returns an error if the engine task has exited. + pub async fn send(&self, frame: Frame) -> Result<(), mpsc::error::SendError> { + self.0.send(EngineAction::Transport(frame)).await + } +} + +/// Sends [`Message`]s to the engine task (Socket.IO layer use). +#[derive(Clone, Debug)] +pub struct MessageSender(pub mpsc::Sender); + +impl MessageSender { + /// Sends a [`Message`] to the engine task. + /// + /// # Errors + /// + /// Returns an error if the engine task has exited. + pub async fn send(&self, message: Message) -> Result<(), mpsc::error::SendError> { + self.0.send(EngineAction::Sink(message)).await + } +} + +/// Drives the engine protocol and transport concurrently until the session ends. +/// +/// `sink` receives decoded inbound [`Message`]s from the transport. +/// The caller creates the engine channel, retains the [`MessageSender`] half, +/// and passes the [`FrameSender`] and receiver here. +/// +/// # Errors +/// +/// Returns an error if either the engine or transport task fails. +#[allow(clippy::too_many_arguments)] +pub async fn connect( + url: Url, + http_client: reqwest::Client, + websocket_connector: C, + strategy: TransportStrategy, + sink: S, + engine_rx: mpsc::Receiver, + frame_tx: FrameSender, + transport_capacity: usize, +) -> Result<(), Error> +where + C: WebSocketConnector, + S: Sink + Unpin + Send + 'static, + S::Error: std::error::Error + Send + Sync + 'static, +{ + let (transport_tx, transport_rx) = mpsc::channel(transport_capacity); + + let (handshake_tx, handshake_rx) = oneshot::channel(); + + let token = CancellationToken::new(); + + let eio_future = engine_io(sink, engine_rx, transport_tx, handshake_rx, token.clone()); + + let transport_future = strategy.run( + url, + http_client, + websocket_connector, + handshake_tx, + frame_tx, + transport_rx, + token, + ); + + tokio::try_join!( + eio_future.map_err(Error::Engine), + transport_future.map_err(Error::Transport), + )?; + + Ok(()) +} + +struct Heartbeat { + deadline: Instant, + ping_window: std::time::Duration, +} + +impl Heartbeat { + fn new(ping_window: std::time::Duration) -> Self { + Self { + deadline: Instant::now() + ping_window, + ping_window, + } + } + + fn reset(&mut self) { + self.deadline = Instant::now() + self.ping_window; + } +} + +#[tracing::instrument(skip_all, err)] +async fn engine_io( + mut sink: S, + mut engine_rx: mpsc::Receiver, + transport_tx: mpsc::Sender, + handshake_rx: oneshot::Receiver, + token: CancellationToken, +) -> Result<(), EngineError> +where + S: Sink + Unpin, + S::Error: std::error::Error + Send + Sync + 'static, +{ + // Ensure the transport shuts down whenever the engine exits, regardless of the reason. + let _guard = token.drop_guard(); + + let handshake = handshake_rx.await?; + tracing::debug!(sid = %handshake.sid, "<- OPEN"); + + let mut heartbeat = Heartbeat::new(handshake.ping_window()); + + while let Some(action) = tokio::time::timeout_at(heartbeat.deadline, engine_rx.recv()).await? { + match action { + EngineAction::Transport(frame) => match frame { + Frame::Packet(packet) => { + tracing::trace!(%packet, "<- packet"); + + match packet { + Packet::Close => { + tracing::debug!("server closed"); + + break; + } + Packet::Ping(payload) => { + tracing::trace!("-> PONG"); + + transport_tx.send(Packet::Pong(payload).into()).await?; + + heartbeat.reset(); + } + Packet::Message(payload) => { + sink.send(Message::Text(payload)) + .await + .map_err(|e| EngineError::SendSink(e.into()))?; + } + Packet::Noop => {} + + packet => return Err(EngineError::Server(packet)), + } + } + Frame::Binary(payload) => { + tracing::trace!(bytes = payload.len(), "<- binary"); + + sink.send(Message::Binary(payload)) + .await + .map_err(|e| EngineError::SendSink(e.into()))?; + } + }, + EngineAction::Sink(message) => match message { + Message::Text(bytes) => { + let packet = Packet::Message(bytes); + + tracing::trace!(%packet, "-> MESSAGE"); + + transport_tx.send(packet.into()).await?; + } + Message::Binary(bytes) => { + tracing::trace!(bytes = bytes.len(), "-> binary"); + + transport_tx.send(bytes.into()).await?; + } + Message::Close => { + tracing::debug!("client closed"); + tracing::trace!("-> CLOSE"); + + transport_tx.send(Packet::Close.into()).await?; + break; + } + }, + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures_util::{sink, Sink}; + use std::pin::Pin; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + use tokio::sync::{mpsc, oneshot}; + use tokio_util::sync::CancellationToken; + + struct FailingSink; + + impl Sink for FailingSink { + type Error = std::io::Error; + + fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn start_send(self: Pin<&mut Self>, _: Message) -> Result<(), Self::Error> { + Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) + } + + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + fn make_handshake() -> Handshake { + Handshake { + sid: "sid".into(), + upgrades: vec![], + ping_interval: 25_000, + ping_timeout: 5_000, + max_payload: 1_000_000, + } + } + + struct Setup { + tx: mpsc::Sender, + rx: mpsc::Receiver, + transport_tx: mpsc::Sender, + transport_rx: mpsc::Receiver, + handshake_rx: oneshot::Receiver, + } + + fn setup() -> Setup { + let (tx, rx) = mpsc::channel(4); + let (transport_tx, transport_rx) = mpsc::channel(4); + let (handshake_tx, handshake_rx) = oneshot::channel(); + handshake_tx.send(make_handshake()).unwrap(); + Setup { + tx, + rx, + transport_tx, + transport_rx, + handshake_rx, + } + } + + impl Setup { + async fn run(self) -> (Result<(), EngineError>, mpsc::Receiver) { + self.run_with(sink::drain()).await + } + + async fn run_with(self, sink: S) -> (Result<(), EngineError>, mpsc::Receiver) + where + S: Sink + Unpin, + S::Error: std::error::Error + Send + Sync + 'static, + { + let transport_rx = self.transport_rx; + let result = engine_io( + sink, + self.rx, + self.transport_tx, + self.handshake_rx, + CancellationToken::new(), + ) + .await; + (result, transport_rx) + } + } + + type CapturingSink = Pin + Send>>; + + fn capturing_sink() -> (CapturingSink, Arc>>) { + let store = Arc::new(Mutex::new(Vec::::new())); + let s = store.clone(); + let sink = Box::pin(sink::unfold(s, |s, msg: Message| async move { + s.lock().unwrap().push(msg); + Ok::<_, std::convert::Infallible>(s) + })); + (sink, store) + } + + #[test] + fn engine_action_from_frame() { + let frame = Frame::Binary(Bytes::from_static(b"x")); + let action = EngineAction::from(frame.clone()); + assert!(matches!(action, EngineAction::Transport(f) if f == frame)); + } + + #[test] + fn engine_action_from_message() { + let action = EngineAction::from(Message::Close); + assert!(matches!(action, EngineAction::Sink(Message::Close))); + } + + #[tokio::test] + async fn frame_sender_wraps_frame_as_transport() { + let (tx, mut rx) = mpsc::channel(4); + let sender = FrameSender(tx); + let frame = Frame::Binary(Bytes::from_static(b"x")); + sender.send(frame.clone()).await.unwrap(); + assert!(matches!(rx.recv().await.unwrap(), EngineAction::Transport(f) if f == frame)); + } + + #[tokio::test] + async fn message_sender_wraps_message_as_sink() { + let (tx, mut rx) = mpsc::channel(4); + let sender = MessageSender(tx); + sender.send(Message::Close).await.unwrap(); + assert!(matches!( + rx.recv().await.unwrap(), + EngineAction::Sink(Message::Close) + )); + } + + #[tokio::test] + async fn engine_io_handshake_dropped_is_error() { + let (_, rx) = mpsc::channel(4); + let (transport_tx, _) = mpsc::channel(4); + let (handshake_tx, handshake_rx) = oneshot::channel::(); + drop(handshake_tx); + let result = engine_io( + sink::drain(), + rx, + transport_tx, + handshake_rx, + CancellationToken::new(), + ) + .await; + assert!(matches!(result, Err(EngineError::RecvHandshake(_)))); + } + + #[tokio::test] + async fn engine_io_server_close_exits_ok() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Close))) + .await + .unwrap(); + let (result, _) = s.run().await; + result.unwrap(); + } + + #[tokio::test] + async fn engine_io_engine_channel_closed_exits_ok() { + let s = setup(); + drop(s.tx); + let result = engine_io( + sink::drain(), + s.rx, + s.transport_tx, + s.handshake_rx, + CancellationToken::new(), + ) + .await; + result.unwrap(); + } + + #[tokio::test] + async fn engine_io_client_close_sends_close_packet() { + let s = setup(); + s.tx.send(EngineAction::Sink(Message::Close)).await.unwrap(); + let (result, mut transport_rx) = s.run().await; + result.unwrap(); + assert!(matches!( + transport_rx.recv().await.unwrap(), + Frame::Packet(Packet::Close) + )); + } + + #[tokio::test] + async fn engine_io_ping_triggers_pong() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Ping( + "probe".into(), + )))) + .await + .unwrap(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Close))) + .await + .unwrap(); + let (result, mut transport_rx) = s.run().await; + result.unwrap(); + assert!(matches!( + transport_rx.recv().await.unwrap(), + Frame::Packet(Packet::Pong(p)) if p == "probe" + )); + } + + #[tokio::test] + async fn engine_io_noop_is_ignored() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Noop))) + .await + .unwrap(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Close))) + .await + .unwrap(); + let (result, _) = s.run().await; + result.unwrap(); + } + + #[tokio::test] + async fn engine_io_unexpected_packet_is_server_error() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Upgrade))) + .await + .unwrap(); + let (result, _) = s.run().await; + assert!(matches!(result, Err(EngineError::Server(Packet::Upgrade)))); + } + + #[tokio::test] + async fn engine_io_text_message_forwarded_to_sink() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Message( + "hello".into(), + )))) + .await + .unwrap(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Close))) + .await + .unwrap(); + let (sink, store) = capturing_sink(); + let (result, _) = s.run_with(sink).await; + result.unwrap(); + let msgs = store.lock().unwrap(); + assert!(matches!(&msgs[0], Message::Text(t) if t == "hello")); + } + + #[tokio::test] + async fn engine_io_binary_frame_forwarded_to_sink() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Binary(Bytes::from_static( + b"bin", + )))) + .await + .unwrap(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Close))) + .await + .unwrap(); + let (sink, store) = capturing_sink(); + let (result, _) = s.run_with(sink).await; + result.unwrap(); + let msgs = store.lock().unwrap(); + assert!(matches!(&msgs[0], Message::Binary(b) if b.as_ref() == b"bin")); + } + + #[tokio::test] + async fn engine_io_outbound_text_sent_to_transport() { + let s = setup(); + s.tx.send(EngineAction::Sink(Message::Text("out".into()))) + .await + .unwrap(); + s.tx.send(EngineAction::Sink(Message::Close)).await.unwrap(); + let (result, mut transport_rx) = s.run().await; + result.unwrap(); + assert!(matches!( + transport_rx.recv().await.unwrap(), + Frame::Packet(Packet::Message(m)) if m == "out" + )); + } + + #[tokio::test] + async fn engine_io_outbound_binary_sent_to_transport() { + let s = setup(); + s.tx.send(EngineAction::Sink(Message::Binary(Bytes::from_static( + b"out", + )))) + .await + .unwrap(); + s.tx.send(EngineAction::Sink(Message::Close)).await.unwrap(); + let (result, mut transport_rx) = s.run().await; + result.unwrap(); + assert!(matches!( + transport_rx.recv().await.unwrap(), + Frame::Binary(b) if b.as_ref() == b"out" + )); + } + + #[tokio::test] + async fn engine_io_heartbeat_timeout_fires() { + let (tx, rx) = mpsc::channel(4); + let (transport_tx, _transport_rx) = mpsc::channel(4); + let (handshake_tx, handshake_rx) = oneshot::channel(); + handshake_tx + .send(Handshake { + sid: "sid".into(), + upgrades: vec![], + ping_interval: 1, + ping_timeout: 1, + max_payload: 1_000_000, + }) + .unwrap(); + // tx is kept alive so recv() never returns None; heartbeat fires after 2ms. + let result = engine_io( + sink::drain(), + rx, + transport_tx, + handshake_rx, + CancellationToken::new(), + ) + .await; + drop(tx); + assert!(matches!(result, Err(EngineError::HeartbeatTimeout(_)))); + } + + #[tokio::test] + async fn engine_io_message_sink_error() { + let s = setup(); + s.tx.send(EngineAction::Transport(Frame::Packet(Packet::Message( + "x".into(), + )))) + .await + .unwrap(); + let (result, _) = s.run_with(FailingSink).await; + assert!(matches!(result, Err(EngineError::SendSink(_)))); + } +} diff --git a/third_party/eioc/src/error.rs b/third_party/eioc/src/error.rs new file mode 100644 index 0000000000..7b9358d4bd --- /dev/null +++ b/third_party/eioc/src/error.rs @@ -0,0 +1,257 @@ +//! Error types for Engine.IO operations. + +use crate::engine::EngineAction; +use crate::packet::{Frame, Handshake, Packet}; +use bytestring::ByteString; +use miette::Diagnostic; +use thiserror::Error; +use tokio::sync::{mpsc, oneshot}; +use tokio::time::error::Elapsed; + +/// Top-level error aggregator for `eioc`. +#[derive(Debug, Error, Diagnostic)] +#[error(transparent)] +#[diagnostic(transparent)] +pub enum Error { + /// Error from the transport coordination task. + Transport(#[from] TransportError), + + /// Error from the engine protocol task. + Engine(#[from] EngineError), +} + +/// Errors that occur during an active Engine.IO session. +#[derive(Debug, Error, Diagnostic)] +pub enum EngineError { + /// Outbound frame channel to the transport task is closed. + #[error("outbound frame channel closed")] + #[diagnostic( + code(eioc::engine::send_transport), + help("the transport task exited; check for prior transport errors") + )] + SendTransport(#[from] mpsc::error::SendError), + + /// Inbound message delivery to the Socket.IO layer failed. + #[error("inbound message sink closed")] + #[diagnostic( + code(eioc::engine::send_sink), + help("the consumer of inbound messages was dropped") + )] + // Keep as `#[source]` (not `#[from]`) so type-erased errors do not + // implicitly convert into `EngineError` at unrelated call sites. + SendSink(#[source] Box), + + /// The handshake oneshot channel was dropped before the server responded. + #[error("failed to receive Engine.IO handshake")] + #[diagnostic( + code(eioc::engine::recv_handshake), + help( + "the transport task exited before completing the handshake; check the transport for prior errors" + ) + )] + RecvHandshake(#[from] oneshot::error::RecvError), + + /// The server stopped sending heartbeat pings within the expected window. + #[error("heartbeat timeout")] + #[diagnostic( + code(eioc::engine::heartbeat_timeout), + help("the server stopped responding; check the network or server load") + )] + HeartbeatTimeout(#[from] Elapsed), + + /// An unexpected packet was received during the session. + #[error("unexpected packet {0:?}")] + #[diagnostic( + code(eioc::engine::server), + help( + "the server sent a packet that violates the Engine.IO state machine; likely a server bug or version mismatch" + ) + )] + Server(Packet), +} + +/// Errors that occur during Engine.IO connection setup and transport coordination. +#[derive(Debug, Error, Diagnostic)] +pub enum TransportError { + /// A WebSocket transport error. + #[error(transparent)] + #[diagnostic(transparent)] + WebSocket(#[from] WebSocketError), + + /// An HTTP polling transport error. + #[error(transparent)] + #[diagnostic(transparent)] + Polling(#[from] PollingError), + + /// Sending an action to the engine task failed because the channel is closed. + #[error("engine action channel closed")] + #[diagnostic( + code(eioc::transport::send_engine), + help("the engine task exited; check for prior engine errors") + )] + SendEngine(#[from] mpsc::error::SendError), + + /// Handshake data could not be forwarded to the engine task. + #[error("failed to send handshake to engine task")] + #[diagnostic( + code(eioc::transport::send_handshake), + help( + "the engine task exited before the handshake arrived; check the engine for prior errors" + ) + )] + SendHandshake(Handshake), + + /// The first frame received was not an Open packet. + #[error("expected Open packet as first frame")] + #[diagnostic( + code(eioc::transport::open), + help( + "the server did not send the expected Open packet; check the server implementation and Engine.IO version" + ) + )] + Open(Frame), +} + +/// Errors specific to the WebSocket transport. +#[derive(Debug, Error, Diagnostic)] +pub enum WebSocketError { + /// An error from the `tokio-tungstenite` library. + #[error(transparent)] + #[diagnostic(code(eioc::transport::websocket::tungstenite))] + Tungstenite(#[from] tokio_tungstenite::tungstenite::Error), + + /// The WebSocket stream ended without a close frame. + #[error("WebSocket stream closed unexpectedly")] + #[diagnostic( + code(eioc::transport::websocket::closed), + help("the server closed the TCP connection without sending a WebSocket close frame") + )] + Closed, + + /// The server did not respond to the probe Ping with a Pong. + #[error("probe Pong not received")] + #[diagnostic( + code(eioc::transport::websocket::probe), + help( + "the server did not respond to the probe Ping with a Pong; check the server implementation and Engine.IO version" + ) + )] + Probe(Frame), + + /// A packet decoding error within a WebSocket text frame. + #[error(transparent)] + #[diagnostic(transparent)] + Packet(#[from] PacketError), +} + +/// Errors specific to the HTTP long-polling transport. +#[derive(Debug, Error, Diagnostic)] +pub enum PollingError { + /// An HTTP client error. + #[error(transparent)] + #[diagnostic(code(eioc::transport::polling::reqwest))] + Reqwest(#[from] reqwest::Error), + + /// Binary attachment is not valid base64. + #[error("failed to decode base64 attachment")] + #[diagnostic(code(eioc::transport::polling::base64))] + Base64(#[from] base64::DecodeError), + + /// HTTP polling POST returned a non-`ok` response body. + #[error("unexpected polling response: {0}")] + #[diagnostic( + code(eioc::transport::polling::response), + help( + "the server returned an unexpected body; verify the server implementation and Engine.IO version" + ) + )] + Response(String), + + /// A packet decoding error within a polling payload. + #[error(transparent)] + #[diagnostic(transparent)] + Packet(#[from] PacketError), +} + +/// Errors from decoding a raw Engine.IO packet. +#[derive(Debug, Error, Diagnostic)] +#[allow(missing_docs)] +pub enum PacketError { + /// Packet bytes are empty. + #[error("empty packet")] + #[diagnostic(code(eioc::packet::empty))] + Empty, + + /// First char of a packet is not a valid Engine.IO type id. + #[error("invalid type id {id}")] + #[diagnostic(code(eioc::packet::invalid_id))] + InvalidId { id: char }, + + /// Open packet's JSON payload is malformed. + #[error("failed to parse Open payload")] + #[diagnostic(code(eioc::packet::handshake))] + Handshake(#[from] serde_json::Error), + + /// Unexpected payload for the packet type. + #[error("unexpected payload for type id {id}")] + #[diagnostic(code(eioc::packet::payload))] + Payload { id: char, payload: ByteString }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packet_error_empty_display() { + assert_eq!(PacketError::Empty.to_string(), "empty packet"); + } + + #[test] + fn packet_error_invalid_id_display() { + assert_eq!( + PacketError::InvalidId { id: '9' }.to_string(), + "invalid type id 9" + ); + } + + #[test] + fn packet_error_payload_display() { + let e = PacketError::Payload { + id: '1', + payload: "extra".into(), + }; + assert_eq!(e.to_string(), "unexpected payload for type id 1"); + } + + #[test] + fn engine_error_server_display() { + let e = EngineError::Server(crate::packet::Packet::Upgrade); + assert_eq!(e.to_string(), "unexpected packet Upgrade"); + } + + #[test] + fn polling_error_response_display() { + let e = PollingError::Response("forbidden".to_string()); + assert_eq!(e.to_string(), "unexpected polling response: forbidden"); + } + + #[test] + fn transport_error_from_polling() { + let e: TransportError = PollingError::Response("x".into()).into(); + assert!(matches!(e, TransportError::Polling(_))); + } + + #[test] + fn error_from_engine_error() { + let e: Error = EngineError::Server(crate::packet::Packet::Close).into(); + assert!(matches!(e, Error::Engine(_))); + } + + #[test] + fn error_from_transport_error() { + let e: Error = + TransportError::Open(crate::packet::Frame::Packet(crate::packet::Packet::Close)).into(); + assert!(matches!(e, Error::Transport(_))); + } +} diff --git a/third_party/eioc/src/lib.rs b/third_party/eioc/src/lib.rs new file mode 100644 index 0000000000..8a7640e34a --- /dev/null +++ b/third_party/eioc/src/lib.rs @@ -0,0 +1,22 @@ +#![doc = include_str!("../README.md")] +#![warn(missing_docs)] +#![warn(clippy::pedantic)] +#![warn(clippy::assertions_on_result_states)] + +pub mod engine; +pub mod error; +pub mod packet; +pub mod polling; +pub mod transport; +pub mod websocket; + +/// The Engine.IO protocol version implemented by this crate (`EIO` query parameter). +pub const ENGINE_IO_VERSION: &str = "4"; + +/// Convenience re-exports for common usage. +pub mod prelude { + pub use crate::engine::{EngineAction, FrameSender, MessageSender}; + pub use crate::packet::{Frame, Handshake, Message, Packet}; + pub use crate::transport::TransportStrategy; + pub use crate::websocket::{WebSocketConnector, WebSocketStream}; +} diff --git a/third_party/eioc/src/packet.rs b/third_party/eioc/src/packet.rs new file mode 100644 index 0000000000..72c4b1fd67 --- /dev/null +++ b/third_party/eioc/src/packet.rs @@ -0,0 +1,458 @@ +//! Engine.IO packet types. + +use std::time::Duration; + +use crate::error::PacketError; +use bytes::Bytes; +use bytestring::ByteString; +use serde::Deserialize; + +/// Payload sent in the WebSocket upgrade probe ping and expected in the pong reply. +pub const PROBE: ByteString = ByteString::from_static("probe"); + +/// Content exchanged between the Socket.IO and Engine.IO layers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + /// A UTF-8 text payload. + Text(ByteString), + /// A raw binary payload. + Binary(Bytes), + /// Signals a clean connection close. + Close, +} + +impl std::fmt::Display for Message { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Text(s) => f.debug_tuple("Text").field(&format_args!("{s}")).finish(), + Self::Binary(b) => f.debug_struct("Binary").field("len", &b.len()).finish(), + Self::Close => f.write_str("Close"), + } + } +} + +/// A wire-level frame exchanged with the transport layer. +/// +/// [`Frame::Packet`] carries a fully-encoded Engine.IO text packet (e.g. `"4hello"`). +/// [`Frame::Binary`] carries a raw binary payload with no packet-type prefix. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Frame { + /// A UTF-8 text frame (contains a complete Engine.IO packet). + Packet(Packet), + /// A raw binary frame. + Binary(Bytes), +} + +impl From for Frame { + fn from(packet: Packet) -> Self { + Frame::Packet(packet) + } +} + +impl From for Frame { + fn from(bytes: Bytes) -> Self { + Frame::Binary(bytes) + } +} + +/// Data exchanged in the Engine.IO v4 handshake (the `open` packet payload). +/// +/// # Wire format +/// +/// ```json +/// { +/// "sid": "lv_VI97HAXpY6yYWAAAC", +/// "upgrades": ["websocket"], +/// "pingInterval": 25000, +/// "pingTimeout": 20000, +/// "maxPayload": 1000000 +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Handshake { + /// Session identifier; sent as the `sid` query parameter in all subsequent requests. + pub sid: ByteString, + + /// Available transport upgrades (e.g. `["websocket"]`). + pub upgrades: Vec, + + /// Server ping interval in milliseconds. + #[serde(rename = "pingInterval")] + pub ping_interval: u64, + + /// Server ping timeout in milliseconds. + #[serde(rename = "pingTimeout")] + pub ping_timeout: u64, + + /// Maximum bytes per chunk, used by the client to aggregate packets into payloads. + #[serde(rename = "maxPayload")] + pub max_payload: u64, +} + +impl Handshake { + /// Returns `true` if the server offered a WebSocket upgrade. + pub fn can_upgrade_to_websocket(&self) -> bool { + self.upgrades.iter().any(|u| u == "websocket") + } + + /// Combined `pingInterval + pingTimeout` as a [`Duration`]. + pub fn ping_window(&self) -> Duration { + Duration::from_millis(self.ping_interval + self.ping_timeout) + } +} + +/// A packet in the Engine.IO v4 protocol. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Packet { + /// `0`: Handshake data from the server. + Open(Handshake), + /// `1`: The transport can be closed. + Close, + /// `2`: Heartbeat from the server (client must reply with [`Packet::Pong`]). + Ping(ByteString), + /// `3`: Heartbeat reply. + Pong(ByteString), + /// `4`: Application-level text data. + Message(ByteString), + /// `5`: Sent by the client to finalise a transport upgrade. + Upgrade, + /// `6`: Sent by the server to flush a pending long-poll GET during upgrade. + Noop, +} + +impl Packet { + /// Appends the wire encoding of this packet to `buffer`. + /// + /// # Panics + /// + /// Panics if called on `Open` or `Noop`, which are server-only packets. + pub fn write(&self, buffer: &mut String) { + match self { + Self::Open(..) => panic!("client should never encode an Open packet"), + Self::Close => buffer.push('1'), + Self::Ping(payload) => { + buffer.reserve(1 + payload.len()); + buffer.push('2'); + buffer.push_str(payload); + } + Self::Pong(payload) => { + buffer.reserve(1 + payload.len()); + buffer.push('3'); + buffer.push_str(payload); + } + Self::Message(payload) => { + buffer.reserve(1 + payload.len()); + buffer.push('4'); + buffer.push_str(payload); + } + Self::Upgrade => buffer.push('5'), + Self::Noop => panic!("client should never encode a Noop packet"), + } + } + + /// Encodes this packet into a [`String`]. + pub fn encode(&self) -> String { + let mut buffer = String::new(); + self.write(&mut buffer); + buffer + } + + /// Decodes a single packet from a text frame. + /// + /// The first character is the packet-type digit (`'0'`..=`'6'`). + /// + /// # Errors + /// + /// Returns an error if the byte string is empty, has an unrecognised type ID, or the payload is malformed. + pub fn decode(bytes: &ByteString) -> Result { + let mut chars = bytes.chars(); + + let Some(id) = chars.next() else { + return Err(PacketError::Empty); + }; + + let rest = chars.as_str(); + + match id { + '0' => Ok(Packet::Open(serde_json::from_str(rest)?)), + '1' => { + if rest.is_empty() { + Ok(Packet::Close) + } else { + Err(PacketError::Payload { + id: '1', + payload: bytes.slice_ref(rest), + }) + } + } + '2' => Ok(Packet::Ping(bytes.slice_ref(rest))), + '3' => Ok(Packet::Pong(bytes.slice_ref(rest))), + '4' => Ok(Packet::Message(bytes.slice_ref(rest))), + '5' => { + if rest.is_empty() { + Ok(Packet::Upgrade) + } else { + Err(PacketError::Payload { + id: '5', + payload: bytes.slice_ref(rest), + }) + } + } + '6' => { + if rest.is_empty() { + Ok(Packet::Noop) + } else { + Err(PacketError::Payload { + id: '6', + payload: bytes.slice_ref(rest), + }) + } + } + id => Err(PacketError::InvalidId { id })?, + } + } +} + +impl std::fmt::Display for Packet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Open(h) => f + .debug_tuple("Open") + .field(&format_args!("{}", h.sid)) + .finish(), + Self::Close => f.write_str("Close"), + Self::Ping(s) => f.debug_tuple("Ping").field(&format_args!("{s}")).finish(), + Self::Pong(s) => f.debug_tuple("Pong").field(&format_args!("{s}")).finish(), + Self::Message(s) => f + .debug_tuple("Message") + .field(&format_args!("{s}")) + .finish(), + Self::Upgrade => f.write_str("Upgrade"), + Self::Noop => f.write_str("Noop"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bss(s: &'static str) -> ByteString { + ByteString::from_static(s) + } + + fn test_handshake() -> Handshake { + Handshake { + sid: "abc".into(), + upgrades: vec!["websocket".into()], + ping_interval: 25000, + ping_timeout: 5000, + max_payload: 1_000_000, + } + } + + #[test] + fn handshake_missing_required_field_is_error() { + let json = serde_json::json!({ + "sid": "test-456", + "upgrades": [], + "pingInterval": 25000, + "pingTimeout": 20000 + }); + let bytes = ByteString::from(format!("0{json}")); + Packet::decode(&bytes).unwrap_err(); + } + + #[test] + fn handshake_ping_window() { + let hs = test_handshake(); + assert_eq!(hs.ping_window(), std::time::Duration::from_secs(30)); + } + + #[test] + fn decode_invalid_packet_id() { + assert!(matches!( + Packet::decode(&bss("9")), + Err(PacketError::InvalidId { id: '9' }) + )); + } + + #[test] + fn decode_empty_packet() { + assert!(matches!( + Packet::decode(&ByteString::new()), + Err(PacketError::Empty) + )); + } + + #[test] + fn decode_open() { + let hs = test_handshake(); + let json = r#"{"sid":"abc","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":5000,"maxPayload":1000000}"#; + let bytes = ByteString::from(format!("0{json}")); + assert_eq!(Packet::decode(&bytes).unwrap(), Packet::Open(hs)); + } + + #[test] + fn encode_decode_close_round_trip() { + let encoded = Packet::Close.encode(); + assert_eq!( + Packet::decode(&ByteString::from(encoded)).unwrap(), + Packet::Close + ); + } + + #[test] + fn encode_decode_ping_round_trip() { + let payload = bss("probe"); + let encoded = Packet::Ping(payload.clone()).encode(); + assert_eq!( + Packet::decode(&ByteString::from(encoded)).unwrap(), + Packet::Ping(payload) + ); + } + + #[test] + fn encode_decode_ping_empty_payload() { + let encoded = Packet::Ping(ByteString::new()).encode(); + assert_eq!( + Packet::decode(&ByteString::from(encoded)).unwrap(), + Packet::Ping(ByteString::new()) + ); + } + + #[test] + fn encode_decode_pong_round_trip() { + let payload = bss("probe"); + let encoded = Packet::Pong(payload.clone()).encode(); + assert_eq!( + Packet::decode(&ByteString::from(encoded)).unwrap(), + Packet::Pong(payload) + ); + } + + #[test] + fn encode_decode_message_round_trip() { + let text = bss("hello world"); + let encoded = Packet::Message(text.clone()).encode(); + assert_eq!( + Packet::decode(&ByteString::from(encoded)).unwrap(), + Packet::Message(text) + ); + } + + #[test] + fn encode_decode_upgrade_round_trip() { + let encoded = Packet::Upgrade.encode(); + assert_eq!( + Packet::decode(&ByteString::from(encoded)).unwrap(), + Packet::Upgrade + ); + } + + #[test] + fn decode_noop() { + assert_eq!(Packet::decode(&bss("6")).unwrap(), Packet::Noop); + } + + #[test] + fn handshake_can_upgrade_to_websocket_true() { + let hs = test_handshake(); + assert!(hs.can_upgrade_to_websocket()); + } + + #[test] + fn handshake_can_upgrade_to_websocket_false() { + let hs = Handshake { + sid: "x".into(), + upgrades: vec![], + ping_interval: 25000, + ping_timeout: 5000, + max_payload: 1_000_000, + }; + assert!(!hs.can_upgrade_to_websocket()); + } + + #[test] + fn decode_close_with_payload_is_error() { + let bytes = bss("1extra"); + assert!(matches!( + Packet::decode(&bytes), + Err(crate::error::PacketError::Payload { id: '1', .. }) + )); + } + + #[test] + fn decode_upgrade_with_payload_is_error() { + let bytes = bss("5extra"); + assert!(matches!( + Packet::decode(&bytes), + Err(crate::error::PacketError::Payload { id: '5', .. }) + )); + } + + #[test] + fn decode_noop_with_payload_is_error() { + let bytes = bss("6extra"); + assert!(matches!( + Packet::decode(&bytes), + Err(crate::error::PacketError::Payload { id: '6', .. }) + )); + } + + #[test] + fn message_display_text() { + let s = format!("{}", Message::Text(bss("hello"))); + assert_eq!(s, "Text(hello)"); + } + + #[test] + fn message_display_binary() { + let s = format!( + "{}", + Message::Binary(bytes::Bytes::from_static(b"\x01\x02")) + ); + assert_eq!(s, "Binary { len: 2 }"); + } + + #[test] + fn message_display_close() { + assert_eq!(format!("{}", Message::Close), "Close"); + } + + #[test] + fn packet_display_variants() { + let hs = test_handshake(); + assert_eq!(format!("{}", Packet::Open(hs)), "Open(abc)"); + assert_eq!(format!("{}", Packet::Close), "Close"); + assert_eq!(format!("{}", Packet::Ping(bss("probe"))), "Ping(probe)"); + assert_eq!(format!("{}", Packet::Pong(bss("probe"))), "Pong(probe)"); + assert_eq!( + format!("{}", Packet::Message(bss("hello"))), + "Message(hello)" + ); + assert_eq!(format!("{}", Packet::Upgrade), "Upgrade"); + assert_eq!(format!("{}", Packet::Noop), "Noop"); + } + + #[test] + fn frame_from_conversions() { + let frame: Frame = Packet::Close.into(); + assert!(matches!(frame, Frame::Packet(Packet::Close))); + + let frame: Frame = bytes::Bytes::from_static(b"\x01").into(); + assert!(matches!(frame, Frame::Binary(_))); + } + + #[test] + #[should_panic(expected = "client should never encode an Open packet")] + fn encode_open_panics() { + Packet::Open(test_handshake()).encode(); + } + + #[test] + #[should_panic(expected = "client should never encode a Noop packet")] + fn encode_noop_panics() { + Packet::Noop.encode(); + } +} diff --git a/third_party/eioc/src/polling.rs b/third_party/eioc/src/polling.rs new file mode 100644 index 0000000000..c651c0dceb --- /dev/null +++ b/third_party/eioc/src/polling.rs @@ -0,0 +1,356 @@ +//! HTTP long-polling transport tasks for Engine.IO v4. + +use crate::engine::FrameSender; +use crate::error::{PollingError, TransportError}; +use crate::packet::{Frame, Handshake, Packet}; +use crate::prelude::WebSocketStream; +use crate::websocket::WebSocketConnector; +use crate::ENGINE_IO_VERSION; +use base64::prelude::{Engine as _, BASE64_STANDARD}; +use bytes::Bytes; +use bytestring::ByteString; +use reqwest::Client; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +use tracing::Instrument; +use url::Url; + +const SEPARATOR: char = '\x1e'; + +impl Frame { + fn decode_binary(data: &[u8]) -> Result { + Ok(Self::Binary(Bytes::from(BASE64_STANDARD.decode(data)?))) + } + + fn decode_packet(bytes: &ByteString) -> Result { + Ok(Self::Packet(Packet::decode(bytes)?)) + } + + fn decode(bytes: &ByteString) -> Result { + let mut chars = bytes.chars(); + + if chars.next().is_some_and(|b| b == 'b') { + Self::decode_binary(chars.as_str().as_bytes()) + } else { + Self::decode_packet(bytes) + } + } + + fn write(&self, buffer: &mut String) { + match self { + Frame::Packet(packet) => packet.write(buffer), + Frame::Binary(bytes) => { + buffer.push('b'); + BASE64_STANDARD.encode_string(bytes, buffer); + } + } + } +} + +fn decode_frames(bytes: &ByteString) -> Result, PollingError> { + bytes + .split(SEPARATOR) + .map(|s| Frame::decode(&bytes.slice_ref(s))) + .collect() +} + +fn encode_frames(frames: &[Frame]) -> String { + let mut buffer = String::new(); + for (i, frame) in frames.iter().enumerate() { + if i > 0 { + buffer.push(SEPARATOR); + } + frame.write(&mut buffer); + } + buffer +} + +/// Builds the polling URL by appending the EIO version and transport parameters. +fn polling_url(mut base_url: Url) -> Url { + base_url + .query_pairs_mut() + .append_pair("EIO", ENGINE_IO_VERSION) + .append_pair("transport", "polling"); + base_url +} + +/// Wraps a [`reqwest::Client`] with Engine.IO HTTP polling helpers. +#[derive(Clone)] +pub struct PollingClient(pub Client); + +impl PollingClient { + async fn get(&self, url: &Url) -> Result, PollingError> { + let response = self + .0 + .get(url.as_str()) + .send() + .await? + .error_for_status()? + .text() + .await?; + + tracing::trace!(bytes = response.len(), "<- GET"); + decode_frames(&ByteString::from(response)) + } + + async fn get_one(&self, url: &Url) -> Result { + let response = self + .0 + .get(url.as_str()) + .send() + .await? + .error_for_status()? + .text() + .await?; + + tracing::trace!(bytes = response.len(), "<- GET"); + + Frame::decode(&ByteString::from(response)) + } + + async fn post(&self, url: &Url, frames: &[Frame]) -> Result<(), PollingError> { + let body = encode_frames(frames); + tracing::trace!(bytes = body.len(), "-> POST"); + + let response = self + .0 + .post(url.as_str()) + .body(body) + .send() + .await? + .error_for_status()? + .text() + .await?; + + if !response.eq_ignore_ascii_case("ok") { + return Err(PollingError::Response(response)); + } + + Ok(()) + } + + /// Loops batched POST requests until `token` fires or `rx` closes. + /// + /// Returns `rx` on exit so the WebSocket phase can reuse it. + #[tracing::instrument(level = "debug", skip_all, err)] + async fn post_until_cancelled( + &self, + url: &Url, + mut rx: mpsc::Receiver, + token: CancellationToken, + ) -> Result, TransportError> { + let mut buffer = Vec::with_capacity(8); + + loop { + let count = tokio::select! { + () = token.cancelled() => { + tracing::debug!("cancelled polling POST"); + break; + }, + count = rx.recv_many(&mut buffer, 8) => count, + }; + + if count == 0 { + break; + } + + self.post(url, &buffer).await?; + buffer.clear(); + } + + Ok(rx) + } + + /// Drains all outbound frames from `rx` until the sender is dropped. + #[tracing::instrument(level = "debug", skip_all, err)] + async fn post_until_closed( + &self, + url: &Url, + mut rx: mpsc::Receiver, + ) -> Result<(), TransportError> { + let mut buffer = Vec::with_capacity(8); + + while rx.recv_many(&mut buffer, 8).await > 0 { + self.post(url, &buffer).await?; + buffer.clear(); + } + + Ok(()) + } + + /// Loops GET requests, decoding each response and forwarding frames to the engine. + /// + /// Exits when `token` fires, the engine channel closes, or an HTTP error occurs. + #[tracing::instrument(level = "debug", skip_all, err)] + async fn get_until_cancelled( + &self, + url: &Url, + frame_tx: FrameSender, + token: CancellationToken, + ) -> Result { + while !token.is_cancelled() { + for frame in self.get(url).await? { + frame_tx.send(frame).await?; + } + } + + tracing::debug!("cancelled polling GET"); + + Ok(frame_tx) + } + + /// Runs the full polling transport lifecycle: handshake, GET/POST loops, and optional WebSocket upgrade. + /// + /// # Errors + /// + /// Returns an error if a network, protocol, or channel failure occurs. + #[tracing::instrument(skip_all, err)] + pub async fn transport( + self, + base_url: Url, + connector: C, + handshake_tx: oneshot::Sender, + frame_tx: FrameSender, + transport_rx: mpsc::Receiver, + token: CancellationToken, + ) -> Result<(), TransportError> + where + C: WebSocketConnector, + { + let mut url = polling_url(base_url.clone()); + + let span = tracing::debug_span!("connect", %url); + + let handshake = match self.get_one(&url).instrument(span).await? { + Frame::Packet(Packet::Open(handshake)) => handshake, + frame => return Err(TransportError::Open(frame)), + }; + + url.query_pairs_mut().append_pair("sid", &handshake.sid); + let do_upgrade = handshake.can_upgrade_to_websocket(); + let sid = handshake.sid.clone(); + + handshake_tx + .send(handshake) + .map_err(TransportError::SendHandshake)?; + + let _guard = token.clone().drop_guard(); + + let child_token = token.child_token(); + + let get_fut = self.get_until_cancelled(&url, frame_tx, child_token.clone()); + + if do_upgrade { + let post_fut = self.post_until_cancelled(&url, transport_rx, child_token.clone()); + + let stream_fut = async { + let _guard = child_token.drop_guard(); + + let stream = WebSocketStream::connect(base_url, Some(&sid), connector).await?; + + tracing::debug!("paused polling transport"); + + Ok::<_, TransportError>(stream) + }; + + let (get_result, post_result, stream_result) = + tokio::join!(get_fut, post_fut, stream_fut); + + let frame_tx = get_result?; + let transport_rx = post_result?; + let stream = stream_result?; + + stream.transport(None, frame_tx, transport_rx).await?; + } else { + let post_fut = self.post_until_closed(&url, transport_rx); + + let (get_result, post_result) = tokio::join!(get_fut, post_fut); + + get_result?; + post_result?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bss(s: &'static str) -> ByteString { + ByteString::from_static(s) + } + + #[test] + fn polling_url_appends_params() { + let base = Url::parse("http://localhost:3000/socket.io/").unwrap(); + let url = polling_url(base); + let query = url.query().unwrap(); + assert!(query.contains("EIO=4")); + assert!(query.contains("transport=polling")); + } + + #[test] + fn frame_decode_text_packet() { + let frame = Frame::decode(&bss("4hello")).unwrap(); + assert!(matches!(frame, Frame::Packet(Packet::Message(m)) if m == "hello")); + } + + #[test] + fn frame_decode_binary_base64() { + use base64::prelude::{Engine as _, BASE64_STANDARD}; + let encoded = BASE64_STANDARD.encode(b"abc"); + let input = ByteString::from(format!("b{encoded}")); + let frame = Frame::decode(&input).unwrap(); + assert!(matches!(frame, Frame::Binary(b) if b.as_ref() == b"abc")); + } + + #[test] + fn frame_decode_invalid_base64_is_error() { + Frame::decode(&bss("b!!!")).unwrap_err(); + } + + #[test] + fn frame_write_packet() { + let frame = Frame::Packet(Packet::Message("hello".into())); + let mut buf = String::new(); + frame.write(&mut buf); + assert_eq!(buf, "4hello"); + } + + #[test] + fn frame_write_binary() { + use base64::prelude::{Engine as _, BASE64_STANDARD}; + let raw = Bytes::from_static(b"abc"); + let frame = Frame::Binary(raw); + let mut buf = String::new(); + frame.write(&mut buf); + assert_eq!(buf, format!("b{}", BASE64_STANDARD.encode(b"abc"))); + } + + #[test] + fn decode_encode_frames_roundtrip() { + let text = bss("4hello\x1e4world"); + let frames = decode_frames(&text).unwrap(); + assert_eq!(frames.len(), 2); + let encoded = encode_frames(&frames); + assert_eq!(encoded, "4hello\x1e4world"); + } + + #[test] + fn encode_frames_single() { + let frames = vec![Frame::Packet(Packet::Pong("probe".into()))]; + assert_eq!(encode_frames(&frames), "3probe"); + } + + #[test] + fn encode_frames_empty() { + assert_eq!(encode_frames(&[]), ""); + } + + #[test] + fn decode_frames_error_propagates() { + decode_frames(&bss("9invalid")).unwrap_err(); + } +} diff --git a/third_party/eioc/src/transport.rs b/third_party/eioc/src/transport.rs new file mode 100644 index 0000000000..1aef50de4e --- /dev/null +++ b/third_party/eioc/src/transport.rs @@ -0,0 +1,113 @@ +//! Engine.IO transport coordination. +//! +//! Manages the transport lifecycle: HTTP long-polling handshake, optional +//! upgrade to WebSocket, and clean shutdown via cancellation. + +use crate::engine::FrameSender; +use crate::error::TransportError; +use crate::packet::{Frame, Handshake}; +use crate::polling::PollingClient; +use crate::websocket::{WebSocketConnector, WebSocketStream}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +use url::Url; + +/// Selects which transport to use when opening an Engine.IO connection. +#[derive(Debug, Default)] +pub enum TransportStrategy { + /// Start with HTTP long-polling, then upgrade to WebSocket when the server offers it. + #[default] + Polling, + /// Connect directly over WebSocket, skipping the polling handshake. + WebSocket, +} + +impl TransportStrategy { + /// Runs the transport lifecycle to completion. + /// + /// # Errors + /// + /// Returns an error if the transport encounters a protocol or I/O failure. + #[allow(clippy::too_many_arguments)] + pub async fn run( + self, + base_url: Url, + http_client: reqwest::Client, + connector: C, + handshake_tx: oneshot::Sender, + frame_tx: FrameSender, + transport_rx: mpsc::Receiver, + token: CancellationToken, + ) -> Result<(), TransportError> + where + C: WebSocketConnector + Send + 'static, + { + match self { + TransportStrategy::Polling => { + PollingClient(http_client) + .transport( + base_url, + connector, + handshake_tx, + frame_tx, + transport_rx, + token, + ) + .await + } + TransportStrategy::WebSocket => { + let stream = WebSocketStream::connect(base_url, None, connector).await?; + + stream + .transport(Some(handshake_tx), frame_tx, transport_rx) + .await?; + + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::FrameSender; + use crate::error::TransportError; + use tokio::sync::{mpsc, oneshot}; + use tokio_tungstenite::tungstenite::Error as TungsteniteError; + use url::Url; + + #[test] + fn transport_strategy_default_is_polling() { + assert!(matches!( + TransportStrategy::default(), + TransportStrategy::Polling + )); + } + + #[tokio::test] + async fn websocket_strategy_propagates_connector_error() { + let base_url = Url::parse("ws://127.0.0.1:1/").unwrap(); + let http_client = reqwest::Client::new(); + let connector = async |_| Err(TungsteniteError::ConnectionClosed); + let (handshake_tx, _handshake_rx) = oneshot::channel(); + let (engine_tx, _engine_rx) = mpsc::channel(1); + let frame_tx = FrameSender(engine_tx); + let (_transport_tx, transport_rx) = mpsc::channel(1); + let token = CancellationToken::new(); + + let result = TransportStrategy::WebSocket + .run( + base_url, + http_client, + connector, + handshake_tx, + frame_tx, + transport_rx, + token, + ) + .await; + + assert!(matches!(result, Err(TransportError::WebSocket(_)))); + } +} diff --git a/third_party/eioc/src/websocket.rs b/third_party/eioc/src/websocket.rs new file mode 100644 index 0000000000..b3bd5ab43d --- /dev/null +++ b/third_party/eioc/src/websocket.rs @@ -0,0 +1,544 @@ +//! WebSocket transport for Engine.IO v4. + +use crate::engine::FrameSender; +use crate::error::{TransportError, WebSocketError}; +use crate::packet::{Frame, Handshake, Packet, PROBE}; +use crate::ENGINE_IO_VERSION; +use bytestring::ByteString; +use futures_util::{Sink, SinkExt, Stream, StreamExt, TryStreamExt}; +use std::future::Future; +use std::pin::Pin; +use std::task::{ready, Context, Poll}; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot}; +use tokio_tungstenite::tungstenite::Error as TungsteniteError; +use tokio_tungstenite::tungstenite::Message as WebSocketMessage; +use tokio_tungstenite::{connect_async, MaybeTlsStream}; +use tracing::Instrument; +use url::Url; + +/// A WebSocket connector that can open a stream from a [`Url`]. +pub trait WebSocketConnector: Send + 'static { + /// Opens a WebSocket connection for the provided [`Url`]. + fn connect( + self, + url: Url, + ) -> impl Future> + Send; +} + +impl WebSocketConnector for F +where + F: FnOnce(Url) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + fn connect( + self, + url: Url, + ) -> impl Future> + Send { + self(url) + } +} + +/// Opens a plain `tokio-tungstenite` WebSocket connection with no custom TLS +/// or header configuration. +impl WebSocketConnector for () { + async fn connect(self, url: Url) -> Result { + let (stream, _) = connect_async(url).await?; + Ok(WebSocketStream(stream)) + } +} + +/// Framed WebSocket stream that speaks [`Frame`] instead of raw [`WebSocketMessage`]. +pub struct WebSocketStream(pub tokio_tungstenite::WebSocketStream>); + +/// Converts an inbound WebSocket text frame into a [`ByteString`] without copying. +fn bytestring_from_utf8_bytes(utf8: tokio_tungstenite::tungstenite::Utf8Bytes) -> ByteString { + // SAFETY: `tungstenite::Utf8Bytes` guarantees the inner `Bytes` is valid UTF-8. + unsafe { ByteString::from_bytes_unchecked(utf8.into()) } +} + +impl Stream for WebSocketStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + loop { + let frame = match ready!(self.0.poll_next_unpin(cx)) { + Some(result) => match result { + Ok(message) => match message { + WebSocketMessage::Text(text) => { + tracing::trace!(bytes = text.len(), "<- TEXT"); + + let bytes = bytestring_from_utf8_bytes(text); + let packet = Packet::decode(&bytes)?; + + Some(Ok(Frame::Packet(packet))) + } + WebSocketMessage::Binary(binary) => { + tracing::trace!(bytes = binary.len(), "<- BINARY"); + + Some(Ok(Frame::Binary(binary))) + } + _ => continue, + }, + Err(e) => Some(Err(e.into())), + }, + None => None, + }; + + return Poll::Ready(frame); + } + } +} + +impl Sink for WebSocketStream { + type Error = WebSocketError; + + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.0 + .poll_ready_unpin(cx) + .map_err(WebSocketError::Tungstenite) + } + + fn start_send(mut self: Pin<&mut Self>, frame: Frame) -> Result<(), Self::Error> { + let message = match frame { + Frame::Packet(packet) => { + let text = packet.encode(); + + tracing::trace!(bytes = text.len(), "-> TEXT"); + + WebSocketMessage::text(text) + } + Frame::Binary(bytes) => { + tracing::trace!(bytes = bytes.len(), "-> BINARY"); + + WebSocketMessage::binary(bytes) + } + }; + self.0 + .start_send_unpin(message) + .map_err(WebSocketError::Tungstenite) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.0 + .poll_flush_unpin(cx) + .map_err(WebSocketError::Tungstenite) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.0 + .poll_close_unpin(cx) + .map_err(WebSocketError::Tungstenite) + } +} + +/// Builds the WebSocket URL by converting the scheme and appending EIO/transport/sid parameters. +fn websocket_url(mut url: Url, sid: Option<&str>) -> Url { + let scheme = match url.scheme() { + "http" => Some("ws"), + "https" => Some("wss"), + _ => None, + }; + + if let Some(scheme) = scheme { + let _ = url.set_scheme(scheme); + } + + { + let mut query = url.query_pairs_mut(); + + query + .append_pair("EIO", ENGINE_IO_VERSION) + .append_pair("transport", "websocket"); + + if let Some(sid) = sid { + query.append_pair("sid", sid); + } + } + + url +} + +impl WebSocketStream { + /// Opens a [`WebSocketStream`], running the upgrade probe when `sid` is present. + /// + /// # Errors + /// + /// Returns an error if the connection or probe fails. + pub async fn connect( + base_url: Url, + sid: Option<&str>, + connector: C, + ) -> Result + where + C: WebSocketConnector, + { + let url = websocket_url(base_url, sid); + + let span = tracing::debug_span!("connect", %url); + + let mut stream = connector.connect(url).instrument(span).await?; + + if sid.is_some() { + stream.probe().await?; + } + + Ok(stream) + } + + /// Waits for the next frame, returning an error if the stream is closed. + async fn recv(&mut self) -> Result { + self.next() + .await + .ok_or(WebSocketError::Closed) + .and_then(|r| r) + } + + /// Sends a probe `Ping` and expects a matching `Pong`, confirming the WebSocket path is live. + #[tracing::instrument(level = "debug", skip_all, err)] + async fn probe(&mut self) -> Result<(), WebSocketError> { + tracing::debug!("-> PING probe"); + + self.send(Packet::Ping(PROBE).into()).await?; + + match self.recv().await? { + Frame::Packet(Packet::Pong(payload)) if payload == PROBE => { + tracing::debug!("<- PONG probe"); + } + + frame => return Err(WebSocketError::Probe(frame)), + } + + Ok(()) + } + + /// Drives the WebSocket I/O loop until the stream or either channel closes. + /// + /// When `handshake_tx` is `Some`, reads the first `Open` frame and forwards the handshake + /// (direct WebSocket transport). When `None`, sends `Upgrade` immediately (polling upgrade path). + /// + /// # Errors + /// + /// Returns an error if a transport or protocol failure occurs. + #[tracing::instrument(skip_all, err)] + pub async fn transport( + mut self, + handshake_tx: Option>, + frame_tx: FrameSender, + mut transport_rx: mpsc::Receiver, + ) -> Result<(), TransportError> { + if let Some(handshake_tx) = handshake_tx { + let handshake = match self.recv().await? { + Frame::Packet(Packet::Open(handshake)) => handshake, + frame => return Err(TransportError::Open(frame)), + }; + + tracing::debug!(sid = %handshake.sid, "<- OPEN"); + + handshake_tx + .send(handshake) + .map_err(TransportError::SendHandshake)?; + } else { + tracing::debug!("-> UPGRADE"); + + self.send(Packet::Upgrade.into()).await?; + } + + loop { + tokio::select! { + result = self.try_next() => { + let Some(frame) = result? else { + tracing::debug!("websocket stream closed"); + break; + }; + + frame_tx.send(frame).await?; + } + + option = transport_rx.recv() => { + let Some(frame) = option else { + tracing::debug!("transport channel closed"); + break; + }; + + self.send(frame).await?; + } + }; + } + + self.close().await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::FrameSender; + use bytes::Bytes; + use futures_util::{SinkExt, StreamExt}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::sync::{mpsc, oneshot}; + use tokio_tungstenite::tungstenite::Message as WsMsg; + use tokio_tungstenite::{accept_async, client_async, MaybeTlsStream}; + + async fn ws_pair() -> ( + WebSocketStream, + tokio_tungstenite::WebSocketStream, + ) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_task = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + accept_async(tcp).await.unwrap() + }); + let tcp = TcpStream::connect(addr).await.unwrap(); + let (ws, _) = client_async("ws://127.0.0.1/", MaybeTlsStream::Plain(tcp)) + .await + .unwrap(); + let client = WebSocketStream(ws); + let server = server_task.await.unwrap(); + (client, server) + } + + #[test] + fn websocket_url_http_becomes_ws() { + let url = Url::parse("http://localhost:3000/socket.io/").unwrap(); + let ws = websocket_url(url, None); + assert_eq!(ws.scheme(), "ws"); + let q = ws.query().unwrap(); + assert!(q.contains("EIO=4")); + assert!(q.contains("transport=websocket")); + } + + #[test] + fn websocket_url_https_becomes_wss() { + let url = Url::parse("https://example.com/socket.io/").unwrap(); + let ws = websocket_url(url, None); + assert_eq!(ws.scheme(), "wss"); + } + + #[test] + fn websocket_url_appends_sid() { + let url = Url::parse("http://localhost/socket.io/").unwrap(); + let ws = websocket_url(url, Some("abc123")); + assert!(ws.query().unwrap().contains("sid=abc123")); + } + + #[test] + fn websocket_url_no_sid_when_none() { + let url = Url::parse("http://localhost/socket.io/").unwrap(); + let ws = websocket_url(url, None); + assert!(!ws.query().unwrap().contains("sid=")); + } + + #[test] + fn websocket_url_unknown_scheme_unchanged() { + let url = Url::parse("ws://localhost/socket.io/").unwrap(); + let ws = websocket_url(url, None); + assert_eq!(ws.scheme(), "ws"); + } + + #[test] + fn bytestring_from_utf8_bytes_preserves_content() { + use tokio_tungstenite::tungstenite::Utf8Bytes; + let text = "hello world"; + let utf8 = Utf8Bytes::from(text); + let bs = bytestring_from_utf8_bytes(utf8); + assert_eq!(&*bs, text); + } + + #[tokio::test] + async fn stream_decodes_text_frame_as_packet() { + let (mut client, mut server) = ws_pair().await; + server.send(WsMsg::text("4hello")).await.unwrap(); + let frame = client.next().await.unwrap().unwrap(); + assert!(matches!(frame, Frame::Packet(Packet::Message(m)) if m == "hello")); + } + + #[tokio::test] + async fn stream_decodes_binary_frame() { + let (mut client, mut server) = ws_pair().await; + server.send(WsMsg::binary(b"data".as_ref())).await.unwrap(); + let frame = client.next().await.unwrap().unwrap(); + assert!(matches!(frame, Frame::Binary(b) if b.as_ref() == b"data")); + } + + #[tokio::test] + async fn stream_invalid_packet_id_is_error() { + let (mut client, mut server) = ws_pair().await; + server.send(WsMsg::text("9bad")).await.unwrap(); + client.next().await.unwrap().unwrap_err(); + } + + #[tokio::test] + async fn sink_sends_packet_frame_as_text() { + let (mut client, mut server) = ws_pair().await; + client + .send(Frame::Packet(Packet::Pong("probe".into()))) + .await + .unwrap(); + let msg = server.next().await.unwrap().unwrap(); + assert_eq!(msg.to_text().unwrap(), "3probe"); + } + + #[tokio::test] + async fn sink_sends_binary_frame() { + let (mut client, mut server) = ws_pair().await; + client + .send(Frame::Binary(Bytes::from_static(b"raw"))) + .await + .unwrap(); + let msg = server.next().await.unwrap().unwrap(); + assert!(msg.is_binary()); + assert_eq!(msg.into_data().as_ref(), b"raw"); + } + + #[tokio::test] + async fn probe_succeeds_on_matching_pong() { + let (mut client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + let msg = server.next().await.unwrap().unwrap(); + assert_eq!(msg.to_text().unwrap(), "2probe"); + server.send(WsMsg::text("3probe")).await.unwrap(); + }); + client.probe().await.unwrap(); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn probe_fails_on_wrong_frame() { + let (mut client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + let _ = server.next().await.unwrap().unwrap(); + server.send(WsMsg::text("4unexpected")).await.unwrap(); + }); + assert!(matches!( + client.probe().await, + Err(WebSocketError::Probe(_)) + )); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn transport_no_handshake_sends_upgrade() { + let (client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + let msg = server.next().await.unwrap().unwrap(); + assert_eq!(msg.to_text().unwrap(), "5"); + while let Some(Ok(_)) = server.next().await {} + }); + let (engine_tx, _) = mpsc::channel(4); + let (_, transport_rx) = mpsc::channel::(4); + client + .transport(None, FrameSender(engine_tx), transport_rx) + .await + .unwrap(); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn transport_with_handshake_reads_open_packet() { + let open_text = r#"0{"sid":"abc","upgrades":[],"pingInterval":25000,"pingTimeout":5000,"maxPayload":1000000}"#; + let (client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + server.send(WsMsg::text(open_text)).await.unwrap(); + while let Some(Ok(_)) = server.next().await {} + }); + let (handshake_tx, handshake_rx) = oneshot::channel(); + let (engine_tx, _) = mpsc::channel(4); + let (transport_tx, transport_rx) = mpsc::channel::(4); + drop(transport_tx); + client + .transport(Some(handshake_tx), FrameSender(engine_tx), transport_rx) + .await + .unwrap(); + assert_eq!(&*handshake_rx.await.unwrap().sid, "abc"); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn transport_with_handshake_non_open_is_error() { + let (client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + server.send(WsMsg::text("4hello")).await.unwrap(); + while let Some(Ok(_)) = server.next().await {} + }); + let (handshake_tx, _) = oneshot::channel(); + let (engine_tx, _) = mpsc::channel(4); + let (_, transport_rx) = mpsc::channel::(4); + let result = client + .transport(Some(handshake_tx), FrameSender(engine_tx), transport_rx) + .await; + assert!(matches!(result, Err(TransportError::Open(_)))); + let _ = server_task.await; + } + + #[tokio::test] + async fn transport_with_handshake_dropped_receiver_is_error() { + let open_text = r#"0{"sid":"abc","upgrades":[],"pingInterval":25000,"pingTimeout":5000,"maxPayload":1000000}"#; + let (client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + server.send(WsMsg::text(open_text)).await.unwrap(); + while let Some(Ok(_)) = server.next().await {} + }); + let (handshake_tx, handshake_rx) = oneshot::channel::(); + drop(handshake_rx); + let (engine_tx, _) = mpsc::channel(4); + let (_, transport_rx) = mpsc::channel::(4); + let result = client + .transport(Some(handshake_tx), FrameSender(engine_tx), transport_rx) + .await; + assert!(matches!(result, Err(TransportError::SendHandshake(_)))); + let _ = server_task.await; + } + + #[tokio::test] + async fn transport_forwards_server_frame_to_engine() { + let (client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + let _ = server.next().await; // consume Upgrade + server.send(WsMsg::text("4data")).await.unwrap(); + let _ = server.close(None).await; + while let Some(Ok(_)) = server.next().await {} + }); + let (engine_tx, mut engine_rx) = mpsc::channel(4); + // Keep transport_tx alive so the select always drains the WS stream first. + let (transport_tx, transport_rx) = mpsc::channel::(4); + client + .transport(None, FrameSender(engine_tx), transport_rx) + .await + .unwrap(); + drop(transport_tx); + let action = engine_rx.recv().await.unwrap(); + assert!( + matches!(action, crate::engine::EngineAction::Transport(Frame::Packet(Packet::Message(m))) if m == "data") + ); + server_task.await.unwrap(); + } + + #[tokio::test] + async fn transport_forwards_outbound_frame_to_server() { + let (client, mut server) = ws_pair().await; + let server_task = tokio::spawn(async move { + let msg = server.next().await.unwrap().unwrap(); + assert_eq!(msg.to_text().unwrap(), "5"); + let msg = server.next().await.unwrap().unwrap(); + assert_eq!(msg.to_text().unwrap(), "4out"); + while let Some(Ok(_)) = server.next().await {} + }); + let (engine_tx, _) = mpsc::channel(4); + let (transport_tx, transport_rx) = mpsc::channel::(4); + transport_tx + .send(Frame::Packet(Packet::Message("out".into()))) + .await + .unwrap(); + drop(transport_tx); + client + .transport(None, FrameSender(engine_tx), transport_rx) + .await + .unwrap(); + server_task.await.unwrap(); + } +} From cbe5a68fff3e4136f05187362737891b1f5a4fad Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 14 Sep 2026 21:54:11 +0800 Subject: [PATCH 02/11] fix(peer): synchronize remote sessions and expose host file and terminal capabilities --- src/apps/cli/src/account.rs | 188 ++++++--- src/apps/cli/src/daemon/pid.rs | 7 +- .../peer_host/commands/external_sources.rs | 29 +- .../cli/src/peer_host/commands/filesystem.rs | 196 ++++++++- src/apps/cli/src/peer_host/commands/mod.rs | 235 +++++++---- .../cli/src/peer_host/commands/permission.rs | 25 +- .../cli/src/peer_host/commands/terminal.rs | 388 +++++++++++++++++ .../cli/src/peer_host/commands/workspace.rs | 52 +++ src/apps/cli/src/peer_host/fanout.rs | 391 +++++++++++++----- src/apps/desktop/AGENTS.md | 5 + src/apps/desktop/src/api/agentic_api.rs | 36 +- src/apps/desktop/src/api/commands.rs | 92 ++++- .../desktop/src/api/local_file_download.rs | 173 ++++++++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/api/path_target.rs | 8 + .../desktop/src/api/remote_connect_api.rs | 272 +++++++++--- src/apps/desktop/src/api/terminal_api.rs | 232 +++++++---- src/apps/desktop/src/lib.rs | 66 ++- .../deviceInterconnectionOverview.test.ts | 8 +- .../RelayHttpClient.generation.test.ts | 2 +- .../RemoteConnectDialog.status.test.tsx | 2 +- .../SessionListTargetInitialization.test.tsx | 48 ++- .../src/flow_chat/components/ChatInput.tsx | 1 + .../modern/ModernFlowChatContainer.tsx | 26 ++ .../services/AgenticEventListener.test.ts | 30 ++ .../services/AgenticEventListener.ts | 14 + .../services/RelaySessionHistory.test.ts | 124 ++++++ .../flow_chat/services/RelaySessionHistory.ts | 144 +++++++ .../PeerSessionRefreshModule.test.ts | 23 +- .../PeerSessionRefreshModule.ts | 56 ++- .../SessionRecordReplica.test.ts | 75 ++++ .../session-stream/SessionRecordReplica.ts | 16 + .../state-machine/RuntimeStatus.test.ts | 20 + .../state-machine/SessionStateMachine.ts | 26 ++ .../src/flow_chat/store/FlowChatStore.test.ts | 104 ++++- .../src/flow_chat/store/FlowChatStore.ts | 251 ++++++++++- .../api/adapters/peer-device-adapter.test.ts | 117 ++---- .../api/adapters/peer-device-adapter.ts | 44 +- .../api/generated/remoteSurface.ts | 6 +- .../api/service-api/AgentAPI.ts | 4 + .../api/service-api/RemoteConnectAPI.ts | 32 ++ .../components/ApplicationSettingsPages.tsx | 60 +-- .../src/infrastructure/peer-device/README.md | 257 +++--------- .../peer-device/deviceSurfaceRouting.ts | 4 + .../remoteConnectionState.test.ts | 2 +- .../remote-connect/remoteConnectionState.ts | 2 +- .../services/workspaceFileTransfer.test.ts | 65 ++- .../services/workspaceFileTransfer.ts | 71 ++-- .../terminal/utils/TerminalInputQueue.ts | 83 +--- 49 files changed, 3154 insertions(+), 959 deletions(-) create mode 100644 src/apps/cli/src/peer_host/commands/terminal.rs create mode 100644 src/apps/desktop/src/api/local_file_download.rs create mode 100644 src/web-ui/src/flow_chat/services/AgenticEventListener.test.ts create mode 100644 src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts create mode 100644 src/web-ui/src/flow_chat/services/RelaySessionHistory.ts create mode 100644 src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.test.ts create mode 100644 src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.ts create mode 100644 src/web-ui/src/flow_chat/state-machine/RuntimeStatus.test.ts diff --git a/src/apps/cli/src/account.rs b/src/apps/cli/src/account.rs index ffe8f1a6ef..497fa433b4 100644 --- a/src/apps/cli/src/account.rs +++ b/src/apps/cli/src/account.rs @@ -104,6 +104,9 @@ fn bounded_account_error(message: &str) -> String { /// CLI-owned routing effects injected into the shared Account Runtime. pub(crate) struct CliAccountRoutingHost { + publisher: RwLock< + Option>, + >, self_ref: Weak, runtime: OnceLock>, relay_client: RwLock>>, @@ -111,18 +114,27 @@ pub(crate) struct CliAccountRoutingHost { /// changes take the write lease, so old events cannot escape through a new /// account's Relay client. lifecycle: Arc>, + routing_cancel: tokio::sync::watch::Sender, } impl CliAccountRoutingHost { fn new() -> Arc { Arc::new_cyclic(|self_ref| Self { self_ref: self_ref.clone(), + publisher: RwLock::new(None), runtime: OnceLock::new(), relay_client: RwLock::new(None), lifecycle: Arc::new(RwLock::new(())), + routing_cancel: tokio::sync::watch::channel(0).0, }) } + pub(crate) async fn session_publisher( + &self, + ) -> Option> { + self.publisher.read().await.clone() + } + fn bind_runtime(&self, runtime: Weak) { self.runtime .set(runtime) @@ -143,15 +155,8 @@ impl CliAccountRoutingHost { } self.stop_routing().await; - let ws_url = format!( - "{}/ws", - request - .relay_url - .replace("https://", "wss://") - .replace("http://", "ws://") - ); let (client, mut event_rx) = RelayClient::new(); - client.connect(&ws_url).await?; + client.connect(&request.relay_url).await?; client .connect_authenticated(&request.session.token, &request.device_name) .await?; @@ -189,9 +194,20 @@ impl CliAccountRoutingHost { tracing::debug!("Stopping stale device routing event loop"); break; } - routing - .handle_relay_event(event, &client, generation, &expected_token) - .await; + if matches!(&event, RelayEvent::DeviceMessageReceived { .. }) { + let routing = routing.clone(); + let client = client.clone(); + let token = expected_token.clone(); + tokio::spawn(async move { + routing + .handle_relay_event(event, &client, generation, &token) + .await; + }); + } else { + routing + .handle_relay_event(event, &client, generation, &expected_token) + .await; + } } routing.retire_routing_client_if_same(&client).await; tracing::info!("Device routing event loop exited"); @@ -200,6 +216,10 @@ impl CliAccountRoutingHost { } async fn stop_routing(&self) { + // Wake and retire pending RPC futures before waiting for their leases. + // Cancellation does not imply rollback; an interrupted mutation is never replayed. + self.routing_cancel + .send_modify(|epoch| *epoch = epoch.wrapping_add(1)); let _routing_guard = self.lifecycle.write().await; self.stop_routing_locked().await; } @@ -209,6 +229,9 @@ impl CliAccountRoutingHost { } async fn stop_routing_locked(&self) { + if let Some(publisher) = self.publisher.write().await.take() { + publisher.close(); + } if let Some(client) = self.relay_client.write().await.take() { client.disconnect().await; } @@ -235,6 +258,14 @@ impl CliAccountRoutingHost { } async fn retire_routing_client_if_same(&self, client: &Arc) -> bool { + { + let current = self.relay_client.read().await; + if !same_routing_client(current.as_ref(), client) { + return false; + } + self.routing_cancel + .send_modify(|epoch| *epoch = epoch.wrapping_add(1)); + } let _routing_guard = self.lifecycle.write().await; let mut current = self.relay_client.write().await; if !take_routing_client_if_same(&mut current, client) { @@ -258,6 +289,7 @@ impl CliAccountRoutingHost { return; } + let mut cancelled = self.routing_cancel.subscribe(); let _routing_lease = self.lifecycle.read().await; if !self .routing_loop_is_current(account_generation, relay_client) @@ -266,13 +298,26 @@ impl CliAccountRoutingHost { tracing::debug!("Ignoring event from a stale device routing client"); return; } + let runtime = self.runtime().expect("bound account runtime"); + let Ok((session, relay_url)) = runtime + .read_account_context_for_generation(account_generation) + .await + else { + return; + }; let fanout_owner = PeerFanoutOwner { account_generation, account_token: expected_token.to_string(), relay_client: Arc::clone(relay_client), - runtime: Arc::downgrade(&self.runtime().expect("bound account runtime")), - routing: Arc::downgrade(self), + runtime: Arc::downgrade(&runtime), + session, + relay_url, + cancellation: Some(cancelled.clone()), }; + tokio::select! { + biased; + _ = cancelled.changed() => { tracing::debug!("Retired pending RPC after account routing transition"); } + _ = async { ACTIVE_PEER_FANOUT_OWNER .scope(fanout_owner, async { self.handle_current_relay_event( @@ -284,6 +329,8 @@ impl CliAccountRoutingHost { .await; }) .await; + } => {} + } } async fn handle_current_relay_event( @@ -313,6 +360,16 @@ impl CliAccountRoutingHost { .routing_loop_is_current(account_generation, relay_client) .await { + match openbitfun_core::service::remote_connect::session_log::SessionPublisher::start_for_host( + session.user_id.clone(), device_id.clone(), relay_url.clone(), session.token.clone(), + ).await { + Ok(publisher) => { + let publisher = Arc::new(publisher); + openbitfun_core::service::remote_connect::start_session_interaction_publication(&publisher); + if let Some(old) = self.publisher.write().await.replace(publisher) { old.close(); } + } + Err(error) => tracing::error!("Unable to start durable session publisher: {error}"), + } if let Err(error) = session_store::save_session_with_device( &session.token, &session.user_id, @@ -382,7 +439,7 @@ impl CliAccountRoutingHost { } }; tracing::info!( - "Device command from {source_device_id}: {command:?} corr={correlation_id}" + "Device command received: source={source_device_id} corr={correlation_id}" ); let peer_key = match session .peer_message_key(&relay_url, &source_device_id) @@ -395,6 +452,30 @@ impl CliAccountRoutingHost { } }; let response = match &command { + RemoteCommand::GetSessionKey { session_id } => { + let result=async { + let publisher=self.session_publisher().await.ok_or_else(||anyhow::anyhow!("Session publisher unavailable"))?; + if session_id != openbitfun_core::service::remote_connect::session_log::HOST_CATALOG_ID && !session_id.starts_with("terminal-") { + openbitfun_core::service::remote_connect::synchronize_session_records(&publisher,session_id).await.map_err(anyhow::Error::msg)?; + } + let session_id=session_id.clone();let account=session.user_id.clone(); + tokio::task::spawn_blocking(move || -> Result<(String,String)> { + let device=DeviceIdentity::from_current_machine()?; + let log=openbitfun_core::service::remote_connect::session_log::SessionLog::existing_for_host(&account,&device.device_id,&session_id)?; + Ok((log.relay_session_id(),log.key_grant()?)) + }).await? + }.await; + match result { + Ok((relay_session_id, key)) => RemoteResponse::SessionKey { + session_id: session_id.clone(), + relay_session_id, + key, + }, + Err(error) => RemoteResponse::Error { + message: error.to_string(), + }, + } + } RemoteCommand::HostInvoke { command, args } => { crate::peer_host::handle_host_invoke(command, args.clone()).await } @@ -461,6 +542,14 @@ impl CliAccountRoutingHost { expected_token: &str, ) { tracing::warn!("Device routing auth error: {message}"); + { + let current = self.relay_client.read().await; + if !same_routing_client(current.as_ref(), relay_client) { + return; + } + self.routing_cancel + .send_modify(|epoch| *epoch = epoch.wrapping_add(1)); + } { let _routing_guard = self.lifecycle.write().await; let mut current = self.relay_client.write().await; @@ -486,7 +575,7 @@ impl CliAccountRoutingHost { let runtime = self.runtime()?; let generation = runtime.account_context_generation(); let _routing_lease = self.lifecycle.read().await; - let (session, _) = runtime + let (session, relay_url) = runtime .read_account_context_for_generation(generation) .await?; let relay_client = self @@ -503,10 +592,12 @@ impl CliAccountRoutingHost { } Ok(PeerFanoutOwner { account_generation: generation, - account_token: session.token, + account_token: session.token.clone(), relay_client, runtime: Arc::downgrade(&runtime), - routing: self.self_ref.clone(), + session, + relay_url, + cancellation: Some(self.routing_cancel.subscribe()), }) } } @@ -576,7 +667,11 @@ pub(crate) struct PeerFanoutOwner { account_token: String, relay_client: Arc, runtime: Weak, - routing: Weak, + session: AccountSession, + relay_url: String, + // Captured at admission, not at dequeue: transitions that already happened + // must also cancel queued work. + cancellation: Option>, } tokio::task_local! { @@ -590,6 +685,10 @@ pub(crate) fn inherited_peer_fanout_owner() -> Option { } impl PeerFanoutOwner { + pub(crate) fn cancellation_receiver(&self) -> Option> { + self.cancellation.clone() + } + fn matches(&self, generation: u64, token: &str, relay_client: &Arc) -> bool { self.account_generation == generation && self.account_token == token @@ -604,57 +703,52 @@ impl PeerFanoutOwner { account_token: account_token.to_string(), relay_client: Arc::new(relay_client), runtime: Weak::new(), - routing: Weak::new(), + session: AccountSession::new(account_token.into(), "test".into(), [0; 32]), + relay_url: "http://localhost".into(), + cancellation: None, } } + #[cfg(test)] + pub(crate) fn with_test_cancellation( + mut self, + cancellation: tokio::sync::watch::Receiver, + ) -> Self { + self.cancellation = Some(cancellation); + self + } + #[cfg(test)] pub(crate) fn generation_for_test(&self) -> u64 { self.account_generation } } +/// Immutable credentials for one admitted delivery. Never holds a routing lock +/// during encryption, directory lookup, or network acknowledgement. pub(crate) struct PeerFanoutLease { pub(crate) session: AccountSession, pub(crate) relay_url: String, pub(crate) relay_client: Arc, - _routing_lease: tokio::sync::OwnedRwLockReadGuard<()>, } -pub(crate) async fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result { +pub(crate) fn acquire_peer_fanout_lease(owner: &PeerFanoutOwner) -> Result { let runtime = owner .runtime .upgrade() .ok_or_else(|| anyhow!("account runtime stopped"))?; - let routing = owner - .routing - .upgrade() - .ok_or_else(|| anyhow!("account routing stopped"))?; - let routing_lease = routing.lifecycle.clone().read_owned().await; - if !runtime.account_context_is_current(owner.account_generation) { - return Err(anyhow!("queued Peer event account changed")); - } - let (session, relay_url) = runtime - .read_account_context_for_generation(owner.account_generation) - .await?; - let client = routing - .relay_client - .read() - .await - .clone() - .ok_or_else(|| anyhow!("device routing not connected"))?; - if !owner.matches( - runtime.account_context_generation(), - &session.token, - &client, - ) { + if !runtime.account_context_is_current(owner.account_generation) + || owner + .cancellation + .as_ref() + .is_none_or(|cancel| cancel.has_changed().unwrap_or(true)) + { return Err(anyhow!("queued Peer event routing owner changed")); } Ok(PeerFanoutLease { - session, - relay_url, - relay_client: client, - _routing_lease: routing_lease, + session: owner.session.clone(), + relay_url: owner.relay_url.clone(), + relay_client: owner.relay_client.clone(), }) } diff --git a/src/apps/cli/src/daemon/pid.rs b/src/apps/cli/src/daemon/pid.rs index 95e4e497eb..12c557848e 100644 --- a/src/apps/cli/src/daemon/pid.rs +++ b/src/apps/cli/src/daemon/pid.rs @@ -9,10 +9,9 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; fn default_pid_file_path() -> Result { - let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot determine home directory"))?; - Ok(home - .join(openbitfun_core_types::product_identity::hidden_data_directory()) - .join("cli_daemon.pid")) + let paths = openbitfun_core::infrastructure::PathManager::new() + .map_err(|error| anyhow!("resolve daemon storage: {error}"))?; + Ok(paths.product_home_dir().join("cli_daemon.pid")) } fn write_pid_file_to(path: &Path, pid: u32) -> Result<()> { diff --git a/src/apps/cli/src/peer_host/commands/external_sources.rs b/src/apps/cli/src/peer_host/commands/external_sources.rs index 7385b01483..bf74e18e1c 100644 --- a/src/apps/cli/src/peer_host/commands/external_sources.rs +++ b/src/apps/cli/src/peer_host/commands/external_sources.rs @@ -118,27 +118,16 @@ pub(super) async fn workspace_root( "Workspace path is not available on this Host", ) })?; - let current = state - .workspace_service - .get_current_workspace() - .await - .ok_or_else(|| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::HostUnavailable, - "No workspace is open on the CLI Host", - true, - ) - })?; - let current = current.root_path.canonicalize().map_err(|_| { - ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::HostUnavailable, - "The CLI Host workspace is not available", - true, - ) - })?; - if current != requested { + let opened = state.workspace_service.get_opened_workspaces().await; + let known_local_workspace = opened.iter().any(|workspace| { + !matches!( + workspace.workspace_kind, + openbitfun_core::service::workspace::manager::WorkspaceKind::Remote + ) && workspace.root_path.canonicalize().ok().as_ref() == Some(&requested) + }); + if !known_local_workspace { return Err(ExternalSourceOperationError::invalid_request( - "External compatibility is limited to the current Host workspace", + "External compatibility requires an opened local Host workspace", )); } Ok(Some(requested)) diff --git a/src/apps/cli/src/peer_host/commands/filesystem.rs b/src/apps/cli/src/peer_host/commands/filesystem.rs index 422e845a90..a60e942bfc 100644 --- a/src/apps/cli/src/peer_host/commands/filesystem.rs +++ b/src/apps/cli/src/peer_host/commands/filesystem.rs @@ -5,6 +5,24 @@ use serde_json::{json, Value}; use crate::peer_host::args::{get_string, optional_string, request_value}; use crate::peer_host::state::PeerHostState; +// An explicit workspace without SSH identity denotes the runtime-local provider. +// Keep omitted scope available for existing runtime callers which use path routing. +fn remote_hint(request: &Value) -> Option { + request + .get("remoteConnectionId") + .or_else(|| request.get("remote_connection_id")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + request + .get("workspacePath") + .or_else(|| request.get("workspace_path")) + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + .map(|_| String::new()) + }) +} + fn directory_nodes_to_json( nodes: Vec, ) -> Vec { @@ -29,7 +47,7 @@ pub(crate) async fn get_directory_children( ) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let preferred = optional_string(request, "remoteConnectionId"); + let preferred = remote_hint(request); let nodes = state .filesystem_service .get_directory_contents_with_remote_hint(&path, preferred.as_deref()) @@ -44,17 +62,22 @@ pub(crate) async fn get_directory_children_paginated( ) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - let preferred = optional_string(request, "remoteConnectionId"); + let preferred = remote_hint(request); let offset = request.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let limit = request.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; - let nodes = state + let mut nodes = state .filesystem_service .get_directory_contents_with_remote_hint(&path, preferred.as_deref()) .await .map_err(|e| format!("Failed to get paginated directory children: {e}"))?; + openbitfun_services_core::filesystem::sort_directory_nodes( + &mut nodes, + request.get("sortBy").and_then(Value::as_str), + request.get("sortOrder").and_then(Value::as_str), + )?; let total = nodes.len(); - let has_more = total > offset + limit; + let has_more = total > offset.saturating_add(limit); let page_nodes: Vec<_> = nodes.into_iter().skip(offset).take(limit).collect(); Ok(json!({ @@ -66,22 +89,165 @@ pub(crate) async fn get_directory_children_paginated( })) } -pub(crate) async fn check_path_exists(args: &Value) -> Result { - let path_str = if let Some(req) = args.get("request") { - get_string(req, "path")? - } else { - get_string(args, "path")? - }; - Ok(json!(std::path::Path::new(&path_str).exists())) +pub(crate) async fn check_path_exists( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let path = get_string(request, "path")?; + let hint = remote_hint(request); + openbitfun_core::service::filesystem::path_operations::exists( + &state.filesystem_service, + &path, + hint.as_deref(), + ) + .await + .map(|exists| json!(exists)) } pub(crate) async fn create_directory(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let path = get_string(request, "path")?; - state + let hint = remote_hint(request); + openbitfun_core::service::filesystem::path_operations::create_directory( + &state.filesystem_service, + &path, + hint.as_deref(), + ) + .await?; + Ok(Value::Null) +} + +pub(crate) async fn read_file_content( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let path = get_string(request, "filePath")?; + let hint = remote_hint(request); + if let Some(encoding) = optional_string(request, "encoding") { + if !encoding.eq_ignore_ascii_case("utf-8") && !encoding.eq_ignore_ascii_case("utf8") { + return Err(format!("Unsupported text encoding: {encoding}")); + } + } + openbitfun_core::service::filesystem::path_operations::read_text( + &state.filesystem_service, + &path, + hint.as_deref(), + ) + .await + .map(Value::String) +} + +pub(crate) async fn write_file_content( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let path = get_string(request, "filePath")?; + let content = get_string(request, "content")?; + let hint = remote_hint(request); + openbitfun_core::service::filesystem::path_operations::write_text_checked( + &state.filesystem_service, + &path, + &content, + hint.as_deref(), + request + .get("expectedHash") + .or_else(|| request.get("expected_hash")) + .and_then(Value::as_str), + ) + .await?; + Ok(Value::Null) +} + +pub(crate) async fn rename_file(state: &PeerHostState, args: &Value) -> Result { + let request = request_value(args); + let from = get_string(request, "oldPath")?; + let to = get_string(request, "newPath")?; + let hint = remote_hint(request); + openbitfun_core::service::filesystem::path_operations::rename( + &state.filesystem_service, + &from, + &to, + hint.as_deref(), + ) + .await?; + Ok(Value::Null) +} + +pub(crate) async fn delete_path( + state: &PeerHostState, + args: &Value, + directory: bool, +) -> Result { + let request = request_value(args); + let path = get_string(request, "path")?; + let hint = remote_hint(request); + let recursive = request + .get("recursive") + .and_then(Value::as_bool) + .unwrap_or(false); + openbitfun_core::service::filesystem::path_operations::remove( + &state.filesystem_service, + &path, + directory, + recursive, + hint.as_deref(), + ) + .await?; + Ok(Value::Null) +} + +pub(crate) async fn list_directory_files( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let path = get_string(request, "path")?; + let hint = remote_hint(request); + let extensions = request.get("extensions").and_then(Value::as_array); + let nodes = state .filesystem_service - .create_directory(&path) + .get_directory_contents_with_remote_hint(&path, hint.as_deref()) .await - .map_err(|e| format!("Failed to create directory: {e}"))?; - Ok(Value::Null) + .map_err(|e| e.to_string())?; + let mut files: Vec<_> = nodes + .into_iter() + .filter(|node| !node.is_directory) + .filter(|node| { + extensions.is_none_or(|values| { + node.extension.as_ref().is_some_and(|ext| { + values.iter().any(|value| { + value + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(ext)) + }) + }) + }) + }) + .map(|node| node.name) + .collect(); + files.sort(); + Ok(json!(files)) +} + +pub(crate) async fn workspace_file_upload( + state: &PeerHostState, + args: &Value, +) -> Result { + let account = state + .account_runtime + .snapshot() + .await + .info + .ok_or("Sign in to use workspace transfers")?; + let request = serde_json::from_value(request_value(args).clone()) + .map_err(|error| format!("Invalid workspace upload request: {error}"))?; + let result = openbitfun_core::service::filesystem::upload::workspace_file_upload( + account.user_id, + request, + ) + .await?; + serde_json::to_value(result).map_err(|error| error.to_string()) } diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 6fe144eea3..c174da11df 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -12,6 +12,7 @@ mod session; mod snapshot; mod soft; mod system; +mod terminal; mod tools; mod workspace; @@ -19,44 +20,51 @@ use serde_json::Value; use super::state::PeerHostState; -pub(crate) async fn dispatch( - command: &str, - args: &Value, - state: &PeerHostState, -) -> Result { +// Select the handler before polling it. A monolithic async match retains every +// handler's construction temporaries in its poll frame (900 KiB in a debug +// build), exhausting the worker stack when a handler starts the runtime. +// Returning a selected future releases dispatch's stack before execution. +pub(crate) fn dispatch<'a>( + command: &'a str, + args: &'a Value, + state: &'a PeerHostState, +) -> std::pin::Pin> + Send + 'a>> { match command { - "list_ai_models_by_config" => models::list_ai_models_by_config(args).await, - "list_visible_subagents" | "list_subagents" | "list_manageable_subagents" => tools::list_subagents(command, args).await, - "get_session_permission_mode" => permission::session_permission_mode(state, args, false, false).await, - "update_session_permission_mode" => permission::session_permission_mode(state, args, true, false).await, - "update_active_turn_permission_mode" => permission::session_permission_mode(state, args, true, true).await, - "get_ai_model_catalog" => models::get_ai_model_catalog().await, - "project_ai_model_reasoning_catalog" => models::project_ai_model_reasoning_catalog(args).await, - "get_model_configs" => models::get_model_configs().await, - "get_models_dev_catalog_status" => models::get_models_dev_catalog_status().await, - "refresh_models_dev_catalog_now" => models::refresh_models_dev_catalog_now().await, - "test_ai_config_connection" => models::test_ai_config_connection(args).await, + "list_ai_models_by_config" => Box::pin(models::list_ai_models_by_config(args)), + "list_visible_subagents" | "list_subagents" | "list_manageable_subagents" => Box::pin(tools::list_subagents(command, args)), + "get_session_permission_mode" => Box::pin(permission::session_permission_mode(state, args, false, false)), + "update_session_permission_mode" => Box::pin(permission::session_permission_mode(state, args, true, false)), + "update_active_turn_permission_mode" => Box::pin(permission::session_permission_mode(state, args, true, true)), + "get_ai_model_catalog" => Box::pin(models::get_ai_model_catalog()), + "project_ai_model_reasoning_catalog" => Box::pin(models::project_ai_model_reasoning_catalog(args)), + "get_model_configs" => Box::pin(models::get_model_configs()), + "get_models_dev_catalog_status" => Box::pin(models::get_models_dev_catalog_status()), + "refresh_models_dev_catalog_now" => Box::pin(models::refresh_models_dev_catalog_now()), + "test_ai_config_connection" => Box::pin(models::test_ai_config_connection(args)), // Workspace / config "initialize_workspace_startup_state" => { - workspace::initialize_workspace_startup_state(state).await + Box::pin(workspace::initialize_workspace_startup_state(state)) } - "get_opened_workspaces" => workspace::get_opened_workspaces(state).await, - "get_recent_workspaces" => workspace::get_recent_workspaces(state).await, + "get_opened_workspaces" => Box::pin(workspace::get_opened_workspaces(state)), + "get_recent_workspaces" => Box::pin(workspace::get_recent_workspaces(state)), "get_current_workspace" | "get_workspace_info" => { - workspace::get_current_workspace(state).await + Box::pin(workspace::get_current_workspace(state)) } - "open_workspace" => workspace::open_workspace(state, args).await, - "cleanup_invalid_workspaces" => workspace::cleanup_invalid_workspaces(state).await, - "reload_config" => workspace::reload_config().await, - "get_config" => config::get_config(args).await, - "get_configs" => config::get_configs(args).await, - "set_config" => config::set_config(state, args).await, - "get_web_search_credential_status" => config::get_web_search_credential_status(args).await, - "save_web_search_credential" => config::save_web_search_credential(args).await, - "clear_web_search_credential" => config::clear_web_search_credential(args).await, - "product_control_invoke" => product_control::invoke(state, args).await, - "get_agent_profile_config" => config::get_agent_profile_config(args).await, - "get_agent_profile_configs" => config::get_agent_profile_configs().await, + "set_active_workspace" => Box::pin(workspace::set_active_workspace(state, args)), + "open_workspace" => Box::pin(workspace::open_workspace(state, args)), + "ssh_list_saved_connections" => Box::pin(workspace::ssh_list_saved_connections()), + "open_remote_workspace" => Box::pin(workspace::open_remote_workspace(state, args)), + "cleanup_invalid_workspaces" => Box::pin(workspace::cleanup_invalid_workspaces(state)), + "reload_config" => Box::pin(workspace::reload_config()), + "get_config" => Box::pin(config::get_config(args)), + "get_configs" => Box::pin(config::get_configs(args)), + "set_config" => Box::pin(config::set_config(state, args)), + "get_web_search_credential_status" => Box::pin(config::get_web_search_credential_status(args)), + "save_web_search_credential" => Box::pin(config::save_web_search_credential(args)), + "clear_web_search_credential" => Box::pin(config::clear_web_search_credential(args)), + "product_control_invoke" => Box::pin(product_control::invoke(state, args)), + "get_agent_profile_config" => Box::pin(config::get_agent_profile_config(args)), + "get_agent_profile_configs" => Box::pin(config::get_agent_profile_configs()), "get_external_source_snapshot" | "get_external_source_control_snapshot" | "reveal_external_source_location" @@ -74,18 +82,39 @@ pub(crate) async fn dispatch( | "set_external_mcp_servers_enabled_command" | "choose_external_mcp_conflict_command" | "update_external_integration_policy_command" => { - external_sources::dispatch(command, args, state).await + Box::pin(external_sources::dispatch(command, args, state)) } + "terminal_execute" => Box::pin(terminal::execute(args)), + "terminal_create" => Box::pin(terminal::create(state, args)), + "terminal_write" => Box::pin(terminal::write(args)), + "terminal_resize" => Box::pin(terminal::resize(args)), + "terminal_close" => Box::pin(terminal::close(args)), + "terminal_get_history" => Box::pin(terminal::history(args)), + "terminal_list" => Box::pin(terminal::list()), + "terminal_get" => Box::pin(terminal::get(args)), + "terminal_get_shells" => Box::pin(terminal::shells()), + "terminal_signal" => Box::pin(terminal::signal(args)), + "terminal_ack" => Box::pin(terminal::acknowledge(args)), + "terminal_has_shell_integration" => Box::pin(terminal::has_shell_integration(args)), + "terminal_send_command" => Box::pin(terminal::send_command(args)), + "terminal_shutdown_all" => Box::pin(terminal::shutdown_all()), // Filesystem + "workspace_file_upload" => Box::pin(filesystem::workspace_file_upload(state, args)), "get_directory_children" | "list_files" => { - filesystem::get_directory_children(state, args).await + Box::pin(filesystem::get_directory_children(state, args)) } "get_directory_children_paginated" => { - filesystem::get_directory_children_paginated(state, args).await + Box::pin(filesystem::get_directory_children_paginated(state, args)) } - "check_path_exists" => filesystem::check_path_exists(args).await, - "create_directory" => filesystem::create_directory(state, args).await, + "check_path_exists" => Box::pin(filesystem::check_path_exists(state, args)), + "create_directory" => Box::pin(filesystem::create_directory(state, args)), + "read_file_content" => Box::pin(filesystem::read_file_content(state, args)), + "write_file_content" => Box::pin(filesystem::write_file_content(state, args)), + "rename_file" => Box::pin(filesystem::rename_file(state, args)), + "delete_file" => Box::pin(filesystem::delete_path(state, args, false)), + "delete_directory" => Box::pin(filesystem::delete_path(state, args, true)), + "list_directory_files" => Box::pin(filesystem::list_directory_files(state, args)), // Tools catalog — read-only tool listing for Agents / Assistant // Defaults UI. CLI Host assembles the same Core tool registry as @@ -93,88 +122,89 @@ pub(crate) async fn dispatch( // tell "CLI Host doesn't support catalog query" from "the runtime // really has no tools". Without this the call fell into the unsupported // dispatch branch and the UI silently rendered an empty tool list. - "get_all_tools_info" => tools::get_all_tools_info().await, - "get_chat_mcp_catalog" => tools::get_chat_mcp_catalog(args).await, + "get_all_tools_info" => Box::pin(tools::get_all_tools_info()), + "get_chat_mcp_catalog" => Box::pin(tools::get_chat_mcp_catalog(args)), // Sessions - "list_persisted_sessions" => session::list_persisted_sessions(state, args).await, - "list_persisted_sessions_page" => session::list_persisted_sessions_page(state, args).await, - "save_session_metadata" => session::save_session_metadata(state, args).await, + "list_persisted_sessions" => Box::pin(session::list_persisted_sessions(state, args)), + "list_persisted_sessions_page" => Box::pin(session::list_persisted_sessions_page(state, args)), + "save_session_metadata" => Box::pin(session::save_session_metadata(state, args)), "list_persisted_sessions_count" => { - session::list_persisted_sessions_count(state, args).await + Box::pin(session::list_persisted_sessions_count(state, args)) } - "search_session_content" => session::search_session_content(state, args).await, - "load_session_turn_window" => session::load_session_turn_window(state, args).await, - "load_session_turns" => session::load_session_turns(state, args).await, - "load_session_event_backfill" => session::load_session_event_backfill(state, args), - "restore_session_view" => session::restore_session_view(state, args).await, - "restore_session_with_turns" => session::restore_session_with_turns(state, args).await, - "restore_session" => session::restore_session(state, args).await, - "create_session" => session::create_session(state, args).await, - "delete_session" => session::delete_session(state, args).await, - "rename_session" => session::rename_session(state, args).await, - "archive_session" => session::archive_session(state, args).await, - "touch_session_activity" => session::touch_session_activity(state, args).await, - "get_session_thread_goal" => session::get_session_thread_goal(state, args).await, - "update_session_mode" => session::update_session_mode(state, args).await, - "update_session_model" => session::update_session_model(state, args).await, - "ensure_coordinator_session" => session::ensure_coordinator_session(state, args).await, - "get_available_modes" => session::get_available_modes(state, args).await, - "get_session_stats" => session::get_session_stats(state, args).await, - "save_session_turn" => session::save_session_turn(state, args).await, + "search_session_content" => Box::pin(session::search_session_content(state, args)), + "load_session_turn_window" => Box::pin(session::load_session_turn_window(state, args)), + "load_session_turns" => Box::pin(session::load_session_turns(state, args)), + "load_session_event_backfill" => Box::pin(std::future::ready(session::load_session_event_backfill(state, args))), + "restore_session_view" => Box::pin(session::restore_session_view(state, args)), + "restore_session_with_turns" => Box::pin(session::restore_session_with_turns(state, args)), + "restore_session" => Box::pin(session::restore_session(state, args)), + "create_session" => Box::pin(session::create_session(state, args)), + "delete_session" => Box::pin(session::delete_session(state, args)), + "rename_session" => Box::pin(session::rename_session(state, args)), + "archive_session" => Box::pin(session::archive_session(state, args)), + "touch_session_activity" => Box::pin(session::touch_session_activity(state, args)), + "get_session_thread_goal" => Box::pin(session::get_session_thread_goal(state, args)), + "update_session_mode" => Box::pin(session::update_session_mode(state, args)), + "update_session_model" => Box::pin(session::update_session_model(state, args)), + "ensure_coordinator_session" => Box::pin(session::ensure_coordinator_session(state, args)), + "get_available_modes" => Box::pin(session::get_available_modes(state, args)), + "get_session_stats" => Box::pin(session::get_session_stats(state, args)), + "save_session_turn" => Box::pin(session::save_session_turn(state, args)), // Snapshot / rollback - "rollback_session_to_turn" => snapshot::rollback_session_to_turn(state, args).await, - "get_session_files" => snapshot::get_session_files(state, args).await, + "rollback_session_to_turn" => Box::pin(snapshot::rollback_session_to_turn(state, args)), + "get_session_files" => Box::pin(snapshot::get_session_files(state, args)), // Dialog / tools - "start_dialog_turn" => dialog::start_dialog_turn(state, args).await, - "cancel_dialog_turn" => dialog::cancel_dialog_turn(state, args).await, - "start_user_question_interaction" => dialog::start_user_question_interaction(state, args).await, - "submit_user_answers" => dialog::submit_user_answers(state, args).await, + "start_dialog_turn" => Box::pin(dialog::start_dialog_turn(state, args)), + "cancel_dialog_turn" => Box::pin(dialog::cancel_dialog_turn(state, args)), + "start_user_question_interaction" => Box::pin(dialog::start_user_question_interaction(state, args)), + "submit_user_answers" => Box::pin(dialog::submit_user_answers(state, args)), // Per-tool interrupt. The controller renders Terminal cards for Turns // this host owns, so it must be able to stop a running tool here — // same owner as cancel_dialog_turn, one level finer. Reaches the Core // coordinator via the compatibility surface both CLI and Desktop Peer // Hosts share. - "cancel_tool" => dialog::cancel_tool(state, args).await, - "list_pending_permission_requests" => permission::list_pending_permission_requests(state), - "subscribe_permission_requests" => permission::subscribe_permission_requests(), - "respond_permission" => permission::respond_permission(state, args).await, - "respond_permission_batch" => permission::respond_permission_batch(state, args).await, + "cancel_tool" => Box::pin(dialog::cancel_tool(state, args)), + "get_session_interaction_mailbox" => Box::pin(std::future::ready(permission::get_session_interaction_mailbox(state, args))), + "list_pending_permission_requests" => Box::pin(std::future::ready(permission::list_pending_permission_requests(state))), + "subscribe_permission_requests" => Box::pin(std::future::ready(permission::subscribe_permission_requests())), + "respond_permission" => Box::pin(permission::respond_permission(state, args)), + "respond_permission_batch" => Box::pin(permission::respond_permission_batch(state, args)), "list_project_permission_grants" => { - permission::list_project_permission_grants(state, args).await + Box::pin(permission::list_project_permission_grants(state, args)) } "remove_project_permission_grant" => { - permission::remove_project_permission_grant(state, args).await + Box::pin(permission::remove_project_permission_grant(state, args)) } "clear_project_permission_grants" => { - permission::clear_project_permission_grants(state, args).await + Box::pin(permission::clear_project_permission_grants(state, args)) } "list_project_permission_audit" => { - permission::list_project_permission_audit(state, args).await + Box::pin(permission::list_project_permission_audit(state, args)) } // Git (local workspace only) - "git_is_repository" => git::git_is_repository(args).await, - "git_get_repository_trust" => git::git_get_repository_trust(args).await, + "git_is_repository" => Box::pin(git::git_is_repository(args)), + "git_get_repository_trust" => Box::pin(git::git_get_repository_trust(args)), // Soft empty / no-op for Desktop-only subsystems - "notify_cron_host_ready" => soft::notify_cron_host_ready().await, - "list_miniapps" => soft::list_miniapps().await, - "miniapp_worker_list_running" => soft::miniapp_worker_list_running().await, - "get_acp_clients" => soft::get_acp_clients().await, - "list_background_command_activities" => soft::list_background_command_activities().await, + "notify_cron_host_ready" => Box::pin(soft::notify_cron_host_ready()), + "list_miniapps" => Box::pin(soft::list_miniapps()), + "miniapp_worker_list_running" => Box::pin(soft::miniapp_worker_list_running()), + "get_acp_clients" => Box::pin(soft::get_acp_clients()), + "list_background_command_activities" => Box::pin(soft::list_background_command_activities()), // System - "get_system_info" => system::get_system_info().await, - "get_token_usage_statistics" => system::get_token_usage_statistics(state, args).await, + "get_system_info" => Box::pin(system::get_system_info()), + "get_token_usage_statistics" => Box::pin(system::get_token_usage_statistics(state, args)), // Only reachable if the registry marks a command Handled without a // matching arm above; the closure test below fails first. - other => Err(format!( + other => Box::pin(std::future::ready(Err(format!( "command '{other}' is registered as handled on the CLI peer host but this build has no handler for it" - )), + )))), } } @@ -184,6 +214,28 @@ pub(crate) async fn dispatch( /// cannot be advertised as runnable here without a handler (or vice versa). #[cfg(test)] pub(crate) const HANDLED_COMMANDS: &[&str] = &[ + "terminal_execute", + "terminal_get", + "terminal_get_shells", + "terminal_signal", + "terminal_ack", + "terminal_has_shell_integration", + "terminal_send_command", + "terminal_shutdown_all", + "terminal_create", + "terminal_write", + "terminal_resize", + "terminal_close", + "terminal_get_history", + "terminal_list", + "ssh_list_saved_connections", + "open_remote_workspace", + "read_file_content", + "write_file_content", + "rename_file", + "delete_file", + "delete_directory", + "list_directory_files", "list_subagents", "list_manageable_subagents", "test_ai_config_connection", @@ -219,6 +271,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "get_config", "get_configs", "get_current_workspace", + "workspace_file_upload", "get_directory_children", "get_directory_children_paginated", "get_external_source_control_snapshot", @@ -236,6 +289,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "git_is_repository", "initialize_workspace_startup_state", "list_files", + "get_session_interaction_mailbox", "list_pending_permission_requests", "list_persisted_sessions", "list_persisted_sessions_count", @@ -272,6 +326,7 @@ pub(crate) const HANDLED_COMMANDS: &[&str] = &[ "set_external_tool_conflict_choice_command", "set_external_tool_target_decision_command", "set_external_tool_targets_enabled_command", + "set_active_workspace", "start_dialog_turn", "submit_user_answers", "start_user_question_interaction", @@ -307,7 +362,7 @@ mod tests { .find("match command {") .expect("commands::dispatch must match on the command name"); let end = source[start..] - .find("other => Err(") + .find("other =>") .map(|offset| start + offset) .expect("commands::dispatch must end with the `other =>` arm"); let body = &source[start..end]; @@ -336,7 +391,7 @@ mod tests { if pending.is_empty() || !target.contains("::") { continue; } - let is_soft = target.starts_with("soft::"); + let is_soft = target.trim_start_matches("Box::pin(").starts_with("soft::"); for name in pending.drain(..) { if is_soft { soft.insert(name); diff --git a/src/apps/cli/src/peer_host/commands/permission.rs b/src/apps/cli/src/peer_host/commands/permission.rs index ea3858f21f..2c6d064980 100644 --- a/src/apps/cli/src/peer_host/commands/permission.rs +++ b/src/apps/cli/src/peer_host/commands/permission.rs @@ -10,7 +10,16 @@ use crate::peer_host::args::{get_string, request_value}; use crate::peer_host::state::PeerHostState; fn permission_reply(request: &Value) -> Result { - match get_string(request, "reply")?.as_str() { + let reply = get_string(request, "reply")?; + if let Some(updated_input) = request.get("updatedInput").filter(|value| !value.is_null()) { + if reply != "once" || !updated_input.is_object() { + return Err("Edited input requires a one-time approval and an object".to_string()); + } + return Ok(PermissionReply::OnceWithInput { + updated_input: updated_input.clone(), + }); + } + match reply.as_str() { "once" => Ok(PermissionReply::Once), "always" => Ok(PermissionReply::Always), "reject" => Ok(PermissionReply::Reject { @@ -339,3 +348,17 @@ mod selector_tests { } } } + +/// Small live mailbox for reconnecting controllers; never reads transcript history. +pub(crate) fn get_session_interaction_mailbox( + state: &PeerHostState, + args: &Value, +) -> Result { + let session_id = get_string(request_value(args), "sessionId")?; + serde_json::to_value( + state + .agent_runtime + .session_interaction_snapshot(&session_id), + ) + .map_err(|error| error.to_string()) +} diff --git a/src/apps/cli/src/peer_host/commands/terminal.rs b/src/apps/cli/src/peer_host/commands/terminal.rs new file mode 100644 index 0000000000..8915502fad --- /dev/null +++ b/src/apps/cli/src/peer_host/commands/terminal.rs @@ -0,0 +1,388 @@ +//! Terminal presentation adapter. PTYs and history belong to terminal services. +use crate::peer_host::{ + args::{get_string, optional_string, request_value}, + state::PeerHostState, +}; +use openbitfun_core::service::{ + remote_ssh::{get_remote_workspace_manager, RemoteTerminalManager}, + terminal::{ + CloseSessionRequest, CreateSessionRequest, ResizeRequest, TerminalApi, WriteRequest, + }, +}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; + +fn api() -> Result { + TerminalApi::from_singleton().map_err(|e| e.to_string()) +} +fn decode(args: &Value) -> Result { + serde_json::from_value(request_value(args).clone()).map_err(|e| e.to_string()) +} +fn response(session: openbitfun_core::service::remote_ssh::RemoteTerminalSession) -> Value { + json!({"id":session.id,"name":session.name,"cwd":session.cwd,"initialCwd":session.cwd,"shellType":"Remote","status":format!("{:?}",session.status),"cols":session.cols,"rows":session.rows,"connectionId":session.connection_id,"source":"user"}) +} +async fn remote_session(id: &str) -> Option { + let manager = get_remote_workspace_manager()? + .get_terminal_manager() + .await?; + manager.get_session(id).await.map(|_| manager) +} + +pub(crate) async fn create(state: &PeerHostState, args: &Value) -> Result { + let request = request_value(args); + let id = + optional_string(request, "sessionId").unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let explicit = optional_string(request, "connectionId"); + let cwd = optional_string(request, "workingDirectory"); + let explicit_local = request + .get("connectionId") + .or_else(|| request.get("connection_id")) + .and_then(Value::as_str) + == Some(""); + let connection = if explicit_local { + None + } else if explicit.is_some() { + explicit + } else if let Some(path) = &cwd { + openbitfun_core::service::remote_ssh::lookup_remote_connection(path) + .await + .map(|entry| entry.connection_id) + } else { + None + }; + let publisher = state + .account_routing + .session_publisher() + .await + .ok_or("Account realtime publisher is unavailable")?; + if let Some(connection) = connection { + let services = openbitfun_core::service::remote_ssh::workspace_state::ensure_saved_connection_services().await?; + let ssh = services + .get_ssh_manager() + .await + .ok_or("SSH manager is unavailable")?; + if !ssh + .get_saved_connections() + .await + .iter() + .any(|profile| profile.id == connection) + { + return Err("Remote terminal requires a connection saved on this host".into()); + } + let manager = services + .get_terminal_manager() + .await + .ok_or("Remote terminal manager is unavailable")?; + let cols = request + .get("cols") + .and_then(Value::as_u64) + .unwrap_or(80) + .try_into() + .map_err(|_| "Invalid terminal width")?; + let rows = request + .get("rows") + .and_then(Value::as_u64) + .unwrap_or(24) + .try_into() + .map_err(|_| "Invalid terminal height")?; + let created = manager + .create_session( + Some(id.clone()), + optional_string(request, "name"), + &connection, + cols, + rows, + cwd.as_deref(), + None, + ) + .await + .map_err(|e| e.to_string())?; + publisher + .append( + format!("terminal-{id}"), + "terminal-created".into(), + json!({"terminal_id":id}), + ) + .await + .map_err(|e| e.to_string())?; + let result = response(created.session); + let mut rx = created.output_rx; + tokio::spawn(async move { + let mut closed = publisher.subscribe_closed(); + loop { + if *closed.borrow() { + break; + } + let output = + tokio::select! { output = rx.recv() => output, _ = closed.changed() => break }; + match output { + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(_) => break, + } + tokio::time::sleep(std::time::Duration::from_millis(75)).await; + if *closed.borrow() { + break; + } + while rx.try_recv().is_ok() {} + let Some(cursor) = manager.replay_cursor(&id).await else { + break; + }; + if publisher + .append( + format!("terminal-{id}"), + "terminal-output".into(), + json!({"terminal_id":id,"cursor":cursor}), + ) + .await + .is_err() + { + break; + } + } + }); + return Ok(result); + } + let terminal = api()?; + let mut input = request.clone(); + input["sessionId"] = json!(id); + if input.get("source").and_then(Value::as_str) == Some("user") { + input["source"] = json!("manual"); + } + let create: CreateSessionRequest = serde_json::from_value(input).map_err(|e| e.to_string())?; + let mut rx = terminal.session_manager().subscribe_replay_cursor(&id); + let session = terminal + .create_session(create) + .await + .map_err(|e| e.to_string())?; + publisher + .append( + format!("terminal-{id}"), + "terminal-created".into(), + json!({"terminal_id":id}), + ) + .await + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + let mut closed = publisher.subscribe_closed(); + loop { + if *closed.borrow() { + break; + } + if tokio::select! { changed = rx.changed() => changed, _ = closed.changed() => break } + .is_err() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(75)).await; + if *closed.borrow() { + break; + } + let cursor = *rx.borrow_and_update(); + if publisher + .append( + format!("terminal-{id}"), + "terminal-output".into(), + json!({"terminal_id":id,"cursor":cursor}), + ) + .await + .is_err() + { + break; + } + } + }); + serde_json::to_value(session).map_err(|e| e.to_string()) +} + +pub(crate) async fn write(args: &Value) -> Result { + let request: WriteRequest = decode(args)?; + if let Some(manager) = remote_session(&request.session_id).await { + manager + .write(&request.session_id, request.data.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } else { + api()?.write(request).await.map_err(|e| e.to_string())?; + } + Ok(Value::Null) +} +pub(crate) async fn resize(args: &Value) -> Result { + let request: ResizeRequest = decode(args)?; + if let Some(manager) = remote_session(&request.session_id).await { + manager + .resize(&request.session_id, request.cols, request.rows) + .await + .map_err(|e| e.to_string())?; + } else { + api()?.resize(request).await.map_err(|e| e.to_string())?; + } + Ok(Value::Null) +} +pub(crate) async fn close(args: &Value) -> Result { + let request: CloseSessionRequest = decode(args)?; + if let Some(manager) = remote_session(&request.session_id).await { + manager + .close_session(&request.session_id) + .await + .map_err(|e| e.to_string())?; + } else { + api()? + .close_session(request) + .await + .map_err(|e| e.to_string())?; + } + Ok(Value::Null) +} +pub(crate) async fn history(args: &Value) -> Result { + let request = request_value(args); + let id = get_string(request, "sessionId")?; + let after = request + .get("afterOffset") + .and_then(Value::as_u64) + .unwrap_or(0); + let limit = if request.get("afterOffset").is_some() { + 64 * 1024 + } else { + usize::MAX + }; + let page = if let Some(manager) = remote_session(&id).await { + manager.replay_page(&id, after, limit).await + } else { + api()? + .session_manager() + .replay_page(&id, after, limit) + .await + } + .ok_or("Terminal is unavailable")?; + let mut result = serde_json::to_value(page).map_err(|e| e.to_string())?; + result["sessionId"] = json!(id); + Ok(result) +} +pub(crate) async fn list() -> Result { + let mut sessions: Vec = api()? + .list_sessions() + .await + .map_err(|e| e.to_string())? + .into_iter() + .map(|s| serde_json::to_value(s).unwrap()) + .collect(); + if let Some(state) = get_remote_workspace_manager() { + if let Some(manager) = state.get_terminal_manager().await { + sessions.extend(manager.list_sessions().await.into_iter().map(response)); + } + } + Ok(json!(sessions)) +} + +pub(crate) async fn get(args: &Value) -> Result { + let id = get_string(request_value(args), "sessionId")?; + if let Some(manager) = remote_session(&id).await { + return manager + .get_session(&id) + .await + .map(response) + .ok_or("Terminal is unavailable".into()); + } + serde_json::to_value(api()?.get_session(&id).await.map_err(|e| e.to_string())?) + .map_err(|e| e.to_string()) +} + +pub(crate) async fn shells() -> Result { + serde_json::to_value(api()?.get_available_shells()).map_err(|e| e.to_string()) +} + +pub(crate) async fn signal(args: &Value) -> Result { + let request: openbitfun_core::service::terminal::SignalRequest = decode(args)?; + if let Some(manager) = remote_session(&request.session_id).await { + let bytes: &[u8] = match request.signal.trim().to_ascii_uppercase().as_str() { + "SIGINT" | "INT" => &[3], + "SIGTSTP" | "TSTP" => &[26], + _ => { + return Err( + "Remote PTY supports INT and TSTP; close the terminal to end the session" + .into(), + ) + } + }; + manager + .write(&request.session_id, bytes) + .await + .map_err(|e| e.to_string())?; + } else { + api()?.signal(request).await.map_err(|e| e.to_string())?; + } + Ok(Value::Null) +} + +pub(crate) async fn acknowledge(args: &Value) -> Result { + let request: openbitfun_core::service::terminal::AcknowledgeRequest = decode(args)?; + if remote_session(&request.session_id).await.is_none() { + api()? + .acknowledge_data(request) + .await + .map_err(|e| e.to_string())?; + } + Ok(Value::Null) +} + +pub(crate) async fn has_shell_integration(args: &Value) -> Result { + let id = get_string(request_value(args), "sessionId")?; + if remote_session(&id).await.is_some() { + return Ok(Value::Bool(false)); + } + api()?.get_session(&id).await.map_err(|e| e.to_string())?; + Ok(Value::Bool(api()?.has_shell_integration(&id).await)) +} + +pub(crate) async fn send_command(args: &Value) -> Result { + let request: openbitfun_core::service::terminal::SendCommandRequest = decode(args)?; + if let Some(manager) = remote_session(&request.session_id).await { + manager + .write( + &request.session_id, + format!("{}\n", request.command).as_bytes(), + ) + .await + .map_err(|e| e.to_string())?; + } else { + api()? + .send_command(request) + .await + .map_err(|e| e.to_string())?; + } + Ok(Value::Null) +} + +pub(crate) async fn shutdown_all() -> Result { + let mut errors = Vec::new(); + if let Some(state) = get_remote_workspace_manager() { + if let Some(manager) = state.get_terminal_manager().await { + for session in manager.list_sessions().await { + if let Err(error) = manager.close_session(&session.id).await { + errors.push(error.to_string()); + } + } + } + } + api()?.shutdown_all().await; + if !errors.is_empty() { + return Err(errors.join("; ")); + } + Ok(Value::Null) +} + +pub(crate) async fn execute(args: &Value) -> Result { + let request: openbitfun_core::service::terminal::ExecuteCommandRequest = decode(args)?; + let result = if let Some(manager) = remote_session(&request.session_id).await { + manager + .execute(&request.session_id, &request.command, request.timeout_ms) + .await + .map_err(|e| e.to_string())? + } else { + api()? + .execute_command(request) + .await + .map_err(|e| e.to_string())? + }; + serde_json::to_value(result).map_err(|e| e.to_string()) +} diff --git a/src/apps/cli/src/peer_host/commands/workspace.rs b/src/apps/cli/src/peer_host/commands/workspace.rs index 815912a39e..1b7fbae8c8 100644 --- a/src/apps/cli/src/peer_host/commands/workspace.rs +++ b/src/apps/cli/src/peer_host/commands/workspace.rs @@ -49,6 +49,24 @@ pub(crate) async fn get_current_workspace(state: &PeerHostState) -> Result Result { + let workspace_id = get_string(request_value(args), "workspaceId")?; + state + .workspace_service + .set_active_workspace(&workspace_id) + .await + .map_err(|error| format!("Failed to activate workspace: {error}"))?; + let workspace = state + .workspace_service + .get_current_workspace() + .await + .ok_or_else(|| "Active workspace not found after switching".to_string())?; + Ok(workspace_info_to_json(&workspace)) +} + pub(crate) async fn open_workspace(state: &PeerHostState, args: &Value) -> Result { let request = request_value(args); let path = get_string(request, "path")?; @@ -78,6 +96,29 @@ pub(crate) async fn open_workspace(state: &PeerHostState, args: &Value) -> Resul Ok(workspace_info_to_json(&info)) } +pub(crate) async fn open_remote_workspace( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let path = get_string(request, "remotePath")?; + let connection_id = get_string(request, "connectionId")?; + let host = crate::peer_host::args::optional_string(request, "sshHost"); + let coordinator = openbitfun_core::agentic::coordination::get_global_coordinator() + .ok_or("Conversation coordinator is unavailable")?; + let info = coordinator + .open_workspace_with_runtime_ownership( + &state.workspace_service, + PathBuf::from(path), + Some(&connection_id), + host.as_deref(), + "peer workspace open", + ) + .await + .map_err(|e| e.to_string())?; + Ok(workspace_info_to_json(&info)) +} + pub(crate) async fn reload_config() -> Result { openbitfun_core::service::config::reload_global_config() .await @@ -93,3 +134,14 @@ pub(crate) async fn cleanup_invalid_workspaces(state: &PeerHostState) -> Result< .map_err(|e| format!("Failed to cleanup invalid workspaces: {e}"))?; Ok(json!(removed)) } + +pub(crate) async fn ssh_list_saved_connections() -> Result { + let state = + openbitfun_core::service::remote_ssh::workspace_state::ensure_saved_connection_services() + .await?; + let ssh = state + .get_ssh_manager() + .await + .ok_or("SSH manager is unavailable")?; + serde_json::to_value(ssh.get_saved_connections().await).map_err(|e| e.to_string()) +} diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index f6eca80a93..feb0cd15c7 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -5,7 +5,7 @@ //! and bookkeeping boundary; it is not a visibility boundary. use std::collections::HashSet; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use openbitfun_agent_runtime::sdk::{ attach_session_event_cursor, AgentEventReceiver, PermissionRequestEvent, @@ -13,16 +13,20 @@ use openbitfun_agent_runtime::sdk::{ use openbitfun_agent_tools::effective_tool_invocation; use openbitfun_core::service::remote_connect::remote_server::RemoteCommand; use openbitfun_events::{project_agentic_frontend_event, AgenticEvent, ToolEventData}; -use tokio::sync::{broadcast, mpsc}; +use tokio::sync::{broadcast, mpsc, OwnedSemaphorePermit, Semaphore}; use crate::account::PeerFanoutOwner; use super::control::{attached_controllers, controller_delivery_lease}; use super::state::{PeerHostState, PeerTurnKey}; -const PEER_EVENT_DELIVERY_CAPACITY: usize = i32::MAX as usize; +// Memory budget for ephemeral control notifications. Durable transcript and PTY +// replay are owned elsewhere, so this queue never carries their output streams. +const PEER_EVENT_DELIVERY_BYTES: usize = 64 * 1024 * 1024; +const PEER_EVENT_DELIVERY_CAPACITY: usize = 1024; struct QueuedPeerDeviceEvent { + _memory: Option, owner: PeerFanoutOwner, targets: Vec, event: String, @@ -39,6 +43,7 @@ impl QueuedPeerDeviceEvent { payload: serde_json::Value, ) -> Self { Self { + _memory: None, owner, targets, event, @@ -59,6 +64,7 @@ impl QueuedPeerDeviceEvent { ) -> Self { let terminal = terminal_turn.map(|turn| (turns.clone(), generation, turn)); Self { + _memory: None, owner, targets, event, @@ -123,32 +129,49 @@ pub(crate) fn start_peer_event_fanout(state: PeerHostState, mut rx: AgentEventRe loop { match rx.recv().await { Ok(envelope) => { - if let Err(error) = handle_agentic_event(&state, envelope.event).await { - tracing::warn!("CLI Peer event fanout lost continuity: {error}"); - interrupt_and_fail_peer_turns( + let publisher = state.account_routing.session_publisher().await; + let mut incoming = vec![envelope.event]; + // A microbatch is a scheduling unit, never an admission limit. + // Drain already-ready events without delaying a quiet stream. + while incoming.len() < 64 { + match rx.try_recv() { + Ok(event) => incoming.push(event.event), + Err(broadcast::error::TryRecvError::Lagged(skipped)) => { + tracing::error!( + "CLI session event source lagged by {skipped} events" + ); + report_publication_gap(&state, "Runtime event source lagged").await; + } + Err(_) => break, + } + } + let mut publication = Vec::new(); + for event in incoming { + if let Err(error) = handle_agentic_event( &state, - false, - "Peer event fanout lost continuity", + event, + publisher.is_some(), + &mut publication, ) - .await; - if drain_broadcast_receiver(&mut rx) { - state.turns.interrupt_event_stream(true); - break; + .await + { + tracing::error!("CLI session event publication failed: {error}"); + report_publication_gap(&state, &error).await; + } + } + if let Some(publisher) = publisher { + if let Err(error) = publisher.append_batch(publication).await { + tracing::error!("CLI session journal commit failed: {error}"); + report_publication_gap(&state, &error.to_string()).await; } - state.turns.mark_event_stream_ready(); } } Err(broadcast::error::RecvError::Lagged(skipped)) => { - tracing::warn!("CLI Peer event fanout lagged by {skipped} events"); - interrupt_and_fail_peer_turns(&state, false, "Peer event stream lagged").await; - if drain_broadcast_receiver(&mut rx) { - state.turns.interrupt_event_stream(true); - break; - } - state.turns.mark_event_stream_ready(); + tracing::error!("CLI session event source lagged by {skipped} events"); + report_publication_gap(&state, "Runtime event source lagged").await; } Err(broadcast::error::RecvError::Closed) => { - interrupt_and_fail_peer_turns(&state, true, "Peer event stream closed").await; + report_publication_gap(&state, "Runtime event source closed").await; break; } } @@ -209,14 +232,7 @@ fn start_peer_permission_event_fanout(state: PeerHostState) { } } Err(broadcast::error::RecvError::Closed) => { - if let Err(error) = state - .cancel_and_drain_peer_turns("Peer permission event stream closed") - .await - { - tracing::warn!( - "Peer work was not fully cancelled after permission event closure: {error}" - ); - } + tracing::error!("Peer permission event source closed; runtime turns and permission mailbox remain owned by the host"); break; } } @@ -231,15 +247,26 @@ async fn fanout_permission_event(event: PermissionRequestEvent) { } } -async fn interrupt_and_fail_peer_turns(state: &PeerHostState, closed: bool, reason: &'static str) { - let drain = state.turns.interrupt_event_stream(closed); - let interrupted_turns = drain.turns.clone(); - if let Err(error) = state.cancel_peer_turns(drain, reason).await { - tracing::warn!("Peer turn cancellation after event interruption was incomplete: {error}"); - } - for turn in interrupted_turns { - let (event, payload) = interrupted_turn_failure_projection(&turn, reason); - fanout_peer_device_event(event, payload).await; +async fn report_publication_gap(state: &PeerHostState, reason: &str) { + if let Some(publisher) = state.account_routing.session_publisher().await { + for session in publisher + .session_ids() + .await + .into_iter() + .filter(|session| !session.starts_with("terminal-")) + { + if let Err(error) = + openbitfun_core::service::remote_connect::synchronize_session_records( + &publisher, &session, + ) + .await + { + tracing::error!("Unable to reconcile runtime records after source gap: {error}"); + } + } + if let Err(error) = publisher.report_source_gap(reason).await { + tracing::error!("Unable to persist session continuity warning: {error}"); + } } } @@ -256,7 +283,12 @@ fn drain_broadcast_receiver( } } -async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Result<(), String> { +async fn handle_agentic_event( + state: &PeerHostState, + event: AgenticEvent, + durable: bool, + publication: &mut Vec<(String, String, serde_json::Value)>, +) -> Result<(), String> { let event_turn = event_turn_key(&event); let terminal_turn = terminal_turn_key(&event); if terminal_turn @@ -424,8 +456,9 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res } } + openbitfun_core::service::remote_connect::notify_session_catalog_event(&event); let cursor = state.session_event_journal.record(&event); - let Some(mut projected) = project_agentic_frontend_event(event) else { + let Some(mut projected) = project_agentic_frontend_event(event.clone()) else { if let Some(turn) = terminal_turn { state.turns.finish_turn(&turn); } @@ -434,6 +467,58 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res if let Some(cursor) = cursor { attach_session_event_cursor(&mut projected.payload, cursor); } + if durable { + let name = projected.event_name.as_str(); + let policy = + openbitfun_core::service::remote_connect::session_records::session_event_publication( + name, + &projected.payload, + ); + if !policy.synchronize_records && !policy.persist_control { + return Ok(()); + } + if let Some(publisher) = state.account_routing.session_publisher().await { + if policy.synchronize_records { + async { + if let Some(turn) = projected + .payload + .get("turnId") + .or_else(|| projected.payload.get("settledTurnId")) + .and_then(serde_json::Value::as_str) + { + openbitfun_core::service::remote_connect::synchronize_session_record_turn( + &publisher, session_id, turn, + ) + .await + } else if name == "agentic://session-history-changed" { + openbitfun_core::service::remote_connect::synchronize_session_records( + &publisher, session_id, + ) + .await + } else { + Ok(()) + } + } + .await?; + } + } + if let Some(session_id) = projected + .payload + .get("session_id") + .or_else(|| projected.payload.get("sessionId")) + .and_then(serde_json::Value::as_str) + { + if policy.persist_control { + publication.push(( + session_id.to_owned(), + projected.event_name, + projected.payload, + )); + } + settle_record_only_event(&state.turns, terminal_turn.as_ref()); + return Ok(()); + } + } let targets = attached_controllers(); if targets.is_empty() { // Controller presence is not Runtime ownership. Keep recording the @@ -580,7 +665,6 @@ pub(crate) async fn fanout_peer_device_event(event: String, payload: serde_json: return; } let inherited_owner = crate::account::inherited_peer_fanout_owner(); - let inherits_routing_lease = inherited_owner.is_some(); let owner = match inherited_owner { Some(owner) => owner, None => match super::state::peer_host_state().map(|state| state.account_routing.clone()) { @@ -598,58 +682,94 @@ pub(crate) async fn fanout_peer_device_event(event: String, payload: serde_json: }, }; let queued = QueuedPeerDeviceEvent::new(owner, targets, event, payload); - if inherits_routing_lease { - // HostInvoke already holds the lifecycle read lease. Never await queue - // capacity or acquire a nested read here: a queued transition writer - // would otherwise create a writer-priority self-deadlock. A detached - // task preserves backpressure and validates the captured owner later. - enqueue_inherited_peer_device_event(peer_event_sender().clone(), queued); - return; - } - if let Err(queued) = enqueue_peer_device_event(peer_event_sender(), queued).await { - tracing::warn!( - "Peer event delivery queue closed before accepting command event; using direct delivery" - ); - fanout_peer_device_event_once(queued).await; + if enqueue_peer_device_event(peer_event_sender(), queued) + .await + .is_err() + { + tracing::warn!("Peer control notification was not accepted: routing retired or delivery queue closed; controllers must refresh their mailbox"); } } -fn enqueue_inherited_peer_device_event( - sender: mpsc::Sender, - queued: QueuedPeerDeviceEvent, -) { - match sender.try_send(queued) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(queued)) => { - tokio::spawn(async move { - if let Err(queued) = enqueue_peer_device_event(&sender, queued).await { - tracing::warn!( - "Peer event delivery queue closed while draining inherited routing event" - ); - fanout_peer_device_event_once(queued).await; - } - }); - } - Err(mpsc::error::TrySendError::Closed(queued)) => { - tokio::spawn(async move { - tracing::warn!( - "Peer event delivery queue closed for inherited routing event; using direct delivery" - ); - fanout_peer_device_event_once(queued).await; - }); - } - } +fn peer_event_budget() -> &'static Arc { + static BUDGET: OnceLock> = OnceLock::new(); + BUDGET.get_or_init(|| Arc::new(Semaphore::new(PEER_EVENT_DELIVERY_BYTES))) } async fn enqueue_peer_device_event( sender: &mpsc::Sender, queued: QueuedPeerDeviceEvent, ) -> Result<(), QueuedPeerDeviceEvent> { - sender.send(queued).await.map_err(|error| error.0) + enqueue_peer_device_event_with_budget( + sender, + queued, + peer_event_budget().clone(), + PEER_EVENT_DELIVERY_BYTES, + ) + .await +} + +async fn enqueue_peer_device_event_with_budget( + sender: &mpsc::Sender, + mut queued: QueuedPeerDeviceEvent, + budget: Arc, + capacity: usize, +) -> Result<(), QueuedPeerDeviceEvent> { + // Include envelope/target overhead. A single oversized notification reserves + // the entire budget, so it remains deliverable without permitting a backlog + // of oversized messages. Large contents use the transport's bulk lane. + let bytes = serde_json::to_vec(&queued.payload) + .map(|json| json.len()) + .unwrap_or(capacity) + .saturating_add(queued.event.len()) + .saturating_add(queued.targets.iter().map(String::len).sum::()) + .saturating_add(512) + .min(capacity) as u32; + let mut cancellation = queued.owner.cancellation_receiver(); + let cancelled = async { + if let Some(ref mut receiver) = cancellation { + let _ = receiver.changed().await; + } else { + std::future::pending::<()>().await; + } + }; + tokio::pin!(cancelled); + let permit = tokio::select! { + biased; + _ = &mut cancelled => return Err(queued), + permit = budget.acquire_many_owned(bytes) => match permit { + Ok(permit) => permit, + Err(_) => return Err(queued), + } + }; + queued._memory = Some(permit); + // Reserve before moving the payload so cancellation returns ownership and + // releases its byte permit. No detached task and no direct-send bypass. + let slot = tokio::select! { + biased; + _ = &mut cancelled => return Err(queued), + slot = sender.reserve() => match slot { + Ok(slot) => slot, + Err(_) => return Err(queued), + } + }; + slot.send(queued); + Ok(()) } async fn fanout_peer_device_event_once(queued: QueuedPeerDeviceEvent) { + let Some(mut cancelled) = queued.owner.cancellation_receiver() else { + return; + }; + tokio::select! { + biased; + _ = cancelled.changed() => {}, + _ = fanout_peer_device_event_current(queued) => {} + } +} + +async fn fanout_peer_device_event_current(queued: QueuedPeerDeviceEvent) { let QueuedPeerDeviceEvent { + _memory, owner, targets, event, @@ -674,7 +794,7 @@ async fn fanout_peer_device_event_once(queued: QueuedPeerDeviceEvent) { return; } - let routing_lease = match crate::account::acquire_peer_fanout_lease(&owner).await { + let routing_lease = match crate::account::acquire_peer_fanout_lease(&owner) { Ok(lease) => lease, Err(error) => { tracing::debug!("Queued Peer event dropped after owner change: {error}"); @@ -774,8 +894,8 @@ mod tests { use openbitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; use super::{ - continuity_is_current, drain_broadcast_receiver, enqueue_inherited_peer_device_event, - enqueue_peer_device_event, event_turn_key, interrupted_turn_failure_projection, + continuity_is_current, drain_broadcast_receiver, enqueue_peer_device_event, + enqueue_peer_device_event_with_budget, event_turn_key, interrupted_turn_failure_projection, owned_terminal_turn, retained_delivery_targets, settle_record_only_event, QueuedPeerDeviceEvent, TerminalDeliveryGuard, }; @@ -864,7 +984,7 @@ mod tests { } #[tokio::test] - async fn closed_delivery_queue_returns_the_event_for_direct_fallback() { + async fn closed_delivery_queue_returns_undelivered_control() { let (tx, rx) = tokio::sync::mpsc::channel(1); drop(rx); let queued = QueuedPeerDeviceEvent::new( @@ -881,33 +1001,92 @@ mod tests { } #[tokio::test] - async fn inherited_enqueue_does_not_wait_for_full_delivery_queue() { + async fn byte_budget_backpressures_until_delivery_releases_memory() { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(1024)); + let make = || { + QueuedPeerDeviceEvent::new( + test_owner(7), + vec![], + "control".into(), + serde_json::json!({"data": "x".repeat(900)}), + ) + }; + assert!( + enqueue_peer_device_event_with_budget(&tx, make(), budget.clone(), 1024) + .await + .is_ok() + ); + let second = enqueue_peer_device_event_with_budget(&tx, make(), budget.clone(), 1024); + tokio::pin!(second); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut second) + .await + .is_err() + ); + let first = rx.recv().await.expect("first"); + assert_eq!( + budget.available_permits(), + 0, + "dequeue is not delivery completion" + ); + drop(first); + assert!(second.await.is_ok()); + drop(rx.recv().await); + assert_eq!(budget.available_permits(), 1024); + } + + #[tokio::test] + async fn account_retirement_cancels_waiting_admission_and_releases_capacity() { let (tx, mut rx) = tokio::sync::mpsc::channel(1); - tx.send(QueuedPeerDeviceEvent::new( + let budget = std::sync::Arc::new(tokio::sync::Semaphore::new(1024)); + let (cancel, cancellation) = tokio::sync::watch::channel(0); + let first = QueuedPeerDeviceEvent::new( test_owner(7), - vec!["controller-1".to_string()], - "first".to_string(), + vec![], + "first".into(), serde_json::json!({}), - )) - .await - .expect("seed queue"); - - enqueue_inherited_peer_device_event( - tx, - QueuedPeerDeviceEvent::new( - test_owner(7), - vec!["controller-1".to_string()], - "second".to_string(), - serde_json::json!({}), - ), ); + assert!( + enqueue_peer_device_event_with_budget(&tx, first, budget.clone(), 1024) + .await + .is_ok() + ); + let queued = QueuedPeerDeviceEvent::new( + test_owner(7).with_test_cancellation(cancellation), + vec![], + "retired".into(), + serde_json::json!({}), + ); + let pending = enqueue_peer_device_event_with_budget(&tx, queued, budget.clone(), 1024); + tokio::pin!(pending); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut pending) + .await + .is_err() + ); + cancel.send_replace(1); + let retired = pending.await.err().expect("cancelled admission"); + assert_eq!(retired.event, "retired"); + drop(retired); + drop(rx.recv().await); + assert_eq!(budget.available_permits(), 1024); + assert!(rx.try_recv().is_err()); + } - assert_eq!(rx.recv().await.expect("first queued event").event, "first"); - let second = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) - .await - .expect("detached enqueue should complete after capacity is available") - .expect("second queued event"); - assert_eq!(second.event, "second"); + #[tokio::test] + async fn retirement_before_enqueue_is_not_missed() { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let (cancel, cancellation) = tokio::sync::watch::channel(0); + let queued = QueuedPeerDeviceEvent::new( + test_owner(7).with_test_cancellation(cancellation), + vec![], + "old".into(), + serde_json::json!({}), + ); + cancel.send_replace(1); + assert!(enqueue_peer_device_event(&tx, queued).await.is_err()); + assert!(rx.try_recv().is_err()); } #[test] diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index c7b8e1a8dd..686d39b1af 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -111,6 +111,11 @@ For companion pet manifest versions and package metadata, use `cargo test -p openbitfun-desktop --lib api::commands::pet_package_tests`. For content-search routing and remote fallback protection, use `cargo test --locked -p openbitfun-desktop --lib api::search_api::tests`. +For controller-local peer download staging, atomic replacement, and failed transfer cleanup, +run `cargo test -p openbitfun-desktop --lib api::local_file_download::tests`. +After changing its registration, also run +`cargo test -p openbitfun-desktop --lib remote_workspace_policy`. + For staged application-update cache and signature behavior, use `cargo test -p openbitfun-desktop --lib api::update_api::tests`. For peer system-info response compatibility, run diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 7d8e9a85ac..c33e44c59e 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1032,6 +1032,8 @@ pub struct PermissionResponseRequest { pub request_id: String, pub reply: PermissionReplyKind, pub feedback: Option, + #[serde(default)] + pub updated_input: Option, } #[derive(Debug, Clone, Deserialize)] @@ -1411,14 +1413,36 @@ pub enum PermissionReplyKind { Reject, } -fn permission_reply(request: PermissionResponseRequest) -> PermissionReply { - match request.reply { +fn permission_reply(request: PermissionResponseRequest) -> Result { + if let Some(updated_input) = request.updated_input { + if !matches!(request.reply, PermissionReplyKind::Once) || !updated_input.is_object() { + return Err("Edited input requires a one-time approval and an object".to_string()); + } + return Ok(PermissionReply::OnceWithInput { updated_input }); + } + Ok(match request.reply { PermissionReplyKind::Once => PermissionReply::Once, PermissionReplyKind::Always => PermissionReply::Always, PermissionReplyKind::Reject => PermissionReply::Reject { feedback: request.feedback, }, - } + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionInteractionMailboxRequest { + pub session_id: String, +} + +#[tauri::command] +pub fn get_session_interaction_mailbox( + runtime: State<'_, DesktopRuntimeContext>, + request: SessionInteractionMailboxRequest, +) -> Result { + Ok(runtime + .session_application() + .session_interaction_snapshot(&request.session_id)) } #[tauri::command] @@ -1447,7 +1471,7 @@ pub async fn respond_permission( request: PermissionResponseRequest, ) -> Result<(), String> { let request_id = request.request_id.clone(); - let reply = permission_reply(request); + let reply = permission_reply(request)?; runtime .agent_runtime() .respond_permission(&request_id, reply) @@ -1461,7 +1485,7 @@ pub async fn respond_permission_batch( request: PermissionResponseRequest, ) -> Result, String> { let request_id = request.request_id.clone(); - let reply = permission_reply(request); + let reply = permission_reply(request)?; runtime .agent_runtime() .respond_permission_batch(&request_id, reply) @@ -4355,7 +4379,7 @@ mod tests { assert!(matches!(request.reply, PermissionReplyKind::Reject)); assert_eq!(request.feedback.as_deref(), Some("Use a read-only path")); assert_eq!( - permission_reply(request), + permission_reply(request).expect("permission reply"), PermissionReply::Reject { feedback: Some("Use a read-only path".to_string()), } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 742d489bc0..fc6cedd922 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -6,7 +6,7 @@ use crate::api::path_target::{ create_directory as create_desktop_directory, create_empty_file, delete_directory as delete_desktop_directory, delete_file as delete_desktop_file, get_path_metadata, path_exists, read_text_file, rename_path, resolve_desktop_path_target, - write_text_file, DesktopPathTarget, + DesktopPathTarget, }; use crate::api::search_api::{ build_content_search_request, group_search_results, prepare_content_search_runner, @@ -516,6 +516,8 @@ pub struct WriteFileContentRequest { pub content: String, #[serde(default, rename = "remoteConnectionId")] pub remote_connection_id: Option, + #[serde(default, rename = "expectedHash")] + pub expected_hash: Option, } #[derive(Debug, Deserialize)] @@ -561,6 +563,10 @@ pub struct GetDirectoryChildrenPaginatedRequest { pub limit: Option, #[serde(default)] pub remote_connection_id: Option, + #[serde(default)] + pub sort_by: Option, + #[serde(default)] + pub sort_order: Option, } pub type ExplorerGetFileTreeRequest = GetFileTreeRequest; @@ -1266,6 +1272,35 @@ pub async fn open_remote_workspace( use openbitfun_core::service::remote_ssh::workspace_state::remote_workspace_stable_id; use openbitfun_core::service::workspace::WorkspaceCreateOptions; + let ssh = state.get_ssh_manager_async().await?; + let saved = ssh + .get_saved_connections() + .await + .into_iter() + .find(|profile| profile.id == request.connection_id) + .ok_or("Remote workspace requires a connection saved on this host")?; + if request + .ssh_host + .as_deref() + .is_some_and(|host| !host.trim().is_empty() && host.trim() != saved.host) + { + return Err("SSH host identity does not match the saved connection".into()); + } + if !request.remote_path.starts_with('/') || request.remote_path.contains('\0') { + return Err("Remote workspace path must be an absolute POSIX path".into()); + } + ssh.ensure_connected(&request.connection_id) + .await + .map_err(|e| e.to_string())?; + let files = state.get_remote_file_service_async().await?; + if !files + .is_dir(&request.connection_id, &request.remote_path) + .await + .map_err(|e| e.to_string())? + { + return Err("Remote workspace path is not a directory".into()); + } + let remote_path = normalize_remote_workspace_path(&request.remote_path); let mut ssh_host = request @@ -2335,9 +2370,14 @@ async fn get_directory_children_paginated_response( .get_directory_contents_with_remote_hint(&request.path, preferred) .await { - Ok(nodes) => { + Ok(mut nodes) => { + openbitfun_core::service::filesystem::sort_directory_nodes( + &mut nodes, + request.sort_by.as_deref(), + request.sort_order.as_deref(), + )?; let total = nodes.len(); - let has_more = total > offset + limit; + let has_more = total > offset.saturating_add(limit); let page_nodes: Vec<_> = nodes.into_iter().skip(offset).take(limit).collect(); Ok(serde_json::json!({ @@ -2817,13 +2857,39 @@ pub async fn write_file_content( state: State<'_, AppState>, request: WriteFileContentRequest, ) -> Result<(), String> { - write_text_file( + let target = resolve_desktop_path_target( &state, &request.file_path, - &request.content, request.remote_connection_id.as_deref(), ) - .await + .await?; + use openbitfun_core::service::filesystem::path_operations::{ + write_local_text_checked, write_text_checked, + }; + match target { + DesktopPathTarget::Local { resolved_path, .. } => { + write_local_text_checked( + &state.filesystem_service, + &resolved_path.to_string_lossy(), + &request.content, + request.expected_hash.as_deref(), + ) + .await + } + DesktopPathTarget::Remote { + requested_path, + entry, + } => { + write_text_checked( + &state.filesystem_service, + &requested_path, + &request.content, + Some(&entry.connection_id), + request.expected_hash.as_deref(), + ) + .await + } + } } #[tauri::command] @@ -5530,3 +5596,17 @@ mod remote_guard_tests { assert!(legacy.remote_connection_id.is_none()); } } + +#[tauri::command] +pub async fn workspace_file_upload( + request: openbitfun_core::service::filesystem::upload::WorkspaceUploadRequest, +) -> Result { + let account = crate::api::remote_connect_api::account_status() + .await? + .user_id + .ok_or("Sign in to use workspace transfers")?; + let status = + openbitfun_core::service::filesystem::upload::workspace_file_upload(account, request) + .await?; + serde_json::to_value(status).map_err(|error| error.to_string()) +} diff --git a/src/apps/desktop/src/api/local_file_download.rs b/src/apps/desktop/src/api/local_file_download.rs new file mode 100644 index 0000000000..2c5757078e --- /dev/null +++ b/src/apps/desktop/src/api/local_file_download.rs @@ -0,0 +1,173 @@ +//! Controller-local atomic destination for streamed peer downloads. +use serde::{Deserialize, Serialize}; +use std::{io::Write, path::PathBuf, sync::Mutex}; +use tauri::{Manager, Resource}; +use tauri_plugin_fs::FsExt; + +struct DownloadSink { + staging: tempfile::NamedTempFile, + destination: PathBuf, + offset: u64, +} +impl DownloadSink { + fn begin(destination: PathBuf) -> Result { + let parent = destination + .parent() + .ok_or("Missing destination directory")?; + let staging = tempfile::Builder::new() + .prefix(".openbitfun-download-") + .tempfile_in(parent) + .map_err(|e| e.to_string())?; + Ok(Self { + staging, + destination, + offset: 0, + }) + } + fn write(&mut self, offset: u64, bytes: &[u8]) -> Result<(), String> { + if self.offset != offset { + return Err("Download offset mismatch".into()); + } + self.staging.write_all(bytes).map_err(|e| e.to_string())?; + self.offset += bytes.len() as u64; + Ok(()) + } + fn finish(self, expected: u64) -> Result<(), String> { + if self.offset != expected { + return Err("Incomplete download".into()); + } + self.staging + .as_file() + .sync_all() + .map_err(|e| e.to_string())?; + self.staging + .persist(&self.destination) + .map_err(|e| e.to_string())?; + Ok(()) + } +} +struct DownloadResource(Mutex>); +impl Resource for DownloadResource {} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum LocalFileDownloadRequest { + Begin { + destination: PathBuf, + }, + Write { + id: u32, + offset: u64, + bytes: Vec, + }, + Finish { + id: u32, + size: u64, + }, + Cancel { + id: u32, + }, +} +#[derive(Serialize)] +pub struct LocalFileDownloadResponse { + id: u32, +} + +#[tauri::command] +pub async fn local_file_download( + webview: tauri::Webview, + request: LocalFileDownloadRequest, +) -> Result { + // File IO runs off the async executor. Resource IDs belong to this webview, + // matching the filesystem plugin's lifetime and cross-window isolation. + tauri::async_runtime::spawn_blocking(move || { + let id = match request { + LocalFileDownloadRequest::Begin { destination } => { + if !destination.is_absolute() || !webview.fs_scope().is_allowed(&destination) { + return Err("Download destination is not authorized".into()); + } + // The selected destination is the only authorized product path. + // Staging remains private to Rust; no directory scope is granted. + let sink = DownloadSink::begin(destination)?; + webview + .resources_table() + .add(DownloadResource(Mutex::new(Some(sink)))) + } + LocalFileDownloadRequest::Write { id, offset, bytes } => { + let resource = webview + .resources_table() + .get::(id) + .map_err(|e| e.to_string())?; + let mut sink = resource.0.lock().map_err(|e| e.to_string())?; + sink.as_mut() + .ok_or("Download already closed")? + .write(offset, &bytes)?; + id + } + LocalFileDownloadRequest::Finish { id, size } => { + let resource = webview + .resources_table() + .take::(id) + .map_err(|e| e.to_string())?; + let sink = resource + .0 + .lock() + .map_err(|e| e.to_string())? + .take() + .ok_or("Download already closed")?; + sink.finish(size)?; + id + } + LocalFileDownloadRequest::Cancel { id } => { + let resource = webview + .resources_table() + .take::(id) + .map_err(|e| e.to_string())?; + resource.0.lock().map_err(|e| e.to_string())?.take(); + id + } + }; + Ok(LocalFileDownloadResponse { id }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn incomplete_and_cancelled_download_preserve_existing_destination() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("existing"); + std::fs::write(&target, b"original").unwrap(); + let mut sink = DownloadSink::begin(target.clone()).unwrap(); + sink.write(0, b"partial").unwrap(); + assert!(sink.finish(100).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"original"); + drop(DownloadSink::begin(target.clone()).unwrap()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + #[test] + fn complete_download_replaces_existing_and_rejects_wrong_offset() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("existing"); + std::fs::write(&target, b"original").unwrap(); + let mut sink = DownloadSink::begin(target.clone()).unwrap(); + assert!(sink.write(1, b"wrong").is_err()); + sink.write(0, b"new").unwrap(); + sink.finish(3).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + #[test] + fn failed_replace_cleans_staging_without_removing_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("directory"); + std::fs::create_dir(&target).unwrap(); + let sink = DownloadSink::begin(target.clone()).unwrap(); + assert!(sink.finish(0).is_err()); + assert!(target.is_dir()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 49e6c4c170..e481bd4ebb 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -32,6 +32,7 @@ pub mod git_api; pub mod html_preview_api; pub mod i18n_api; pub mod insights_api; +pub mod local_file_download; pub mod market_image_api; pub mod mcp_api; pub mod miniapp_agent_api; diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 9d04d2d6eb..7309206051 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -154,6 +154,14 @@ pub async fn resolve_desktop_path_target( }); } + if preferred_remote_connection_id == Some("") { + return Ok(DesktopPathTarget::Local { + requested_path: raw_path.to_string(), + resolved_path: PathBuf::from(raw_path), + is_runtime_artifact: false, + }); + } + let explicit_connection_id = preferred_remote_connection_id .map(str::trim) .filter(|value| !value.is_empty()); diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index c74624f450..32abcdb563 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -73,16 +73,16 @@ static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0); static DEVICE_ROUTING_LIFECYCLE_LOCK: tokio::sync::RwLock<()> = tokio::sync::RwLock::const_new(()); static DEVICE_ROUTING_CONNECTION_ID: AtomicU64 = AtomicU64::new(0); -/// Ceiling on device RPCs executing at once. -/// -/// RPCs run off the routing loop rather than on it, so without a bound a phone -/// that fans out a screenful of `list_sessions` would put all of them on the -/// webview bridge at once. The bound exists to keep that burst from crowding -/// out the next device's first request, not because concurrency is unsafe: -/// each RPC holds its own routing lease and answers its own correlation id. -const MAX_CONCURRENT_DEVICE_RPCS: usize = 8; -static DEVICE_RPC_SLOTS: tokio::sync::Semaphore = - tokio::sync::Semaphore::const_new(MAX_CONCURRENT_DEVICE_RPCS); +/// RPC memory admission belongs to the shared relay transport. Account +/// transitions cancel pending host futures before waiting for routing leases. +fn device_rpc_cancellation() -> &'static tokio::sync::watch::Sender { + static CANCEL: OnceLock> = OnceLock::new(); + CANCEL.get_or_init(|| tokio::sync::watch::channel(0).0) +} + +fn cancel_pending_device_rpcs() { + device_rpc_cancellation().send_modify(|epoch| *epoch = epoch.wrapping_add(1)); +} #[derive(Clone, Debug, Eq, PartialEq)] struct DeviceRoutingOwner { @@ -120,6 +120,8 @@ impl AccountContextTransitionPermit { fn begin() -> Self { ACCOUNT_CONTEXT_TRANSITIONS.fetch_add(1, Ordering::AcqRel); ACCOUNT_CONTEXT_GENERATION.fetch_add(1, Ordering::AcqRel); + cancel_pending_device_rpcs(); + clear_session_subscriptions(); Self } } @@ -296,6 +298,7 @@ async fn disconnect_peer_controllers(reason: &'static str) { /// lifecycle write lease ensures no retiring event handler can cross this /// boundary and dispatch through a subsequently installed connection. async fn stop_and_clear_device_routing(reason: &'static str) { + cancel_pending_device_rpcs(); let _lifecycle = DEVICE_ROUTING_LIFECYCLE_LOCK.write().await; clear_device_routing_state(); if let Some(service) = get_service_holder().read().await.as_ref() { @@ -305,6 +308,16 @@ async fn stop_and_clear_device_routing(reason: &'static str) { } async fn finish_device_routing_event_loop(owner: &DeviceRoutingOwner) { + let current = with_device_routing_state(|state| { + if state.owner.as_ref() != Some(owner) { + return false; + } + cancel_pending_device_rpcs(); + true + }); + if !current { + return; + } let _lifecycle = DEVICE_ROUTING_LIFECYCLE_LOCK.write().await; if !clear_device_routing_if_owner(owner) { return; @@ -312,6 +325,15 @@ async fn finish_device_routing_event_loop(owner: &DeviceRoutingOwner) { disconnect_peer_controllers("Peer device-routing stream closed").await; } +pub(crate) async fn session_publisher( +) -> Option> { + let service = get_service_holder().read().await; + match service.as_ref() { + Some(service) => service.session_publisher().await, + None => None, + } +} + /// Emit granular auto-sync progress for the account login / devices UI. pub fn fanout_peer_device_event(event: String, payload: serde_json::Value) { if crate::api::peer_host_invoke::attached_controllers().is_empty() { @@ -348,6 +370,15 @@ static PEER_EVENT_FANOUT_TX: OnceLock {}, + _ = fanout_peer_device_event_current(item) => {} + } +} + +async fn fanout_peer_device_event_current(item: PeerEventFanoutItem) { let Some(_routing_effect) = lock_current_device_routing(&item.routing_owner).await else { return; }; @@ -1266,6 +1297,30 @@ pub fn init_on_startup() { return; } }; + if openbitfun_services_integrations::remote_connect::account::is_retired_official_relay(&relay_url) { + if let Some(device_id) = loaded.device_id.as_deref() { + if let Err(error) = DeviceIdentity::adopt_account_device_id(device_id) { + log::warn!("Failed to adopt migrating account device id: {error}"); + return; + } + } + match login_account_on_relay_for_generation(openbitfun_product_domains::account::DEFAULT_RELAY_URL.to_string(), Some(startup_generation)).await { + Ok(_) => { + if let Err(error) = account_connect_devices_with_retry().await { + log::warn!("New Relay routing failed: {error}"); + } + restore_saved_bots().await; + } + Err(error) => { + sync_account_login_capability(false); + log::warn!("New Relay sign-in required; previous credential retained: {error}"); + if let Err(error) = ensure_service().await { + log::warn!("Remote connect startup init failed: {error}"); + } + } + } + return; + } let Some(restore_guard) = begin_account_transition_if_current(startup_generation).await else { @@ -2085,11 +2140,18 @@ pub async fn account_login(_request: AccountAuthRequest) -> Result Result { + login_account_on_relay_for_generation(relay_url, None).await +} + +async fn login_account_on_relay_for_generation( + relay_url: String, + required_generation: Option, +) -> Result { // Keep the old account fully usable while credentials are verified. Only a // successful candidate is allowed to begin the protected replacement // transition and retire the old account's runtime state. let _login_guard = ACCOUNT_LOGIN_LOCK.lock().await; - let expected_generation = account_context_generation(); + let expected_generation = required_generation.unwrap_or_else(account_context_generation); if !account_context_is_current(expected_generation) { return Err("account context changed".to_string()); } @@ -2123,6 +2185,14 @@ async fn login_account_on_relay(relay_url: String) -> Result Result, String> .as_ref() .ok_or_else(|| "remote connect service not initialized".to_string())?; - let routing_lifecycle = DEVICE_ROUTING_LIFECYCLE_LOCK.write().await; - // Reuse is allowed only when the active socket is explicitly owned by the // current account generation and token. A service-level connected flag by // itself may still describe the account that was just replaced. @@ -2335,6 +2411,9 @@ pub async fn account_connect_devices() -> Result, String> } } + cancel_pending_device_rpcs(); + let routing_lifecycle = DEVICE_ROUTING_LIFECYCLE_LOCK.write().await; + // Invalidate the prior loop before `start_device_connection` swaps the // service client. Its compare-and-clear exit path must not touch this new // connection's controllers or presence. @@ -2433,21 +2512,8 @@ pub async fn account_connect_devices() -> Result, String> break 'routing_events; } log::info!("Device presence updated: {} online", devices.len()); - let online_device_ids = devices - .iter() - .map(|device| device.device_id.clone()) - .collect::>(); - let request_ids = - crate::api::peer_host_invoke::retain_online_controllers(&online_device_ids); - if let Err(error) = - crate::api::peer_host_invoke::fail_closed_permission_requests( - request_ids, - "Last Peer controller went offline", - ) - .await - { - log::warn!("Peer permission requests were not fully cancelled: {error}"); - } + // Offline presence does not revoke an account device or its + // permission mailbox. Reconnect resumes the same ownership. if !device_routing_owner_is_current(&event_owner).await { break 'routing_events; } @@ -2563,6 +2629,7 @@ pub async fn account_connect_devices() -> Result, String> // The lease is taken here, on the loop, so a // retiring loop still notices it has been // replaced and stops reading events at once. + let mut cancelled = device_rpc_cancellation().subscribe(); let Some(routing_effect) = lock_current_device_routing(&event_owner).await else { @@ -2588,15 +2655,36 @@ pub async fn account_connect_devices() -> Result, String> let ping_generation = control_ping_generation(&rpc_owner); let ping_received_at = std::time::Instant::now(); tokio::spawn(async move { - // Held for the whole call: teardown takes - // the write lease, so an in-flight RPC now - // keeps the connection from being replaced - // out from under its own reply. + tokio::select! { + biased; + _ = cancelled.changed() => {}, + _ = async move { + // A transition cancels this future before + // taking the write lease. Captured account + // ownership cannot cross into its replacement. let _routing_effect = routing_effect; - let Ok(_slot) = DEVICE_RPC_SLOTS.acquire().await else { - return; + let execution = if let RemoteCommand::GetSessionKey { + session_id, + } = &cmd + { + async { + let publisher=session_publisher().await.ok_or_else(||anyhow::anyhow!("Session publisher unavailable"))?; + if session_id != openbitfun_core::service::remote_connect::session_log::HOST_CATALOG_ID && !session_id.starts_with("terminal-") { + openbitfun_core::service::remote_connect::synchronize_session_records(&publisher,session_id).await.map_err(anyhow::Error::msg)?; + } + let response_session=session_id.clone(); + let session_id=session_id.clone();let account=rpc_session.user_id.clone(); + let (relay_session_id,key)=tokio::task::spawn_blocking(move || -> anyhow::Result<(String,String)> { + use openbitfun_core::service::remote_connect::{DeviceIdentity,session_log::SessionLog}; + let device=DeviceIdentity::from_current_machine()?; + let log=SessionLog::existing_for_host(&account,&device.device_id,&session_id)?; + Ok((log.relay_session_id(),log.key_grant()?)) + }).await??; + Ok(serde_json::json!({"resp":"session_key","session_id":response_session,"relay_session_id":relay_session_id,"key":key})) + }.await + } else { + execute_local_remote_command(&cmd).await }; - let execution = execute_local_remote_command(&cmd).await; // Returning drops this reply only. The loop // re-checks ownership at the top of every // iteration, so a stale connection is still @@ -2644,6 +2732,9 @@ pub async fn account_connect_devices() -> Result, String> .await; } } + + } => {} + } }); } Err(e) => { @@ -2702,17 +2793,8 @@ pub async fn account_connect_devices() -> Result, String> } clear_control_ping_if_owner(&event_owner); log::info!("Device routing disconnected"); - let request_ids = - crate::api::peer_host_invoke::take_tracked_permission_requests(); - if let Err(error) = - crate::api::peer_host_invoke::fail_closed_permission_requests( - request_ids, - "Peer device-routing connection lost", - ) - .await - { - log::warn!("Peer permission requests were not fully cancelled: {error}"); - } + // Preserve pending permissions across network loss. Explicit + // account retirement retains the revocation path. if !device_routing_owner_is_current(&event_owner).await { break 'routing_events; } @@ -3207,7 +3289,7 @@ mod sync_state_tests { for (method, endpoint) in [ ( serde_json::json!("openbitfun_server"), - "https://remote.openbitfun.com/v/1.0.0", + "https://remote.openbitfun.com/v/1.0.1", ), ( serde_json::json!({"lan":{"ip":"192.168.1.2"}}), @@ -3398,3 +3480,99 @@ mod peer_event_tests { assert!(should_fanout_peer_ui_event("ai://model-catalog-updated")); } } + +static SESSION_SUBSCRIPTIONS: OnceLock< + std::sync::Mutex< + std::collections::HashMap< + String, + openbitfun_core::service::remote_connect::session_subscriber::SessionSubscriber, + >, + >, +> = OnceLock::new(); + +fn clear_session_subscriptions() { + if let Some(subscriptions) = SESSION_SUBSCRIPTIONS.get() { + if let Ok(mut subscriptions) = subscriptions.lock() { + subscriptions.clear(); + } + } +} + +#[derive(Deserialize)] +pub struct SubscribeSessionRequest { + target_device_id: String, + session_id: String, +} +#[derive(Deserialize)] +pub struct UnsubscribeSessionRequest { + subscription_id: String, +} + +#[tauri::command] +pub async fn account_load_older_session(request: UnsubscribeSessionRequest) -> Result<(), String> { + let load = { + let subscriptions = SESSION_SUBSCRIPTIONS + .get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + .lock() + .map_err(|_| "session subscription owner unavailable")?; + subscriptions + .get(&request.subscription_id) + .ok_or("session subscription is no longer active")? + .load_older() + }; + load.await.map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn account_subscribe_session(request: SubscribeSessionRequest) -> Result { + let generation = account_context_generation(); + let (session, relay) = read_account_context_for_generation(generation).await?; + let source = request.target_device_id.clone(); + let error_source = source.clone(); + let error_session_id = request.session_id.clone(); + let subscriber = + openbitfun_core::service::remote_connect::session_subscriber::SessionSubscriber::start( + session, + relay, + request.target_device_id, + request.session_id, + Arc::new(move |event| { + if !account_context_is_current(generation) { + anyhow::bail!("account subscription retired"); + } + emit_account_event(&event.event, tag_peer_event_source(event.payload, &source)); + Ok(()) + }), + Arc::new(move |error| { + if account_context_is_current(generation) { + emit_account_event( + "account://session-sync-error", + serde_json::json!({"message":error,"sessionId":error_session_id,"targetDeviceId":error_source}), + ); + } + }), + ) + .await + .map_err(|e| e.to_string())?; + let id = uuid::Uuid::new_v4().to_string(); + let mut subscriptions = SESSION_SUBSCRIPTIONS + .get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) + .lock() + .map_err(|_| "session subscription owner unavailable")?; + if !account_context_is_current(generation) { + return Err("account context changed".into()); + } + subscriptions.insert(id.clone(), subscriber); + Ok(id) +} + +#[tauri::command] +pub async fn account_unsubscribe_session(request: UnsubscribeSessionRequest) -> Result<(), String> { + if let Some(subscriptions) = SESSION_SUBSCRIPTIONS.get() { + subscriptions + .lock() + .map_err(|_| "session subscription owner unavailable")? + .remove(&request.subscription_id); + } + Ok(()) +} diff --git a/src/apps/desktop/src/api/terminal_api.rs b/src/apps/desktop/src/api/terminal_api.rs index 0c0d67e3eb..65cb493437 100644 --- a/src/apps/desktop/src/api/terminal_api.rs +++ b/src/apps/desktop/src/api/terminal_api.rs @@ -19,11 +19,11 @@ use openbitfun_core::service::terminal::{ CreateSessionRequest as CoreCreateSessionRequest, ExecuteCommandRequest as CoreExecuteCommandRequest, ExecuteCommandResponse as CoreExecuteCommandResponse, - GetHistoryRequest as CoreGetHistoryRequest, GetHistoryResponse as CoreGetHistoryResponse, - ResizeRequest as CoreResizeRequest, SendCommandRequest as CoreSendCommandRequest, - SessionResponse as CoreSessionResponse, SessionSource as CoreSessionSource, - ShellInfo as CoreShellInfo, ShellType, SignalRequest as CoreSignalRequest, TerminalApi, - TerminalConfig, WriteRequest as CoreWriteRequest, + GetHistoryResponse as CoreGetHistoryResponse, ResizeRequest as CoreResizeRequest, + SendCommandRequest as CoreSendCommandRequest, SessionResponse as CoreSessionResponse, + SessionSource as CoreSessionSource, ShellInfo as CoreShellInfo, ShellType, + SignalRequest as CoreSignalRequest, TerminalApi, TerminalConfig, + WriteRequest as CoreWriteRequest, }; use super::app_state::AppState; @@ -270,6 +270,9 @@ pub struct GetHistoryResponse { pub session_id: String, pub data: String, pub history_size: usize, + pub next_offset: u64, + pub cursor: u64, + pub truncated: bool, /// PTY column count at the time history was captured. pub cols: u16, /// PTY row count at the time history was captured. @@ -282,6 +285,9 @@ impl From for GetHistoryResponse { session_id: resp.session_id, data: resp.data, history_size: resp.history_size, + next_offset: resp.history_size as u64, + cursor: resp.history_size as u64, + truncated: false, cols: resp.cols, rows: resp.rows, } @@ -347,7 +353,85 @@ async fn is_remote_session(session_id: &str) -> bool { false } +/// Coalesce PTY notifications; terminal bytes remain in the owning replay buffer. +fn notify_terminal_replay(session_id: &str) { + use std::{ + collections::HashSet, + sync::{Mutex as StdMutex, OnceLock}, + }; + static PENDING: OnceLock<(StdMutex>, tokio::sync::Notify)> = OnceLock::new(); + let pending = + PENDING.get_or_init(|| (StdMutex::new(HashSet::new()), tokio::sync::Notify::new())); + static WORKER: std::sync::Once = std::sync::Once::new(); + WORKER.call_once(|| { + tokio::spawn(async { + loop { + let pending = PENDING + .get() + .expect("Terminal notification state initialized"); + pending.1.notified().await; + tokio::time::sleep(std::time::Duration::from_millis(75)).await; + let ids: Vec<_> = pending + .0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .drain() + .collect(); + let Some(publisher) = super::remote_connect_api::session_publisher().await else { + continue; + }; + for id in ids { + let mut cursor = None; + if let Some(state) = get_remote_workspace_manager() { + if let Some(manager) = state.get_terminal_manager().await { + cursor = manager.replay_cursor(&id).await; + } + } + if cursor.is_none() { + if let Ok(api) = TerminalApi::from_singleton() { + cursor = api.session_manager().replay_cursor(&id).await; + } + } + if let Err(error) = publisher + .append( + format!("terminal-{id}"), + "terminal-output".into(), + serde_json::json!({"terminal_id":id,"cursor":cursor}), + ) + .await + { + warn!("Failed to publish terminal notification: {}", error); + } + } + } + }); + }); + pending + .0 + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session_id.to_string()); + pending.1.notify_one(); +} + +async fn register_terminal_stream(id: &str) -> Result<(), String> { + if let Some(publisher) = super::remote_connect_api::session_publisher().await { + publisher + .append( + format!("terminal-{id}"), + "terminal-created".into(), + serde_json::json!({"terminal_id":id}), + ) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + fn emit_terminal_event(app_handle: &AppHandle, event: &TerminalEvent) -> bool { + if let TerminalEvent::Data { session_id, .. } | TerminalEvent::Exit { session_id, .. } = event { + notify_terminal_replay(session_id); + } let event_name = "terminal_event"; let local_emit_succeeded = match app_handle.emit(event_name, event) { Ok(()) => true, @@ -459,6 +543,7 @@ async fn spawn_remote_pty_session( ); }); + register_terminal_stream(&session_id).await?; Ok(response) } @@ -478,6 +563,15 @@ pub async fn terminal_create( .map(str::trim) .filter(|s| !s.is_empty()) { + let ssh = app_state.get_ssh_manager_async().await?; + if !ssh + .get_saved_connections() + .await + .iter() + .any(|profile| profile.id == connection_id) + { + return Err("Remote terminal requires a connection saved on this host".into()); + } let terminal_manager = app_state .get_remote_terminal_manager_async() .await @@ -501,23 +595,25 @@ pub async fn terminal_create( .await; } - if let Some((connection_id, remote_cwd)) = - lookup_remote_for_terminal(request.working_directory.as_deref()).await - { - if let Some(remote_manager) = get_remote_workspace_manager() { - let terminal_manager = remote_manager - .get_terminal_manager() - .await - .ok_or("Remote terminal manager not available")?; - - return spawn_remote_pty_session( - &_app, - &terminal_manager, - &connection_id, - &request, - Some(remote_cwd.as_str()), - ) - .await; + if request.connection_id.as_deref() != Some("") { + if let Some((connection_id, remote_cwd)) = + lookup_remote_for_terminal(request.working_directory.as_deref()).await + { + if let Some(remote_manager) = get_remote_workspace_manager() { + let terminal_manager = remote_manager + .get_terminal_manager() + .await + .ok_or("Remote terminal manager not available")?; + + return spawn_remote_pty_session( + &_app, + &terminal_manager, + &connection_id, + &request, + Some(remote_cwd.as_str()), + ) + .await; + } } } @@ -542,6 +638,7 @@ pub async fn terminal_create( .await .map_err(|e| format!("Failed to create session: {}", e))?; + register_terminal_stream(&session.id).await?; Ok(SessionResponse::from(session)) } @@ -798,36 +895,11 @@ pub async fn terminal_execute( .get_terminal_manager() .await .ok_or("Remote terminal manager not available")?; - let session = terminal_manager - .get_session(&request.session_id) - .await - .ok_or("Remote session not found")?; - let ssh_manager = remote_manager - .get_ssh_manager() + let result = terminal_manager + .execute(&request.session_id, &request.command, request.timeout_ms) .await - .ok_or("SSH manager not available")?; - let (stdout, stderr, exit_code) = ssh_manager - .execute_command(&session.connection_id, &request.command) - .await - .map_err(|e| format!("Failed to execute remote command: {}", e))?; - - return Ok(ExecuteCommandResponse { - command: request.command, - command_id: format!( - "remote-cmd-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - ), - output: if stderr.is_empty() { - stdout - } else { - format!("{}\n{}", stdout, stderr) - }, - exit_code: Some(exit_code), - completion_reason: "completed".to_string(), - }); + .map_err(|e| e.to_string())?; + return Ok(ExecuteCommandResponse::from(result)); } } @@ -927,39 +999,41 @@ pub async fn terminal_shutdown_all(state: State<'_, TerminalState>) -> Result<() #[tauri::command] pub async fn terminal_get_history( session_id: String, + after_offset: Option, state: State<'_, TerminalState>, ) -> Result { - if is_remote_session(&session_id).await { - if let Some(remote_manager) = get_remote_workspace_manager() { - if let Some(terminal_manager) = remote_manager.get_terminal_manager().await { - if let Some(session) = terminal_manager.get_session(&session_id).await { - return Ok(GetHistoryResponse { - session_id: session.id, - data: String::new(), - history_size: 0, - cols: session.cols, - rows: session.rows, - }); - } - } + let after = after_offset.unwrap_or(0); + let limit = if after_offset.is_some() { + 64 * 1024 + } else { + usize::MAX + }; + let mut page = None; + if let Some(manager) = get_remote_workspace_manager() { + if let Some(remote) = manager.get_terminal_manager().await { + page = remote.replay_page(&session_id, after, limit).await; } } - - let api = state.get_or_init_api().await?; - - let core_request = CoreGetHistoryRequest { session_id }; - - let response = api - .get_history(core_request) - .await - .map_err(|e| format!("Failed to get history: {}", e))?; - + let page = if let Some(page) = page { + page + } else { + state + .get_or_init_api() + .await? + .session_manager() + .replay_page(&session_id, after, limit) + .await + .ok_or("Terminal session is unavailable")? + }; Ok(GetHistoryResponse { - session_id: response.session_id, - data: response.data, - history_size: response.history_size, - cols: response.cols, - rows: response.rows, + session_id, + data: page.data, + history_size: page.history_size, + next_offset: page.next_offset, + cursor: page.cursor, + truncated: page.truncated, + cols: page.cols, + rows: page.rows, }) } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 91e5cece4b..52c0d7faed 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1298,6 +1298,7 @@ pub async fn run() { webdriver_bridge_result, get_startup_native_trace, api::agentic_api::list_sessions, + api::agentic_api::get_session_interaction_mailbox, api::agentic_api::list_pending_permission_requests, api::agentic_api::subscribe_permission_requests, api::agentic_api::respond_permission, @@ -1387,12 +1388,14 @@ pub async fn run() { delete_agent_companion_pet_package, read_file_content, write_file_content, + workspace_file_upload, reset_workspace_persona_files, check_path_exists, get_file_metadata, get_file_editor_sync_hash, rename_file, export_local_file_to_path, + api::local_file_download::local_file_download, reveal_in_explorer, get_file_tree, explorer_get_file_tree, @@ -1729,6 +1732,9 @@ pub async fn run() { api::remote_connect_api::account_list_devices, api::remote_connect_api::account_delete_device, api::remote_connect_api::account_device_rpc, + api::remote_connect_api::account_subscribe_session, + api::remote_connect_api::account_unsubscribe_session, + api::remote_connect_api::account_load_older_session, // OpenBitFun Page API api::pages_api::page_publish, api::pages_api::page_save_version, @@ -2297,6 +2303,7 @@ async fn deliver_event_to_webview( event: AgenticEvent, session_event_journal: &SessionEventJournal, ) { + openbitfun_core::service::remote_connect::notify_session_catalog_event(&event); let cursor = session_event_journal.record(&event); let Some(mut projected) = openbitfun_events::project_agentic_frontend_event(event) else { log::warn!("Unhandled AgenticEvent type in desktop delivery"); @@ -2306,6 +2313,60 @@ async fn deliver_event_to_webview( attach_session_event_cursor(&mut projected.payload, cursor); } + if let (Some(publisher), Some(session_id)) = ( + api::remote_connect_api::session_publisher().await, + projected + .payload + .get("sessionId") + .or_else(|| projected.payload.get("session_id")) + .and_then(serde_json::Value::as_str), + ) { + let name = projected.event_name.as_str(); + let policy = + openbitfun_core::service::remote_connect::session_records::session_event_publication( + name, + &projected.payload, + ); + if policy.synchronize_records { + if let Err(error) = async { + if let Some(turn) = projected + .payload + .get("turnId") + .or_else(|| projected.payload.get("settledTurnId")) + .and_then(serde_json::Value::as_str) + { + openbitfun_core::service::remote_connect::synchronize_session_record_turn( + &publisher, session_id, turn, + ) + .await + } else if name == "agentic://session-history-changed" { + openbitfun_core::service::remote_connect::synchronize_session_records( + &publisher, session_id, + ) + .await + } else { + Ok(()) + } + } + .await + { + log::error!("Unable to synchronize durable session records: {error}"); + } + } + if policy.persist_control { + if let Err(error) = publisher + .append( + session_id.to_owned(), + projected.event_name.clone(), + projected.payload.clone(), + ) + .await + { + log::error!("Unable to persist session control event: {error}"); + } + } + } + if let Err(e) = transport .emit_generic(&projected.event_name, projected.payload.clone()) .await @@ -2313,9 +2374,8 @@ async fn deliver_event_to_webview( log::error!("Failed to emit event: {:?}", e); } - if !api::peer_host_invoke::attached_controllers().is_empty() { - api::remote_connect_api::fanout_peer_device_event(projected.event_name, projected.payload); - } + // Session events are delivered once through the durable account log. + // Do not duplicate every payload on the per-controller ephemeral channel. } /// Update the rate EMA from a flush that produced `flushed_chars` characters. diff --git a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts index 61a1f4d240..9ddb193799 100644 --- a/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts +++ b/src/web-ui/src/app/components/NavPanel/deviceInterconnectionOverview.test.ts @@ -63,7 +63,7 @@ describe('projectDeviceInterconnectionOverview', () => { remoteStatus: { ...disconnectedStatus, relay_connected: true, - relay_url: 'https://remote.openbitfun.com/v/1.0.0', + relay_url: 'https://remote.openbitfun.com/v/1.0.1', active_method: 'openbitfun_server' as const, clients: [{ id: 'mobile-user', name: 'My iPhone' }], }, @@ -189,7 +189,7 @@ describe('selectActivityFacts', () => { const connectedPhone = { ...disconnectedStatus, relay_connected: true, - relay_url: 'https://remote.openbitfun.com/v/1.0.0', + relay_url: 'https://remote.openbitfun.com/v/1.0.1', active_method: 'openbitfun_server' as const, clients: [{ id: 'mobile-user', name: 'My iPhone' }], }; @@ -269,7 +269,7 @@ describe('selectAttachedGroups', () => { remoteStatus: { ...disconnectedStatus, relay_connected: true, - relay_url: 'https://remote.openbitfun.com/v/1.0.0', + relay_url: 'https://remote.openbitfun.com/v/1.0.1', active_method: 'openbitfun_server' as const, clients: [{ id: 'mobile-user', name: 'My iPhone' }], bot_connected: 'Weixin (family group)', @@ -332,7 +332,7 @@ describe('device display names', () => { remoteStatus: { ...disconnectedStatus, relay_connected: true, - relay_url: 'https://remote.openbitfun.com/v/1.0.0', + relay_url: 'https://remote.openbitfun.com/v/1.0.1', active_method: 'openbitfun_server' as const, clients: [{ id: 'mobile-user', name: 'Pixel.lan' }], }, diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts index 6f48e5772f..3b1256dc03 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RelayHttpClient.generation.test.ts @@ -8,7 +8,7 @@ function deferred() { let resolve!: (value: T) => void; const promise = new P beforeEach(() => { vi.stubGlobal('window', globalThis); }); afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); }); -describe.each(['https://remote.openbitfun.com/v/1.0.0', 'http://192.168.1.9:9700'])('account device routing on %s', (relayUrl) => { +describe.each(['https://remote.openbitfun.com/v/1.0.1', 'http://192.168.1.9:9700'])('account device routing on %s', (relayUrl) => { it('rejects an unauthenticated constructor and sends nothing after logout', async () => { expect(() => new RelayHttpClient(relayUrl, { ...identity(), token: '' })).toThrow(); const client = new RelayHttpClient(relayUrl, identity()); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx index 879bb82abb..92eb432e96 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx @@ -67,7 +67,7 @@ vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ succe vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmWarning: vi.fn().mockResolvedValue(true) })); vi.mock('./AccountPanel', () => ({ AccountPanel: () => null })); -const relayA = 'https://remote.openbitfun.com/v/1.0.0'; +const relayA = 'https://remote.openbitfun.com/v/1.0.1'; function status(overrides: Partial = {}): RemoteConnectStatus { return { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx index d8916c310a..aea8d864a2 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/SessionListTargetInitialization.test.tsx @@ -10,14 +10,15 @@ import SessionListPage, { import type { RemoteSessionManager } from '../../../../../mobile-web/src/services/RemoteSessionManager'; import { useMobileStore } from '../../../../../mobile-web/src/services/store'; -vi.mock('../../../../../mobile-web/src/i18n', () => ({ - useI18n: () => ({ +vi.mock('../../../../../mobile-web/src/i18n', () => { + const t = (key: string) => key; + return { useI18n: () => ({ language: 'en-US', toggleLanguage: vi.fn(), - t: (key: string) => key, + t, formatDate: () => '', - }), -})); + }) }; +}); vi.mock('../../../../../mobile-web/src/theme', () => ({ useTheme: () => ({ isDark: false, toggleTheme: vi.fn() }), @@ -103,6 +104,7 @@ describe('SessionList target initialization ownership', () => { listRecentWorkspaces: vi.fn().mockResolvedValue([]), listAssistants: vi.fn().mockResolvedValue([]), createSession, + subscribeSessionStream: vi.fn().mockResolvedValue({ close: vi.fn() }), } as unknown as RemoteSessionManager; const onSelectSession = vi.fn(); @@ -112,6 +114,7 @@ describe('SessionList target initialization ownership', () => { sessionMgr={manager} onSelectSession={onSelectSession} onOpenWorkspace={vi.fn()} + onOpenDeviceTools={vi.fn()} onDisconnect={vi.fn()} />, ); @@ -165,4 +168,39 @@ describe('SessionList target initialization ownership', () => { 'claw', ); }); + it('workspace creation offers all modes while footer tools preserve workspace identity', async () => { + const selected = { path: '/same', name: 'Selected SSH', remote_connection_id: 'ssh-b', last_opened: '' }; + const createSession = vi.fn().mockResolvedValue('created-in-b'); + const manager = { + controlTargetEpoch: 0, + onControlTargetChange: () => () => {}, + subscribeSessionStream: vi.fn().mockResolvedValue({ close: vi.fn() }), + supportsHostCapability: () => true, + getWorkspaceInfo: vi.fn().mockResolvedValue({has_workspace: true, path: '/different', workspace_kind: 'normal'}), + listWorkspaceCatalog: vi.fn().mockResolvedValue({workspaces: [selected], source: 'opened'}), + listSessions: vi.fn().mockResolvedValue({sessions: [], has_more: false}), + listAssistants: vi.fn().mockResolvedValue([]), + listRecentWorkspaces: vi.fn().mockResolvedValue([selected]), + createSession, + } as unknown as RemoteSessionManager; + const openTool = vi.fn(); + await act(async () => root.render()); + await flushPromises(); + await act(async () => container.querySelector('.harmony-sidebar__workspace-tools')!.click()); + expect(createSession).not.toHaveBeenCalled(); + expect(openTool).toHaveBeenCalledTimes(1); + expect(createSession).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain('shell.recentConversations'); + expect(container.querySelector('.harmony-sidebar__new-chat')).toBeNull(); + await act(async () => container.querySelector('.harmony-sidebar__row-plus')!.click()); + const modes = [...document.querySelectorAll('.harness-profile-picker [role="radio"]')]; + expect(modes).toHaveLength(3); + await act(async () => modes[1].click()); + await flushPromises(); + expect(createSession).toHaveBeenCalledWith('Standard', undefined, '/same', { + remoteConnectionId: 'ssh-b', remoteSshHost: undefined, + }); + }); + }); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 1030a9d6e8..e32bfb03e6 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -4019,6 +4019,7 @@ export const ChatInput: React.FC = ({ ); let composerCleared = false; const trimmedMessage = message.trim(); + if (!trimmedMessage.startsWith('/')) return false; const commandWhitespaceIndex = trimmedMessage.search(/\s/); const command = trimmedMessage.startsWith('/') ? (commandWhitespaceIndex === -1 diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 54272b60aa..0564e7db7c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -1955,6 +1955,32 @@ export const ModernFlowChatContainer: React.FC = ( } const session = flowChatStore.getState().sessions.get(sessionId); + if (getActiveSurfaceScope().surfaceId !== 'local' && !presentation) { + if (direction === 'after' || session?.isPartial === false) return 'exhausted'; + const scope = getActiveSurfaceScope(); + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'loading' })); + try { + const prepared = await options?.prepareViewportForPresentationCommit?.(); + if (prepared === false || !scope.isCurrent() || activeSessionIdRef.current !== sessionId) { + options?.cancelViewportPresentationCommit?.(); + return 'cancelled'; + } + const loaded = await flowChatStore.loadOlderRelaySessionHistory(sessionId); + if (!scope.isCurrent() || activeSessionIdRef.current !== sessionId) return 'cancelled'; + return loaded ? 'applied' : 'exhausted'; + } catch (error) { + options?.cancelViewportPresentationCommit?.(); + if (isSurfaceChangedError(error)) return 'cancelled'; + log.error('Relay history page failed', { sessionId, error }); + if (scope.isCurrent()) setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); + return 'not-ready'; + } finally { + if (scope.isCurrent() && activeSessionIdRef.current === sessionId) { + setHistoryBoundaryState(previous => previous[direction] === 'error' + ? previous : { ...previous, [direction]: 'idle' }); + } + } + } const historyView = flowChatStore.getSessionHistoryViewState(sessionId); const totalTurnCount = Math.max( historyView?.catalog?.totalTurnCount ?? 0, diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.test.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.test.ts new file mode 100644 index 0000000000..cfe7cea815 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.test.ts @@ -0,0 +1,30 @@ +import { afterEach, expect, it, vi } from 'vitest'; +const fixture = vi.hoisted(() => ({ + surface: 'local', text: null as ((event: any) => void) | null, + tool: null as ((event: any) => void) | null, +})); +vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ getActiveSurfaceId: () => fixture.surface })); +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { + onTextChunk: (listener: (event: any) => void) => { fixture.text = listener; return () => {}; }, + onToolEvent: (listener: (event: any) => void) => { fixture.tool = listener; return () => {}; }, +} })); +import { agenticEventListener } from './AgenticEventListener'; +afterEach(async () => { await agenticEventListener.stopListening(); fixture.surface = 'local'; }); +it('uses records for remote content while local content and remote approvals keep their owners', async () => { + const onTextChunk = vi.fn(), onToolEvent = vi.fn(); + await agenticEventListener.startListening({ onTextChunk, onToolEvent }); + fixture.text?.({ text: 'local token' }); + fixture.tool?.({ toolEvent: { event_type: 'Completed', result: 'local result' } }); + expect(onTextChunk).toHaveBeenCalledOnce(); + expect(onToolEvent).toHaveBeenCalledOnce(); + fixture.surface = 'peer'; + fixture.text?.({ text: 'duplicate remote token' }); + for (const event_type of ['Started', 'ParamsPartial', 'StreamChunk', 'Completed', 'Failed']) { + fixture.tool?.({ toolEvent: { event_type, result: 'duplicate remote body' } }); + expect(agenticEventListener.dispatchExternal('agentic://tool-event', { toolEvent: { event_type } })).toBe(true); + } + expect(onTextChunk).toHaveBeenCalledOnce(); + expect(onToolEvent).toHaveBeenCalledOnce(); + fixture.tool?.({ toolEvent: { event_type: 'ConfirmationNeeded' } }); + expect(onToolEvent).toHaveBeenCalledTimes(2); +}); diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index b3ab8a6982..2d6d56bccd 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -27,6 +27,17 @@ import type { InterruptedDialogTurnEvent, } from '@/infrastructure/api/service-api/AgentAPI'; import { createLogger } from '@/shared/utils/logger'; +import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; + +/** Remote transcript content has one owner: the canonical session records. + * Tool approval events carry interaction facts, not transcript replacements. */ +function acceptsSurfaceEvent(eventName: string, payload: { toolEvent?: { event_type?: string } }): boolean { + if (getActiveSurfaceId() === 'local') return true; + if (eventName === 'agentic://text-chunk') return false; + if (eventName !== 'agentic://tool-event') return true; + return ['ConfirmationNeeded', 'Confirmed', 'Rejected', 'Cancelled'] + .includes(payload.toolEvent?.event_type ?? ''); +} type UnlistenFn = () => void; @@ -172,6 +183,7 @@ export class AgenticEventListener { if (callbacks.onTextChunk) { const unlisten = agentAPI.onTextChunk((event) => { + if (!acceptsSurfaceEvent('agentic://text-chunk', {})) return; callbacks.onTextChunk?.(event); }); this.unlistenFunctions.push(unlisten); @@ -179,6 +191,7 @@ export class AgenticEventListener { if (callbacks.onToolEvent) { const unlisten = agentAPI.onToolEvent((event) => { + if (!acceptsSurfaceEvent('agentic://tool-event', event)) return; callbacks.onToolEvent?.(event); }); this.unlistenFunctions.push(unlisten); @@ -353,6 +366,7 @@ export class AgenticEventListener { return false; } + if (!acceptsSurfaceEvent(eventName, payload)) return true; switch (eventName) { case 'agentic://session-created': callbacks.onSessionCreated?.(payload as AgenticEvent); diff --git a/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts b/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts new file mode 100644 index 0000000000..1420aa628e --- /dev/null +++ b/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fixture = vi.hoisted(() => ({ + current: true, + interaction: null as ((event: any) => void) | null, + record: null as ((event: unknown) => void) | null, + ready: null as ((event: any) => void) | null, + error: null as ((event: any) => void) | null, + subscribe: vi.fn(), unsubscribe: vi.fn(async () => {}), older: vi.fn(async () => {}), +})); +vi.mock('@/infrastructure/peer-device/deviceSurface', () => ({ + getActiveSurfaceScope: () => ({ surfaceId: 'host', isCurrent: () => fixture.current, + assertCurrent: () => { if (!fixture.current) throw new Error('Surface changed'); } }), +})); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: { + onSessionInteractionChanged: (listener: (event:any)=>void) => {fixture.interaction=listener;return()=>{fixture.interaction=null;};}, + onSessionRecord: (listener: (event: unknown) => void) => { fixture.record = listener; return () => { fixture.record = null; }; }, + onSessionReady: (listener: (event: any) => void) => { fixture.ready = listener; return () => { fixture.ready = null; }; }, + onSessionSyncError: (listener: (event: any) => void) => { fixture.error = listener; return () => { fixture.error = null; }; }, + subscribeSession: fixture.subscribe, unsubscribeSession: fixture.unsubscribe, loadOlderSession: fixture.older, +} })); + +import { RelaySessionHistory } from './RelaySessionHistory'; + +describe('RelaySessionHistory', () => { + beforeEach(() => { vi.clearAllMocks(); fixture.current = true; }); + afterEach(() => { vi.useRealTimers(); }); + + it('registers before cache replay, then uses the same subscription for older pages', async () => { + const apply = vi.fn(); + fixture.subscribe.mockImplementation(async () => { + fixture.record?.({ sessionId: 'session', id: 'turn/turn' }); + fixture.ready?.({ sessionId: 'session', hasMore: true, oldestSeq: 50, cursor: 150 }); + return 'subscription'; + }); + const history = new RelaySessionHistory('session', apply, vi.fn(), vi.fn()); + await history.open(); + fixture.older.mockImplementation(async () => { + fixture.ready?.({ sessionId: 'session', hasMore: false, oldestSeq: 1, cursor: 150 }); + }); + await Promise.all([history.loadOlder(), history.loadOlder()]); + expect(apply).toHaveBeenCalledOnce(); + expect(fixture.subscribe).toHaveBeenCalledOnce(); + expect(fixture.older).toHaveBeenCalledWith('subscription'); + expect(fixture.older).toHaveBeenCalledOnce(); + history.close(); + expect(fixture.unsubscribe).toHaveBeenCalledWith('subscription'); + expect(fixture.record).toBeNull(); + }); + + it('surfaces replay failures instead of leaving the first page loading forever', async () => { + fixture.subscribe.mockImplementation(async () => { + fixture.error?.({ sessionId: 'session', targetDeviceId: 'host', message: 'Unreadable session' }); + return 'subscription'; + }); + const error = vi.fn(); + const history = new RelaySessionHistory('session', vi.fn(), vi.fn(), error); + await expect(history.open()).rejects.toThrow('Unreadable session'); + expect(error).toHaveBeenCalledOnce(); + expect(fixture.unsubscribe).toHaveBeenCalledWith('subscription'); + }); + + it('drops stale replay and unsubscribes when the surface changes during grant', async () => { + const apply = vi.fn(); + fixture.subscribe.mockImplementation(async () => { + fixture.current = false; + fixture.record?.({ sessionId: 'session' }); + return 'subscription'; + }); + const history = new RelaySessionHistory('session', apply, vi.fn(), vi.fn()); + await expect(history.open()).rejects.toThrow('Surface changed'); + expect(apply).not.toHaveBeenCalled(); + expect(fixture.unsubscribe).toHaveBeenCalledWith('subscription'); + }); + it('rejects an older page when its reducer fails even if the host RPC succeeds', async () => { + fixture.subscribe.mockImplementation(async () => { + fixture.ready?.({ sessionId: 'session', hasMore: true, oldestSeq: 50, cursor: 150 }); + return 'subscription'; + }); + const apply = vi.fn(() => { throw new Error('Invalid historical record'); }); + const history = new RelaySessionHistory('session', apply, vi.fn(), vi.fn()); + await history.open(); + fixture.older.mockImplementation(async () => { + fixture.record?.({ sessionId: 'session', id: 'invalid' }); + }); + await expect(history.loadOlder()).rejects.toThrow('Invalid historical record'); + history.close(); + }); + + it('paints latest first and yields between older prefetch pages on the same owner', async () => { + vi.useFakeTimers(); + fixture.subscribe.mockImplementation(async () => { + fixture.ready?.({ sessionId: 'session', hasMore: true, oldestSeq: 50, cursor: 150 }); + return 'subscription'; + }); + fixture.older.mockImplementation(async () => { + fixture.ready?.({ sessionId: 'session', hasMore: false, oldestSeq: 1, cursor: 150 }); + }); + const history = new RelaySessionHistory('session', vi.fn(), vi.fn(), vi.fn()); + await history.open(); + expect(fixture.older).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(250); + expect(fixture.older).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1000); + expect(fixture.older).toHaveBeenCalledOnce(); + history.close(); + }); + +}); + +it('refreshes the small mailbox on initial ready and reconnect, but never on older-page ready', async () => { + fixture.current = true; + const resumed = vi.fn(async () => {}); + fixture.subscribe.mockImplementation(async () => { fixture.ready?.({sessionId:'session',hasMore:true,oldestSeq:5,cursor:10}); return 'subscription'; }); + fixture.older.mockImplementation(async () => { fixture.ready?.({sessionId:'session',hasMore:false,oldestSeq:1,cursor:10}); }); + const history = new RelaySessionHistory('session', vi.fn(), vi.fn(), vi.fn(), resumed); + await history.open(); expect(resumed).toHaveBeenCalledTimes(1); + await history.loadOlder(); expect(resumed).toHaveBeenCalledTimes(1); + fixture.ready?.({sessionId:'session',hasMore:false,oldestSeq:1,cursor:12}); + expect(resumed).toHaveBeenCalledTimes(2); + fixture.interaction?.({sessionId:"session",userQuestionsRevision:2}); + expect(resumed).toHaveBeenCalledTimes(3); + history.close(); +}); diff --git a/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts b/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts new file mode 100644 index 0000000000..97c828bcb2 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts @@ -0,0 +1,144 @@ +import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import type { SessionRecord } from '../session-stream/SessionRecordReplica'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('RelaySessionHistory'); + +export interface RelayHistoryReady { + sessionId: string; + hasMore: boolean; + oldestSeq: number; + cursor: number; +} + +/** One session subscription, shared by opening history and receiving live records. */ +export class RelaySessionHistory { + private readonly scope = getActiveSurfaceScope(); + private readonly listeners: Array<() => void> = []; + private subscriptionId: string | null = null; + private closed = false; + private ready: RelayHistoryReady | null = null; + private resolveReady!: () => void; + private rejectReady!: (error: unknown) => void; + private readonly firstPage = new Promise((resolve, reject) => { + this.resolveReady = resolve; + this.rejectReady = reject; + }); + private opening: Promise | null = null; + private older: Promise | null = null; + private historyTimer: ReturnType | null = null; + private pageApplied: (() => void) | null = null; + private rejectPage: ((error: unknown) => void) | null = null; + + constructor( + readonly sessionId: string, + onRecord: (record: SessionRecord) => void, + onReady: (ready: RelayHistoryReady) => void, + onError: (error: unknown) => void, + onResumed?: () => Promise, + ) { + // Register before asking Rust to replay its cache or fetch the first page. + this.listeners.push(remoteConnectAPI.onSessionRecord(value => { + if (!this.isCurrent()) return; + const record = value as SessionRecord; + if (record.sessionId !== this.sessionId) return; + try { onRecord(record); } catch (error) { this.rejectReady(error); this.rejectPage?.(error); onError(error); } + })); + this.listeners.push(remoteConnectAPI.onSessionSyncError(error => { + if (!this.isCurrent() || error.sessionId !== this.sessionId || error.targetDeviceId !== this.scope.surfaceId) return; + this.rejectReady(new Error(error.message)); + this.rejectPage?.(new Error(error.message)); + onError(new Error(error.message)); + })); + this.listeners.push(remoteConnectAPI.onSessionReady(ready => { + if (!this.isCurrent() || ready.sessionId !== this.sessionId) return; + const resumed = this.pageApplied === null; + this.ready = ready; + if (resumed && onResumed) void onResumed().catch(error => { + log.warn('Session interaction mailbox refresh failed', { sessionId: this.sessionId, error }); + }); + onReady(ready); + this.resolveReady(); + this.pageApplied?.(); + })); + this.listeners.push(remoteConnectAPI.onSessionInteractionChanged(event => { + if (!this.isCurrent() || event.sessionId !== this.sessionId || !onResumed) return; + void onResumed().catch(error => log.warn('Session interaction mailbox refresh failed', {sessionId:this.sessionId,error})); + })); + // A closed subscription may never have had an open() caller. + void this.firstPage.catch(() => {}); + } + + private isCurrent(): boolean { return !this.closed && this.scope.isCurrent(); } + + open(): Promise { + if (!this.opening) this.opening = this.start(); + return this.opening; + } + + private async start(): Promise { + this.scope.assertCurrent('open relay session history'); + if (this.closed) throw new Error('Session subscription is closed'); + try { + const id = await remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId); + if (!this.isCurrent()) { + await remoteConnectAPI.unsubscribeSession(id); + this.scope.assertCurrent('finish opening relay session history'); + throw new Error('Session subscription is closed'); + } + this.subscriptionId = id; + await this.firstPage; + this.scheduleHistoryPrefetch(); + } catch (error) { + this.rejectReady(error); + this.close(); + throw error; + } + } + + // Happy sync.ts fetchOlderMessagesInBackground: paint the latest page first, + // then reuse the same older-page owner and yield between bounded pages. + private scheduleHistoryPrefetch(): void { + if (!this.isCurrent() || !this.ready?.hasMore || this.historyTimer !== null) return; + this.historyTimer = setTimeout(() => { + this.historyTimer = null; + if (!this.isCurrent()) return; + void this.loadOlder().then(() => this.scheduleHistoryPrefetch()).catch(error => { + log.warn('Session history prefetch stopped', { sessionId: this.sessionId, error }); + }); + }, 250); + } + + loadOlder(): Promise { + if (!this.older) this.older = this.readOlder().finally(() => { this.older = null; }); + return this.older; + } + + private async readOlder(): Promise { + await this.open(); + this.scope.assertCurrent('load older relay history'); + if (!this.ready?.hasMore) return false; + if (!this.subscriptionId) throw new Error('Session subscription is closed'); + const applied = new Promise((resolve, reject) => { this.pageApplied = resolve; this.rejectPage = reject; }); + try { + await Promise.all([remoteConnectAPI.loadOlderSession(this.subscriptionId), applied]); + } finally { this.pageApplied = null; this.rejectPage = null; } + this.scope.assertCurrent('finish loading older relay history'); + return true; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.historyTimer !== null) clearTimeout(this.historyTimer); + this.historyTimer = null; + this.listeners.splice(0).forEach(unlisten => unlisten()); + this.rejectReady(new Error('Session subscription closed')); + this.rejectPage?.(new Error('Session subscription closed')); + if (this.subscriptionId) { + void remoteConnectAPI.unsubscribeSession(this.subscriptionId).catch(error => log.warn('Session unsubscribe failed', { error })); + this.subscriptionId = null; + } + } +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.test.ts index 969fc12c75..2c73ac2939 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.test.ts @@ -36,7 +36,6 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import { installPeerSessionRefresh, isSessionProjectionAttachable, - PEER_SESSION_REFRESH_INTERVAL_MS, requestPeerSessionRefresh, runtimeProjectionCaughtUp, } from './PeerSessionRefreshModule'; @@ -68,7 +67,7 @@ describe('PeerSessionRefreshModule', () => { vi.useRealTimers(); }); - it('refreshes immediately, periodically, and on an event-gap request', async () => { + it('hydrates once and repairs explicit gaps without periodic requests', async () => { const refreshPeerSessionSnapshot = vi.fn(async () => ({ applied: false, backendState: 'Processing', @@ -106,12 +105,12 @@ describe('PeerSessionRefreshModule', () => { await vi.advanceTimersByTimeAsync(0); expect(refreshPeerSessionSnapshot).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); - expect(refreshPeerSessionSnapshot).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(60_000); + expect(refreshPeerSessionSnapshot).toHaveBeenCalledTimes(1); requestPeerSessionRefresh('session-1'); await vi.advanceTimersByTimeAsync(0); - expect(refreshPeerSessionSnapshot).toHaveBeenCalledTimes(3); + expect(refreshPeerSessionSnapshot).toHaveBeenCalledTimes(2); cleanup(); }); @@ -137,7 +136,7 @@ describe('PeerSessionRefreshModule', () => { } as any; const cleanup = installPeerSessionRefresh(context); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS * 2); + await vi.advanceTimersByTimeAsync(60_000 * 2); expect(refreshPeerSessionSnapshot).not.toHaveBeenCalled(); cleanup(); @@ -214,7 +213,7 @@ describe('PeerSessionRefreshModule re-attach after a surface switch', () => { const cleanup = installPeerSessionRefresh(contextWithSnapshot(refresh)); await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(60_000); expect(refresh).toHaveBeenCalled(); expect(stateMachineMock.transition).toHaveBeenCalled(); @@ -235,7 +234,7 @@ describe('PeerSessionRefreshModule re-attach after a surface switch', () => { const cleanup = installPeerSessionRefresh(contextWithSnapshot(refresh)); await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(60_000); expect(refresh).toHaveBeenCalledTimes(1); expect(stateMachineMock.reset).not.toHaveBeenCalled(); @@ -308,7 +307,7 @@ describe('PeerSessionRefreshModule re-attach after a surface switch', () => { refresh.mockClear(); documentStub.visibilityState = 'hidden'; - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(60_000); expect(refresh).not.toHaveBeenCalled(); requestPeerSessionRefresh('session-1'); @@ -659,7 +658,7 @@ describe('PeerSessionRefreshModule dead subscription recovery', () => { const cleanup = installPeerSessionRefresh(context); await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(60_000); expect(refreshPeerSessionSnapshot).toHaveBeenCalled(); cleanup(); @@ -671,7 +670,7 @@ describe('PeerSessionRefreshModule dead subscription recovery', () => { const cleanup = installPeerSessionRefresh(context); await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(60_000); expect(ensureLiveSubscription).toHaveBeenCalled(); cleanup(); @@ -683,7 +682,7 @@ describe('PeerSessionRefreshModule dead subscription recovery', () => { const cleanup = installPeerSessionRefresh(context); await vi.advanceTimersByTimeAsync(1); - await vi.advanceTimersByTimeAsync(PEER_SESSION_REFRESH_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(60_000); expect(ensureLiveSubscription).not.toHaveBeenCalled(); cleanup(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.ts index ddb14bd3bb..1bc9d59095 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.ts @@ -1,14 +1,8 @@ /** - * Active-session snapshot reconciliation for the rendered device surface. - * - * DeviceEvent fan-out is the real-time path, but the relay protocol has no - * ACK/replay recovery. A controller that attaches mid-turn can therefore miss - * lifecycle events required by the local FlowChat state machine. The same gap - * exists on the **local** surface: a turn that keeps running on this machine - * while the UI renders another device produces events that surface routing - * drops, so returning to it needs the same repair. This module periodically - * reconciles a small host snapshot and also supports immediate refresh - * requests when an event gap is detected. + * Attach restores a host-owned view once. The durable session subscription + * then follows Socket.IO notifications and replays by receive cursor. Explicit + * runtime gaps or surface activation may request reconciliation; idle windows + * do not repeatedly download snapshots. */ import { @@ -16,6 +10,7 @@ import { isSurfaceChangedError, } from '@/infrastructure/peer-device/deviceSurface'; import { isSurfaceReconcileEnabled } from '@/infrastructure/peer-device/deviceSurfaceReconcile'; +import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import type { RuntimeProjectedAgenticEvent, @@ -45,7 +40,6 @@ import type { FlowChatContext } from './types'; const log = createLogger('PeerSessionRefresh'); -export const PEER_SESSION_REFRESH_INTERVAL_MS = 3000; export const PEER_SESSION_STREAM_STALE_MS = 6000; /** @@ -409,6 +403,27 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void let inFlight = false; const queuedSessionIds = new Set(); let immediateTimer: ReturnType | null = null; + let retrySubscription: ReturnType | null = null; + let retryDelay = 1000; + function releaseSubscription(): void { + if (retrySubscription !== null) { clearTimeout(retrySubscription); retrySubscription = null; } + } + function ensureSubscription(): void { + if (disposed) return; + const surface = getActiveSurfaceScope(); + const sessionId = context.flowChatStore.getState().activeSessionId; + if (surface.surfaceId === 'local' || !sessionId) return; + void context.flowChatStore.loadRelaySessionHistory(sessionId).then(() => { + retryDelay = 1000; + }).catch(error => { + if (disposed || !surface.isCurrent()) return; + log.warn('Session stream subscription deferred', { sessionId, error }); + if (retrySubscription !== null) return; + retrySubscription = setTimeout(() => { retrySubscription = null; ensureSubscription(); }, retryDelay); + retryDelay = Math.min(retryDelay * 2, 30000); + }); + } + function enqueueFollowUpRefresh(sessionId?: string): void { queuedSessionIds.add(sessionId); @@ -433,6 +448,12 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void } return; } + // Peer history and live records share the durable subscription. Runtime + // projection hydration remains only for the controller's local surface. + if (getActiveSurfaceScope().surfaceId !== 'local') { + ensureSubscription(); + return; + } // Hidden only skips the 3s liveness poll. A named repair or first attach // must still run — otherwise a dropped ToolEnd stays frozen in the background. if ( @@ -465,6 +486,7 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void return; } const workspacePath = session.workspacePath.trim(); + ensureSubscription(); pendingQueueManager.reconcileAgainstLiveTurns( sessionId, state.sessions.get(sessionId)?.dialogTurns ?? [], @@ -726,6 +748,7 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void } } inFlight = false; + ensureSubscription(); drainFollowUpRefresh(); } } @@ -747,6 +770,7 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void } installedRefreshRequester = scheduleRefresh; + const unsubscribeSourceGap = remoteConnectAPI.onSessionGap(event => scheduleRefresh(event.sessionId)); const unsubscribeActiveSession = context.flowChatStore.subscribeSelector( state => { @@ -762,9 +786,8 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void }, () => scheduleRefresh(), ); - const interval = setInterval(() => { - void runRefresh(undefined, true); - }, PEER_SESSION_REFRESH_INTERVAL_MS); + // Durable Socket.IO notifications and after-sequence recovery replace the + // previous three-second snapshot poll. Attach still reads the host once. const unsubscribeRuntimeGaps = subscribeRuntimeSessionEventGaps( (surfaceId, sessionId) => { if (getActiveSurfaceScope().surfaceId === surfaceId) { @@ -773,7 +796,7 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void }, ); - const handlePeerModeChanged = (): void => scheduleRefresh(); + const handlePeerModeChanged = (): void => { releaseSubscription(); scheduleRefresh(); }; const handleVisibilityChanged = (): void => { if (typeof document === 'undefined' || document.visibilityState === 'visible') { scheduleRefresh(); @@ -796,9 +819,10 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void if (immediateTimer !== null) { clearTimeout(immediateTimer); } - clearInterval(interval); + releaseSubscription(); unsubscribeActiveSession(); unsubscribeRuntimeGaps(); + unsubscribeSourceGap(); if (typeof window !== 'undefined') { window.removeEventListener('peer-mode:changed', handlePeerModeChanged); } diff --git a/src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.test.ts b/src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.test.ts new file mode 100644 index 0000000000..2f9a5bce80 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { SessionRecordReplica, type SessionRecordUpsert } from './SessionRecordReplica'; + +function record(revision: number, content = 'answer'): SessionRecordUpsert { + return { + sessionId: 'session', id: 'item/text', revision, + turn: { turnId: 'turn', turnIndex: 0, sessionId: 'session', timestamp: 1, + userMessage: { id: 'user', content: 'question', timestamp: 1 }, startTime: 1, status: 'running' }, + round: { id: 'round', turnId: 'turn', roundIndex: 0, timestamp: 2, startTime: 2, status: 'running', + attemptDiagnostics: [{ attemptId: 'attempt', attemptIndex: 1, category: 'retry' }] }, + item: { type: 'text', data: { id: 'text', content, isStreaming: false, timestamp: 3, + attemptId: 'attempt', attemptIndex: 1, subagentSessionId: 'child' } }, + }; +} + +describe('SessionRecordReplica', () => { + it('merges complete blocks by stable identity without duplicating replay', () => { + const replica = new SessionRecordReplica('session'); + replica.apply(record(1, 'first')); + const turn = replica.apply(record(2, 'complete'))!.turn!; + expect(turn.modelRounds[0].textItems.map(item => item.content)).toEqual(['complete']); + expect(turn.modelRounds[0].textItems[0].subagentSessionId).toBe('child'); + expect(turn.modelRounds[0].attemptDiagnostics?.[0].attemptId).toBe('attempt'); + expect(replica.apply(record(1))).toBeNull(); + expect(replica.apply(record(2))).toBeNull(); + }); + + it('loads older blocks without regressing newer turn or round state', () => { + const replica = new SessionRecordReplica('session'); + const completed = record(5); + completed.turn.status = 'completed'; + completed.round!.status = 'completed'; + replica.apply(completed); + const earlier = record(2, 'earlier'); + earlier.id = 'item/earlier'; + earlier.item!.data.id = 'earlier'; + const turn = replica.apply(earlier)!.turn!; + expect(turn.status).toBe('completed'); + expect(turn.modelRounds[0].status).toBe('completed'); + expect(turn.modelRounds[0].textItems).toHaveLength(2); + }); + + it('rejects another session or mismatched ancestry before changing state', () => { + const replica = new SessionRecordReplica('session'); + const wrong = record(1); + wrong.turn.sessionId = 'other'; + expect(() => replica.apply(wrong)).toThrow('another session'); + const wrongRound = record(1); + wrongRound.round!.turnId = 'another-turn'; + expect(() => replica.apply(wrongRound)).toThrow('ancestry'); + expect(replica.apply(record(1))?.turn?.modelRounds).toHaveLength(1); + }); + + it('retains deletion revisions so backward pages cannot resurrect a turn', () => { + const replica = new SessionRecordReplica('session'); + replica.apply(record(1)); + expect(replica.apply({ sessionId: 'session', id: 'turn/turn', revision: 10, deleted: true })) + .toEqual({ turnId: 'turn', turn: null }); + expect(replica.apply(record(5))?.turn).toBeNull(); + const restored = record(11); + restored.id = 'turn/turn'; + delete restored.round; + delete restored.item; + // Recreating a parent does not revive children deleted with it. + expect(replica.apply(restored)?.turn?.modelRounds).toEqual([]); + expect(replica.apply(record(12))?.turn?.modelRounds[0].textItems).toHaveLength(1); + }); + + it('accepts a tombstone arriving before the corresponding history page', () => { + const replica = new SessionRecordReplica('session'); + replica.apply({ sessionId: 'session', id: 'item/text', revision: 10, deleted: true }); + expect(replica.apply(record(2))).toBeNull(); + expect(replica.apply(record(11))?.turn?.modelRounds[0].textItems).toHaveLength(1); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.ts b/src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.ts new file mode 100644 index 0000000000..7666725800 --- /dev/null +++ b/src/web-ui/src/flow_chat/session-stream/SessionRecordReplica.ts @@ -0,0 +1,16 @@ +import { SessionRecordReplica as SharedReplica } from '../../../../shared/relay-transport/SessionRecordReplica'; +import type { DialogTurnData, ModelRoundData, TextItemData, ThinkingItemData, ToolItemData } from '@/shared/types/session-history'; +export interface SessionRecordUpsert { + sessionId: string; id: string; revision: number; deleted?: false; + turn: Omit; + round?: Omit; + item?: {type:'text';data:TextItemData}|{type:'thinking';data:ThinkingItemData}|{type:'tool';data:ToolItemData}; +} +export type SessionRecord = SessionRecordUpsert | {sessionId:string;id:string;revision:number;deleted:true}; +export interface SessionRecordChange {turnId:string;turn:DialogTurnData|null} +/** Web UI presentation types wrap the shared lossless record reducer. */ +export class SessionRecordReplica extends SharedReplica { + override apply(record: SessionRecord): SessionRecordChange | null { + return super.apply(record) as SessionRecordChange | null; + } +} diff --git a/src/web-ui/src/flow_chat/state-machine/RuntimeStatus.test.ts b/src/web-ui/src/flow_chat/state-machine/RuntimeStatus.test.ts new file mode 100644 index 0000000000..1ec5fb7fd6 --- /dev/null +++ b/src/web-ui/src/flow_chat/state-machine/RuntimeStatus.test.ts @@ -0,0 +1,20 @@ +import { expect, it, vi } from 'vitest'; +import { SessionStateMachineImpl } from './SessionStateMachine'; +import { ProcessingPhase, SessionExecutionState } from './types'; + +it('adopts runtime completion and approvals without replaying controller actions', () => { + const machine = new SessionStateMachineImpl('session'); + const listener = vi.fn(); + machine.subscribe(listener); + const waiting = { state: SessionExecutionState.PROCESSING, turnId: 'turn', roundId: 'round', + phase: ProcessingPhase.TOOL_CONFIRMING, pendingTools: ['tool'], error: null }; + machine.acceptRuntimeStatus(waiting); + expect(machine.getContext().pendingToolConfirmations.has('tool')).toBe(true); + const calls = listener.mock.calls.length; + machine.acceptRuntimeStatus(waiting); + expect(listener).toHaveBeenCalledTimes(calls); + machine.acceptRuntimeStatus({ ...waiting, state: SessionExecutionState.IDLE, phase: null, pendingTools: [] }); + expect(machine.getCurrentState()).toBe(SessionExecutionState.IDLE); + expect(machine.getContext().pendingToolConfirmations.size).toBe(0); + expect(machine.getSnapshot().transitionHistory).toHaveLength(0); +}); diff --git a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts index a66d616c1c..b606d2d0c8 100644 --- a/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts +++ b/src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts @@ -80,6 +80,32 @@ export class SessionStateMachineImpl { return this.context; } + /** Adopt host facts without executing controller-side transition effects. */ + acceptRuntimeStatus(status: { + state: SessionExecutionState; + turnId: string | null; + roundId: string | null; + phase: ProcessingPhase | null; + pendingTools: string[]; + error: string | null; + }): void { + const pendingTools = new Set(status.pendingTools); + if (this.currentState === status.state + && this.context.currentDialogTurnId === status.turnId + && this.context.currentModelRoundId === status.roundId + && this.context.processingPhase === status.phase + && this.context.errorMessage === status.error + && pendingTools.size === this.context.pendingToolConfirmations.size + && [...pendingTools].every(id => this.context.pendingToolConfirmations.has(id))) return; + this.currentState = status.state; + this.context = { ...this.context, taskId: this.sessionId, + currentDialogTurnId: status.turnId, currentModelRoundId: status.roundId, + processingPhase: status.phase, pendingToolConfirmations: pendingTools, + errorMessage: status.error, version: this.context.version + 1, + lastUpdateTime: Date.now(), backendSyncedAt: Date.now() }; + this.notifyListeners(); + } + async transition( event: SessionExecutionEvent, payload?: any diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 06f20d5ae7..798dd69c67 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -20,6 +20,10 @@ import { } from './askUserQuestionDraftStore'; const apiMocks = vi.hoisted(() => ({ + getSessionInteractionMailbox: vi.fn(async (sessionId: string) => ({sessionId, userQuestions:{revision:0,questions:[]}, permissions:{revision:0,requests:[]}})), + subscribeRelaySession: vi.fn(), + relayRecord: null as ((value: any) => void) | null, + relayReady: null as ((value: any) => void) | null, listSessions: vi.fn(), listSessionsPage: vi.fn(), loadSessionTurns: vi.fn(), @@ -83,6 +87,7 @@ vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ agentAPI: { + getSessionInteractionMailbox: apiMocks.getSessionInteractionMailbox, cancelSession: apiMocks.cancelSession, deleteSession: apiMocks.deleteSession, restoreSession: apiMocks.restoreSession, @@ -105,6 +110,12 @@ vi.mock('@/features/dispatch/dispatchApi', () => ({ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: { + onSessionInteractionChanged: () => () => {}, + onSessionRecord: (listener: (value: any) => void) => { apiMocks.relayRecord = listener; return () => { apiMocks.relayRecord = null; }; }, + onSessionReady: (listener: (value: any) => void) => { apiMocks.relayReady = listener; return () => { apiMocks.relayReady = null; }; }, + onSessionSyncError: vi.fn(() => () => {}), + subscribeSession: apiMocks.subscribeRelaySession, + unsubscribeSession: vi.fn(async () => {}), }, })); @@ -7103,23 +7114,15 @@ describe('FlowChatStore device surfaces', () => { resetStore(); }); - // The capability record used to collapse every non-SSH host onto `'local'`, - // so one peer that rejects the command downgraded this machine's own history - // restore for the rest of the session. - it('keeps an unsupported restore command on the device that rejected it', async () => { + it('keeps a failed peer log isolated and never falls back to transcript RPC', async () => { activateSurface(PEER_SURFACE_ID); seedSession('peer-1'); - apiMocks.restoreSessionView.mockRejectedValueOnce( - new Error('unknown command restore_session_view'), - ); - apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ - session: restoredSession('peer-1'), - turns: [restoredTurn('peer-1')], - }); - - await flowChatStore.loadSessionHistory('peer-1', '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/repo/OpenBitFun'); - expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); - expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + apiMocks.subscribeRelaySession.mockRejectedValueOnce(new Error('Relay unavailable')); + await expect(flowChatStore.loadSessionHistory('peer-1', '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/repo/OpenBitFun')) + .rejects.toThrow('Relay unavailable'); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('peer-1')?.historyState).toBe('failed'); activateSurface(LOCAL_SURFACE_ID); seedSession('local-1'); @@ -7128,14 +7131,73 @@ describe('FlowChatStore device surfaces', () => { turns: [restoredTurn('local-1')], contextRestoreState: 'ready', }); - await flowChatStore.loadSessionHistory('local-1', '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/repo/OpenBitFun'); + expect(apiMocks.restoreSessionView).toHaveBeenCalledOnce(); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('local-1')?.historyState).toBe('ready'); + }); - expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); - expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); - expect(flowChatStore.getState().sessions.get('local-1')).toMatchObject({ - historyState: 'ready', - }); + it('builds visible turn navigation from canonical records without treating storage gaps as ordinals', async () => { + activateSurface(PEER_SURFACE_ID); + seedSession('peer-log'); + stateMachineManagerMock.getOrCreate.mockReturnValue({ acceptRuntimeStatus: vi.fn() }); + apiMocks.subscribeRelaySession.mockImplementationOnce(async () => { + for (const index of [2, 11]) { + const { modelRounds: _rounds, ...turn } = createPersistedTurn(index, 'peer-log'); + apiMocks.relayRecord?.({ sessionId: 'peer-log', id: `turn/${turn.turnId}`, revision: index + 1, turn }); + } + apiMocks.relayReady?.({ sessionId: 'peer-log', hasMore: false, oldestSeq: 1, cursor: 2 }); + return 'peer-log-subscription'; + }); + await flowChatStore.loadSessionHistory('peer-log', '/runtime'); + const session = flowChatStore.getState().sessions.get('peer-log'); + expect(session?.turnCatalog?.entries.map(entry => [entry.ordinal, entry.storageTurnIndex])) + .toEqual([[0, 2], [1, 11]]); + const selected = await flowChatStore.loadSessionTurnWindow('peer-log', 1); + expect(selected.status).toBe('ready'); + expect(selected.targetTurnId).toBe('turn-11'); + expect(apiMocks.loadSessionTurnWindow).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + }); + + it('retains mailbox questions across canonical updates and clears them when the owning turn completes', async () => { + activateSurface(PEER_SURFACE_ID); + seedSession('peer-mailbox'); + stateMachineManagerMock.getOrCreate.mockReturnValue({ acceptRuntimeStatus: vi.fn() }); + const {modelRounds: _rounds, ...stored} = createPersistedTurn(0, 'peer-mailbox'); + const turn = {...stored, status:'inprogress'}; + apiMocks.getSessionInteractionMailbox.mockResolvedValueOnce({sessionId:'peer-mailbox', permissions:{revision:0,requests:[]}, userQuestions:{revision:1,questions:[{toolId:'question',sessionId:'peer-mailbox',dialogTurnId:'turn-0',modelRoundId:'round-q',questions:{questions:[{question:'Proceed?'}]},registeredAtMs:1}]}} as any); + apiMocks.subscribeRelaySession.mockImplementationOnce(async () => { + apiMocks.relayRecord?.({sessionId:'peer-mailbox',id:'turn/turn-0',revision:1,turn}); + apiMocks.relayReady?.({sessionId:'peer-mailbox',hasMore:false,oldestSeq:1,cursor:1}); + return 'mailbox-subscription'; + }); + await flowChatStore.loadRelaySessionHistory('peer-mailbox'); + const tools = () => flowChatStore.getState().sessions.get('peer-mailbox')!.dialogTurns.flatMap(row=>row.modelRounds.flatMap(round=>round.items)); + await vi.waitFor(()=>expect(tools().find(item=>item.id==='question')).toMatchObject({status:'waiting'})); + apiMocks.relayRecord?.({sessionId:'peer-mailbox',id:'turn/turn-0',revision:2,turn}); + expect(tools().find(item=>item.id==='question')).toMatchObject({status:'waiting'}); + apiMocks.relayRecord?.({sessionId:'peer-mailbox',id:'turn/turn-0',revision:3,turn:{...turn,status:'completed'}}); + expect(tools().find(item=>item.id==='question')).toBeUndefined(); + }); + + it('does not revive a question when a completed record races a mailbox response', async () => { + activateSurface(PEER_SURFACE_ID); seedSession('peer-mailbox-race'); + stateMachineManagerMock.getOrCreate.mockReturnValue({ acceptRuntimeStatus: vi.fn() }); + let resolveMailbox!: (value: any) => void; + apiMocks.getSessionInteractionMailbox.mockImplementationOnce(() => new Promise(resolve => {resolveMailbox=resolve;})); + const {modelRounds:_rounds,...stored}=createPersistedTurn(0,'peer-mailbox-race'); + apiMocks.subscribeRelaySession.mockImplementationOnce(async()=>{ + apiMocks.relayRecord?.({sessionId:'peer-mailbox-race',id:'turn/turn-0',revision:1,turn:{...stored,status:'inprogress'}}); + apiMocks.relayReady?.({sessionId:'peer-mailbox-race',hasMore:false,oldestSeq:1,cursor:1}); return 'race-subscription'; + }); + await flowChatStore.loadRelaySessionHistory('peer-mailbox-race'); + apiMocks.relayRecord?.({sessionId:'peer-mailbox-race',id:'turn/turn-0',revision:2,turn:stored}); + resolveMailbox({sessionId:'peer-mailbox-race',permissions:{revision:0,requests:[]},userQuestions:{revision:1,questions:[{toolId:'question',sessionId:'peer-mailbox-race',dialogTurnId:'turn-0',questions:{questions:[]},registeredAtMs:1}]}}); + await Promise.resolve(); await Promise.resolve(); + expect(flowChatStore.getState().sessions.get('peer-mailbox-race')!.dialogTurns[0].status).toBe('completed'); + expect(flowChatStore.getState().sessions.get('peer-mailbox-race')!.dialogTurns[0].modelRounds).toHaveLength(0); }); it('keeps each device sessions across a switch away and back', async () => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 0b2f0d2dc9..807d72aae2 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -37,6 +37,10 @@ import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { normalizeRemoteSessionScope } from '@/shared/utils/remoteSessionScope'; import { isSurfaceReconcileEnabled } from '@/infrastructure/peer-device/deviceSurfaceReconcile'; import { persistedMayWriteTurn } from '@/flow_chat/session-stream/SessionStream'; +import { SessionRecordReplica, type SessionRecord } from '@/flow_chat/session-stream/SessionRecordReplica'; +import { RelaySessionHistory } from '../services/RelaySessionHistory'; +import { stateMachineManager } from '../state-machine'; +import { ProcessingPhase, SessionExecutionState } from '../state-machine/types'; import { sessionCompletionReceipt } from '../utils/sessionCompletionReceipt'; import { isTurnAwaitingRecovery } from '../utils/interruptedTurnRecovery'; import { sessionActivityStore } from './sessionActivityStore'; @@ -1927,6 +1931,12 @@ interface SurfaceStateContainer { readonly fullHistoryProjectionApplyRequests: Set; readonly pendingRemoveSessionOptions: Map; readonly userQuestionSnapshotRevisions: Map; + readonly relayQuestionMailboxes: Map; + readonly relayMailboxVersions: Map; + readonly relayRecordVersions: Map; + readonly relayMailboxReads: Map>; + readonly relaySessionRecords: Map; + readonly relaySessionHistory: Map; } function createSurfaceStateContainer(surfaceId: DeviceSurfaceId): SurfaceStateContainer { @@ -1942,6 +1952,12 @@ function createSurfaceStateContainer(surfaceId: DeviceSurfaceId): SurfaceStateCo fullHistoryProjectionApplyRequests: new Set(), pendingRemoveSessionOptions: new Map(), userQuestionSnapshotRevisions: new Map(), + relayQuestionMailboxes: new Map(), + relayRecordVersions: new Map(), + relayMailboxVersions: new Map(), + relayMailboxReads: new Map(), + relaySessionRecords: new Map(), + relaySessionHistory: new Map(), }; } @@ -1974,6 +1990,10 @@ export class FlowChatStore { // Selecting another surface swaps the whole visible state; subscribers keep // rendering the previous device's sessions until they are told. onSurfaceActivated(() => { + for (const container of this.surfaceContainers.values()) { + for (const subscription of container.relaySessionHistory.values()) subscription.close(); + container.relaySessionHistory.clear(); + } this.notifyListeners(); }); } @@ -2952,6 +2972,14 @@ export class FlowChatStore { private clearRemovedSessionHistoryState(sessionIds: Iterable, reason: string): void { const removedSessionIds = new Set(sessionIds); + for (const id of removedSessionIds) { + this.activeSurface.relaySessionHistory.get(id)?.close(); + this.activeSurface.relaySessionHistory.delete(id); + this.activeSurface.relaySessionRecords.delete(id); + this.activeSurface.relayQuestionMailboxes.delete(id); + this.activeSurface.relayRecordVersions.delete(id); + this.activeSurface.relayMailboxVersions.delete(id); + } if (removedSessionIds.size === 0) { return; } @@ -3054,6 +3082,19 @@ export class FlowChatStore { } public async ensureSessionFullHistory(sessionId: string, reason: string): Promise { + if (getActiveSurfaceId() !== 'local') { + const scope = getActiveSurfaceScope(); + const surface = this.activeSurface; + await this.loadRelaySessionHistory(sessionId); + scope.assertCurrent('complete relay session history'); + const history = surface.relaySessionHistory.get(sessionId); + while (surface.state.sessions.get(sessionId)?.isPartial) { + if (!history || !await history.loadOlder()) break; + scope.assertCurrent('continue relay session history'); + } + scope.assertCurrent('finish relay session history'); + return surface.state.sessions.get(sessionId)?.isPartial === false; + } const session = this.state.sessions.get(sessionId); if (!session || session.historyState !== 'ready') { return false; @@ -3180,6 +3221,9 @@ export class FlowChatStore { targetOrdinal: number, options?: LoadSessionTurnWindowOptions, ): Promise { + if (getActiveSurfaceId() !== 'local') { + await this.ensureSessionFullHistory(sessionId, 'relay-turn-navigation'); + } const session = this.state.sessions.get(sessionId); const catalog = session?.turnCatalog?.sessionId === sessionId ? session.turnCatalog @@ -3248,6 +3292,27 @@ export class FlowChatStore { } } + if (getActiveSurfaceId() !== 'local') { + // Turn navigation reads the same canonical log replica. It never starts + // a second transcript restore/RPC path after a cache eviction. + const before = Math.max(0, Math.floor(options?.before ?? SESSION_TURN_WINDOW_DEFAULT_BEFORE)); + const after = Math.max(1, Math.floor(options?.after ?? SESSION_TURN_WINDOW_DEFAULT_AFTER)); + const startOrdinal = Math.max(0, normalizedTargetOrdinal - before); + const endOrdinalExclusive = Math.min(catalog.entries.length, normalizedTargetOrdinal + after); + const byId = new Map(session.dialogTurns.map(turn => [turn.id, turn])); + const turns = catalog.entries.slice(startOrdinal, endOrdinalExclusive) + .map(entry => entry.turnId ? byId.get(entry.turnId) : undefined); + if (turns.some(turn => !turn)) throw new Error('Relay turn catalog does not match its history'); + const range = this.cacheSessionLoadedTurnRange(sessionId, { + startOrdinal, endOrdinalExclusive, turns: turns as DialogTurn[], + lastAccessedAt: Date.now(), source: 'target', + }, catalog, normalizedTargetOrdinal); + return { status: 'ready', sessionId, targetOrdinal: normalizedTargetOrdinal, + targetTurnId: entry.turnId, navigationGeneration: generation, + isCurrent: this.isSessionTurnWindowRequestCurrent(sessionId, generation, normalizedTargetOrdinal, source), + cacheHit: true, range, catalog }; + } + const workspacePath = sessionProjectWorkspacePath(session); if (!workspacePath) { return { @@ -5795,6 +5860,179 @@ export class FlowChatStore { }); } + public async loadRelaySessionHistory(sessionId: string): Promise { + const surface = this.activeSurface; + if (surface.surfaceId === 'local') throw new Error('Local history is owned by the local runtime'); + if (!surface.state.sessions.has(sessionId)) throw new Error('Relay session shell is not loaded'); + let history = surface.relaySessionHistory.get(sessionId); + if (!history) { + history = new RelaySessionHistory(sessionId, + record => this.applyRelaySessionRecord(record), + ready => { + this.setState(previous => { + const session = previous.sessions.get(sessionId); + if (!session) return previous; + const sessions = new Map(previous.sessions); + sessions.set(sessionId, { ...session, historyState: 'ready', isHistorical: false, + // This surface owns presentation only. Execution-context loading + // remains on the runtime when it accepts the next command. + contextRestoreState: 'ready', + isPartial: ready.hasMore, loadedTurnCount: session.dialogTurns.length }); + return { ...previous, sessions }; + }); + if (!ready.hasMore) this.refreshRelayTurnCatalog(sessionId); + }, error => { + log.error('Relay session history failed', { sessionId, error }); + this.setSessionHistoryState(sessionId, 'failed'); + // Retrying opens a fresh replay owner; a failed owner must not turn + // the subsequent ready notification into a false successful page. + if (surface.relaySessionHistory.get(sessionId) === history) { + surface.relaySessionHistory.delete(sessionId); + history?.close(); + } + }, () => this.refreshRelayInteractionMailbox(sessionId)); + surface.relaySessionHistory.set(sessionId, history); + } + try { await history.open(); } + catch (error) { + if (surface === this.activeSurface) this.setSessionHistoryState(sessionId, 'failed'); + if (surface.relaySessionHistory.get(sessionId) === history) surface.relaySessionHistory.delete(sessionId); + throw error; + } + } + + private refreshRelayInteractionMailbox(sessionId: string): Promise { + const surface = this.activeSurface; + surface.relayMailboxVersions.set(sessionId, (surface.relayMailboxVersions.get(sessionId) ?? 0) + 1); + const existing = surface.relayMailboxReads.get(sessionId); + if (existing) return existing; + const scope = getActiveSurfaceScope(); + const read = (async () => { + while (scope.isCurrent() && surface === this.activeSurface) { + const mailboxVersion = surface.relayMailboxVersions.get(sessionId); + const recordVersion = surface.relayRecordVersions.get(sessionId) ?? 0; + const eventVersion = liveSessionInteractionStore.captureEventVersion(scope.surfaceId); + const mailbox = await agentAPI.getSessionInteractionMailbox(sessionId); + if (!scope.isCurrent() || surface !== this.activeSurface || !surface.state.sessions.has(sessionId) || mailbox.sessionId !== sessionId) return; + if (mailboxVersion !== surface.relayMailboxVersions.get(sessionId)) continue; + liveSessionInteractionStore.reconcilePermissionSnapshot(scope.surfaceId, sessionId, mailbox.permissions, eventVersion); + // Canonical terminal facts fence an overlapping mailbox response. Do + // not turn a busy token stream into repeated mailbox requests. + if (recordVersion !== (surface.relayRecordVersions.get(sessionId) ?? 0)) { + const turns = surface.state.sessions.get(sessionId)?.dialogTurns ?? []; + mailbox.userQuestions = { ...mailbox.userQuestions, questions: mailbox.userQuestions.questions.filter(question => { + const turn = turns.find(value => value.id === question.dialogTurnId); + const item = turns.flatMap(value => value.modelRounds.flatMap(round => round.items)) + .find(value => value.type === 'tool' && value.toolCall.id === question.toolId); + return (!turn || !['completed', 'cancelled', 'error'].includes(turn.status)) + && (!item || item.type !== 'tool' || !['completed', 'failed', 'cancelled', 'error'].includes(item.status)); + }) }; + } + surface.relayQuestionMailboxes.set(sessionId, mailbox.userQuestions); + this.reconcilePendingUserQuestions(sessionId, mailbox.userQuestions); + return; + } + })().finally(() => { surface.relayMailboxReads.delete(sessionId); }); + surface.relayMailboxReads.set(sessionId, read); + return read; + } + + public async loadOlderRelaySessionHistory(sessionId: string): Promise { + await this.loadRelaySessionHistory(sessionId); + const history = this.activeSurface.relaySessionHistory.get(sessionId); + if (!history) throw new Error('Relay session history is no longer active'); + return history.loadOlder(); + } + + private refreshRelayTurnCatalog(sessionId: string): void { + const session = this.state.sessions.get(sessionId); + if (!session || session.isPartial !== false) return; + const turns = session.dialogTurns.filter(turn => !isProvisionalUsageReportTurn(turn)); + // Optimistic submissions have no storage index yet. Do not invent one or + // confuse storage positions (which can contain gaps) with visible ordinals. + if (turns.some(turn => turn.storageTurnIndex === undefined)) return; + const entries = turns.map((turn, ordinal) => ({ + ordinal, storageTurnIndex: turn.storageTurnIndex!, turnId: turn.id, + preview: turn.userMessage.content, previewTruncated: false, + })); + const revision = `relay:${entries.map(entry => entry.turnId).join('|')}`; + this.updateAuthoritativeSessionTurnCatalog(sessionId, { + schemaVersion: 1, sessionId, revision, complete: true, + totalTurnCount: entries.length, entries, + }); + this.seedSessionHistoryLoadedRanges(sessionId); + } + + /** Both realtime delivery and historical pages use the same record owner. */ + public applyRelaySessionRecord(record: SessionRecord): void { + const surface = this.activeSurface; + if (surface.surfaceId === 'local') throw new Error('Relay record cannot target the local runtime'); + if (!surface.state.sessions.has(record.sessionId)) throw new Error('Relay session shell is not loaded'); + let replica = surface.relaySessionRecords.get(record.sessionId); + if (!replica) { + replica = new SessionRecordReplica(record.sessionId); + surface.relaySessionRecords.set(record.sessionId, replica); + } + const previousTurn = surface.state.sessions.get(record.sessionId)?.dialogTurns; + const change = replica.apply(record); + surface.relayRecordVersions.set(record.sessionId, (surface.relayRecordVersions.get(record.sessionId) ?? 0) + 1); + if (!change) return; + const incoming = change.turn + ? this.convertToDialogTurns([change.turn], { activeTurnId: change.turn.turnId })[0] + : null; + this.setState(previous => { + const session = previous.sessions.get(record.sessionId); + if (!session) return previous; + let dialogTurns = session.dialogTurns.filter(turn => turn.id !== change.turnId); + if (incoming) { + // A submission can be painted before the runtime allocates its turn ID. + // The stable user-message ID joins that optimistic row to its echo. + dialogTurns = dialogTurns.filter(turn => turn.userMessage.id !== incoming.userMessage.id); + dialogTurns.push(incoming); + dialogTurns.sort((a, b) => a.storageTurnIndex !== undefined && b.storageTurnIndex !== undefined + ? a.storageTurnIndex - b.storageTurnIndex : a.startTime - b.startTime); + } + const sessions = new Map(previous.sessions); + sessions.set(record.sessionId, { ...session, dialogTurns }); + return { ...previous, sessions }; + }); + const mailbox = surface.relayQuestionMailboxes.get(record.sessionId); + if (mailbox) { + const turns = this.state.sessions.get(record.sessionId)?.dialogTurns ?? []; + const questions = mailbox.questions.filter(question => { + const turn = turns.find(value => value.id === question.dialogTurnId); + if (turn && ['completed', 'cancelled', 'error'].includes(turn.status)) return false; + const item = turns.flatMap(value => value.modelRounds.flatMap(round => round.items)) + .find(value => value.type === 'tool' && value.toolCall.id === question.toolId); + return !item || item.type !== 'tool' || !['completed', 'failed', 'cancelled', 'error'].includes(item.status); + }); + const currentMailbox = { ...mailbox, questions }; + surface.relayQuestionMailboxes.set(record.sessionId, currentMailbox); + this.reconcilePendingUserQuestions(record.sessionId, currentMailbox); + } + const currentTurns = this.state.sessions.get(record.sessionId)?.dialogTurns ?? []; + if (previousTurn?.length !== currentTurns.length + || previousTurn?.some((turn, index) => turn.id !== currentTurns[index]?.id + || turn.storageTurnIndex !== currentTurns[index]?.storageTurnIndex)) { + this.refreshRelayTurnCatalog(record.sessionId); + } + const latest = currentTurns.at(-1); + const running = latest && !['completed', 'cancelled', 'error'].includes(latest.status); + const round = latest?.modelRounds.at(-1); + const pendingTools = running ? latest.modelRounds.flatMap(modelRound => modelRound.items) + .filter((item): item is FlowToolItem => item.type === 'tool' && item.status === 'pending_confirmation') + .map(item => item.toolCall.id) : []; + stateMachineManager.getOrCreate(record.sessionId).acceptRuntimeStatus({ + state: latest?.status === 'error' ? SessionExecutionState.ERROR + : running ? SessionExecutionState.PROCESSING : SessionExecutionState.IDLE, + turnId: latest?.id ?? null, roundId: round?.id ?? null, + phase: !running ? null : pendingTools.length ? ProcessingPhase.TOOL_CONFIRMING + : round?.items.some(item => item.type === 'tool' && item.status === 'running') + ? ProcessingPhase.TOOL_CALLING : ProcessingPhase.THINKING, + pendingTools, error: latest?.error ?? null, + }); + } + public updateDialogTurn( sessionId: string, dialogTurnId: string, @@ -8098,6 +8336,12 @@ export class FlowChatStore { finishDispatchObserverSkip('initial'); return; } + if (scope.surfaceId !== 'local') { + this.setSessionHistoryState(sessionId, 'hydrating'); + await this.loadRelaySessionHistory(sessionId); + scope.assertCurrent('load relay session history'); + return; + } // The caller remains authoritative for legacy and remote sessions. Only a // persisted dual-root binding may redirect history storage to the project // root; otherwise a stale in-memory execution path can cross workspaces. @@ -8721,10 +8965,9 @@ export class FlowChatStore { executionMs: tool.executionMs, timestamp: tool.startTime, status: isLiveTurn - ? normalizeLiveItemStatus( - tool.status, - tool.toolResult ? (tool.toolResult.success ? 'completed' : 'error') : 'running', - ) + ? tool.toolResult + ? (tool.toolResult.success ? 'completed' : 'error') + : normalizeLiveItemStatus(tool.status, 'running') : normalizeRecoveredToolStatus( tool.status, normalizedTurnStatus, diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 911f0712c6..1b97db6a4e 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -267,97 +267,26 @@ describe('peerInvokePriorityFor', () => { }); describe('PeerDeviceTransportAdapter queue', () => { - it('lets high-priority HostInvoke jump ahead of queued low-priority work', async () => { - const started: string[] = []; - const gate = createDeferred(); - - const deviceRpc = vi.fn(async (_target: string, commandJson: string) => { - const parsed = JSON.parse(commandJson) as { command: string }; - started.push(parsed.command); - if (parsed.command === 'git_is_repository') { - await gate.promise; - } - return JSON.stringify({ - resp: 'host_invoke_result', - ok: true, - value: parsed.command === 'git_is_repository' ? true : { ok: true }, - }); - }); - - const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 1); - await adapter.connect(); - - const low1 = adapter.request('git_is_repository', { request: { repositoryPath: '/a' } }); - const low2 = adapter.request('ssh_is_connected', { connectionId: 'ssh-x' }); - // Allow the first low request to claim the single concurrency slot. - await Promise.resolve(); - expect(started).toEqual(['git_is_repository']); - - const high = adapter.request('restore_session_view', { - request: { sessionId: 's1' }, - }); - await Promise.resolve(); - expect(adapter.getQueueDepthsForTest()).toEqual({ - high: 1, - normal: 0, - low: 1, - }); - - gate.resolve(); - await Promise.all([low1, high, low2]); - - expect(started).toEqual([ - 'git_is_repository', - 'restore_session_view', - 'ssh_is_connected', - ]); - }); - - it('reserves one concurrency slot for terminal work', async () => { - const started: string[] = []; - const firstLowGate = createDeferred(); - + it('orders one dispatch burst with control first without capping in-flight data requests', async () => { + const started: string[] = []; const gate = createDeferred(); const deviceRpc = vi.fn(async (_target: string, commandJson: string) => { - const parsed = JSON.parse(commandJson) as { command: string }; - started.push(parsed.command); - if (parsed.command === 'git_is_repository') { - await firstLowGate.promise; - } - return JSON.stringify({ - resp: 'host_invoke_result', - ok: true, - value: true, - }); + const parsed = JSON.parse(commandJson); started.push(parsed.command); + if (parsed.command === 'git_is_repository') await gate.promise; + return JSON.stringify({resp:'host_invoke_result',ok:true,value:true}); }); - - const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 2); - await adapter.connect(); - - const low1 = adapter.request('git_is_repository', { - request: { repositoryPath: '/a' }, - }); - const low2 = adapter.request('ssh_is_connected', { connectionId: 'ssh-x' }); - await Promise.resolve(); - expect(started).toEqual(['git_is_repository']); - - const terminal = adapter.request('terminal_write', { - request: { sessionId: 't1', data: 'pwd\r' }, - }); - await terminal; - expect(started).toEqual(['git_is_repository', 'terminal_write']); - - firstLowGate.resolve(); - await Promise.all([low1, low2]); - expect(started).toEqual([ - 'git_is_repository', - 'terminal_write', - 'ssh_is_connected', - ]); + const adapter = new PeerDeviceTransportAdapter('peer-1',deviceRpc); await adapter.connect(); + const background = Array.from({length:12},()=>adapter.request('git_is_repository',{request:{repositoryPath:'/runtime'}})); + const control = adapter.request('terminal_write',{request:{sessionId:'terminal',data:'x'}}); + await control; + expect(started[0]).toBe('terminal_write'); + expect(started.filter(command=>command==='git_is_repository')).toHaveLength(12); + expect(adapter.getActiveCountsForTest().low).toBe(12); + gate.resolve(); await Promise.all(background); }); it.each(['cancel_tool', 'start_user_question_interaction', 'submit_user_answers'])('dispatches %s immediately when the non-high slot is busy', async (command) => { - // Interactive control must use the reserved slot while a normal mutation - // is blocked, including stopping an unattended question deadline. + // Interactive control is independent of a blocked normal mutation, + // including stopping an unattended question deadline. const started: string[] = []; const normalGate = createDeferred(); @@ -374,11 +303,10 @@ describe('PeerDeviceTransportAdapter queue', () => { }); }); - const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 2); + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}); await adapter.connect(); - // One normal-priority mutation occupies the single non-high slot - // (maxConcurrent 2 → one slot reserved for high). + // A pending normal-priority mutation does not hold up control calls. const normal1 = adapter.request('set_config', { request: { path: 'a' } }); await Promise.resolve(); await Promise.resolve(); @@ -390,8 +318,7 @@ describe('PeerDeviceTransportAdapter queue', () => { low: 0, }); - // Interrupt fires while the normal slot is busy. It must start on the - // reserved high slot without waiting for the mutation to finish. + // Interrupt must start without waiting for the mutation to finish. const cancel = adapter.request(command, { request: { toolUseId: 'tu-1', toolId: 'tu-1', sessionId: 'session-1' } }); await Promise.resolve(); await Promise.resolve(); @@ -754,14 +681,14 @@ describe('PeerDeviceTransportAdapter disposal', () => { it('settles queued requests instead of dropping them', async () => { const gate = createDeferred(); const deviceRpc = vi.fn(() => gate.promise); - const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 1); + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}); await adapter.connect(); const inFlight = captureOutcome(adapter.request('get_opened_workspaces', { request: {} })); const queued = captureOutcome(adapter.request('list_persisted_sessions_page', { request: { workspacePath: '/repo' }, })); - expect(adapter.getQueueDepthsForTest().high).toBe(1); + expect(adapter.getQueueDepthsForTest().high).toBe(2); await adapter.disconnect(); await flushMicrotasks(); @@ -781,10 +708,11 @@ describe('PeerDeviceTransportAdapter disposal', () => { it('keeps concurrency accounting correct across disposal', async () => { const gate = createDeferred(); const deviceRpc = vi.fn(() => gate.promise); - const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 2); + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}); await adapter.connect(); const inFlight = captureOutcome(adapter.request('get_opened_workspaces', { request: {} })); + await Promise.resolve(); expect(adapter.getActiveCountsForTest()).toMatchObject({ total: 1, high: 1 }); await adapter.disconnect(); @@ -792,6 +720,7 @@ describe('PeerDeviceTransportAdapter disposal', () => { // The request still occupies its transport slot: rewriting the count to // zero underneath it made its later settle decrement a fresh count, so the // limiter believed it had a free slot it did not have. + await Promise.resolve(); expect(adapter.getActiveCountsForTest()).toMatchObject({ total: 1, high: 1 }); gate.resolve(hostInvokeOk([])); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 0deb757eb2..3f52c60f55 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -1,3 +1,4 @@ +import { DEFAULT_RPC_TIMEOUT_MS } from '../../../../../shared/relay-transport/RpcPolicy'; import { translateAgentIdentityCommand, translateAgentIdentityFields, translateAgentIdentityResponse } from '../../../../../shared/agent-harness/wire'; import { ITransportAdapter, type TransportRequestTiming } from './base'; import { TauriTransportAdapter } from './tauri-adapter'; @@ -253,10 +254,9 @@ export function peerInvokePriorityFor(command: string): PeerInvokePriority { return 'normal'; } -/** Max in-flight HostInvoke RPCs per controller. */ -export const PEER_HOST_INVOKE_MAX_CONCURRENT = 4; -export const PEER_READ_REQUEST_TIMEOUT_MS = 10_000; -export const PEER_MUTATION_REQUEST_TIMEOUT_MS = 30_000; +/** The shared account transport owns admission and acknowledged RPC deadlines. */ +export const PEER_READ_REQUEST_TIMEOUT_MS = DEFAULT_RPC_TIMEOUT_MS; +export const PEER_MUTATION_REQUEST_TIMEOUT_MS = DEFAULT_RPC_TIMEOUT_MS; export const PEER_READ_MAX_RETRIES = 4; export const PEER_IDEMPOTENT_MUTATION_MAX_RETRIES = 4; export const PEER_RETRY_BASE_DELAY_MS = 500; @@ -394,6 +394,7 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { /** Bumped on every bind/unbind, so a request can detect it outlived one. */ private surfaceBindingEpoch = 0; private activeCount = 0; + private pumpScheduled = false; private readonly activeByPriority: Record = { high: 0, normal: 0, @@ -411,7 +412,6 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { private readonly targetDeviceId: string, private readonly deviceRpc: DeviceRpcFn, private hooks: PeerDeviceTransportHooks = {}, - private readonly maxConcurrent: number = PEER_HOST_INVOKE_MAX_CONCURRENT, ) { this.surfaceId = surfaceIdForDevice(targetDeviceId); } @@ -695,7 +695,10 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { }; this.pending.add(entry); this.queues[priority].push(entry); - this.pump(); + if (!this.pumpScheduled) { + this.pumpScheduled = true; + queueMicrotask(() => { this.pumpScheduled = false; this.pump(); }); + } }); } @@ -703,7 +706,7 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { if (this.disposed) { return; } - while (this.activeCount < this.maxConcurrent) { + while (true) { const next = this.dequeueNext(); if (!next) { return; @@ -723,30 +726,9 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { } private dequeueNext(): QueuedPeerRequest | undefined { - // Prefer high, then normal. Allow low only when nothing higher is waiting, - // so background git/SSH cannot monopolize slots after a hydrate burst. - if (this.queues.high.length > 0) { - return this.queues.high.shift(); - } - const nonHighConcurrencyLimit = - this.maxConcurrent > 1 ? this.maxConcurrent - 1 : 1; - const activeNonHigh = this.activeByPriority.normal + this.activeByPriority.low; - if ( - this.queues.normal.length > 0 && - activeNonHigh < nonHighConcurrencyLimit - ) { - return this.queues.normal.shift(); - } - // Keep one transport slot available for future interactive work. Without - // this, two slow background RPCs can make terminal input appear frozen. - const lowConcurrencyLimit = this.maxConcurrent > 1 ? this.maxConcurrent - 1 : 1; - if ( - this.queues.low.length > 0 && - this.activeByPriority.low < lowConcurrencyLimit - ) { - return this.queues.low.shift(); - } - return undefined; + // Order the current dispatch burst without a second in-flight limit. + // Slow data requests cannot hold control calls behind adapter-owned slots. + return this.queues.high.shift() ?? this.queues.normal.shift() ?? this.queues.low.shift(); } private async invokePeerCommand( diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 36d1fd8897..bf21f249af 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:df958db3f7fb0f53" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:708b24394aee421b" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -19,11 +19,14 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "account_github_poll", "account_github_start", "account_list_devices", + "account_load_older_session", "account_login", "account_logout", "account_online_devices", "account_status", + "account_subscribe_session", "account_token_expired", + "account_unsubscribe_session", "appearance_market_browse", "appearance_market_download_release", "appearance_market_get_listing", @@ -92,6 +95,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "install_update", "list_agent_companion_pets", "load_insights_report", + "local_file_download", "mark_announcement_seen", "mark_openbitfun_control_surface_ready", "mark_openbitfun_control_surface_unready", diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 62da795e0b..f91e2363fc 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -1077,6 +1077,10 @@ export class AgentAPI { } } + async getSessionInteractionMailbox(sessionId: string): Promise { + return api.invoke('get_session_interaction_mailbox', { request: { sessionId } }); + } + async restoreSessionView( sessionId: string, workspacePath: string, diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index 1d4f32c3e2..8ea82932c2 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -3,6 +3,7 @@ */ import { getTransportAdapter } from '../adapters'; +import { api } from './ApiClient'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('RemoteConnectAPI'); @@ -417,6 +418,37 @@ class RemoteConnectAPIService { } } + onSessionGap(callback: (event: { sessionId: string; reason: string }) => void): () => void { + return api.listen('relay://session-gap', callback); + } + + onSessionRecord(callback: (event: unknown) => void): () => void { + return api.listen('session-record', callback); + } + + onSessionInteractionChanged(callback: (event: { sessionId: string; userQuestionsRevision: number }) => void): () => void { + return api.listen('session-interaction-changed', callback); + } + + onSessionReady(callback: (event: { sessionId: string; hasMore: boolean; oldestSeq: number; cursor: number }) => void): () => void { + return api.listen('relay://session-ready', callback); + } + + onSessionSyncError(callback: (event: { sessionId: string; targetDeviceId: string; message: string }) => void): () => void { + return api.listen('account://session-sync-error', callback); + } + + async loadOlderSession(subscriptionId: string): Promise { + return this.adapter.request('account_load_older_session', { request: { subscription_id: subscriptionId } }); + } + + async subscribeSession(targetDeviceId: string, sessionId: string): Promise { + return this.adapter.request('account_subscribe_session', { request: { target_device_id: targetDeviceId, session_id: sessionId } }); + } + async unsubscribeSession(subscriptionId: string): Promise { + return this.adapter.request('account_unsubscribe_session', { request: { subscription_id: subscriptionId } }); + } + async accountDeviceRpc( targetDeviceId: string, commandJson: string, diff --git a/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx b/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx index b48c6e5b0a..0ad844143c 100644 --- a/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx +++ b/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx @@ -100,17 +100,17 @@ function LaunchAtLoginSetting() { return null; } - if (loading) { - return ; - } - - if (loadFailed) { + if (loading || loadFailed) { return ( - void loadData()} - /> + + + ); } @@ -197,17 +197,17 @@ function AutoUpdateSetting() { return null; } - if (loading) { - return ; - } - - if (loadFailed) { + if (loading || loadFailed) { return ( - void loadData()} - /> + + + ); } @@ -292,17 +292,17 @@ function PreventSleepSetting() { return null; } - if (loading) { - return ; - } - - if (loadFailed) { + if (loading || loadFailed) { return ( - void loadData()} - /> + + + ); } diff --git a/src/web-ui/src/infrastructure/peer-device/README.md b/src/web-ui/src/infrastructure/peer-device/README.md index eef7286dc0..d3dcd52ef1 100644 --- a/src/web-ui/src/infrastructure/peer-device/README.md +++ b/src/web-ui/src/infrastructure/peer-device/README.md @@ -3,39 +3,31 @@ Controller-side React/transport layer for Peer Device Mode. Architecture: [`docs/architecture/peer-device-mode.md`](../../../../../docs/architecture/peer-device-mode.md). -## Migrating to the Session Projection contract - -The invariants below are pairwise rules between writers that carry no shared -position. They are being replaced by one contract — -[`docs/architecture/session-projection.md`](../../../../../docs/architecture/session-projection.md) -— under which a write is admitted by its position in the order rather than by -what it would do to painted content. **This list shrinking is the measure of -that migration**; a change that adds a rule here is going the wrong way. - -Already owned by the contract (`flow_chat/session-stream/`): - -- The stream position, the delivery gap, and the attach fence in invariant 12. - `runtimeSessionEventGate` is now an adapter over `SessionStream`, not a - second owner of that state. -- Surface-scoped Session identity in invariant 0: a stream is keyed by - `(DeviceSurfaceId, SessionId)` by construction. - -Also owned: which read may write a Turn. `replaceRunningSnapshot` is gone — -the persisted record (snapshot merge *and* disk hydrate) may not write the Turn -the runtime stream owns, and the Host's declared executing Turn is part of that -ownership. `snapshotDropsProjectedTurnContent` and -`isRunningSnapshotForwardProgress` survive inside `persistedReadMayReplaceTurn` -for the two gaps the contract does not yet close (a Host with no runtime -projection, and a partial history read); see the contract doc before touching -them. - -Still to migrate, in order: the interaction mailbox, then history positions. +## Relay session ownership + +The selected Desktop or CLI owns execution, persisted records, workspaces, PTYs, +and blocking interactions. A controller owns presentation and drafts. Its lifetime +never determines whether an accepted turn continues running. + +`RelaySessionHistory` owns one subscription per visible surface/session. +`SessionRecordReplica` applies both cached/replayed history and live canonical +records using stable IDs, revisions and tombstones. Following Happy's sync owner, +the latest page paints first; older pages share one in-flight reader and are +prefetched with a yield between pages. Receive cursors advance only after applying +records, never from a send acknowledgement. Transport reconnect resumes that same +subscription. Local Runtime projection machinery is not a second Relay content +writer: peer token/body events must not overwrite canonical records. + +The independent Runtime interaction mailbox restores unanswered questions and +permissions without fetching the transcript. Keep its revisions and surface fence +separate from history pagination. Neither a WebSocket connection nor an event +listener alone recovers an interaction emitted before attachment. ## Invariants (do not regress) 0. **A surface switch is a view change, not a teardown.** Attachments and the - rendered surface are independent: peers stay attached (and keep running our - work) after the UI moves elsewhere, and `switchToLocal` is a switch, not a + rendered surface are independent: peers stay attached after the UI moves elsewhere; accepted work + remains owned by the runtime even when every controller disconnects, and `switchToLocal` is a switch, not a disconnect. Two consequences: - Everything in `resetProductSurface()` must be **frontend-only**. @@ -69,22 +61,6 @@ Still to migrate, in order: the interaction mailbox, then history positions. must not fire while a Runtime attach is resetting the state machine to IDLE. Any new await inside `startTurn` widens that window and must keep the same scope checkpoint. - - **Reconciliation repairs a projection, never guts it.** The wholesale - replace path (`replaceRunningSnapshot`) skips the forward-progress - comparator so a settled turn can adopt the host's copy. A turn keeps its - identity and user message independently of its rounds, so a windowed or - not-yet-checkpointed snapshot can name the turn while carrying none of its - work — and a first-time surface projection has no state machines, so - *every* turn reads as idle and qualifies for replacement. - That combination erased the whole response and left only the prompt on - screen (regression: 2026-08-15). `snapshotDropsProjectedTurnContent` gates - the replace. Equal item counts are not sufficient: text and thinking must - preserve prefix progress, and completed tool results may not disappear. - Recognized client-derived display cards are carried across the terminal - host-tail repair instead of blocking authoritative text reconciliation. - The refresh loop still re-attaches an executing turn when a - snapshot is refused, or a rebuilt surface would render it as static - history. - **Surface-scoped events must stay routed by source device.** Background attachments mean several agent streams share one event bus. The controller tags re-emitted peer payloads with `__openbitfunSourceDeviceId` @@ -100,22 +76,18 @@ Still to migrate, in order: the interaction mailbox, then history positions. rebind render; otherwise React can pair A's old `turnId` with B's Session for one render, including when both devices use the same Session id. -1. **Cloud session/turn APIs stay on the controller** (`LOCAL_ONLY` in - `peer-device-adapter.ts`). Peer history comes from HostInvoke - (`restore_session_view`, list sessions, …), not from - `account_fetch_session_turns`. +1. **Relay subscriptions stay on the controller.** The account subscription + commands attach to the selected runtime's encrypted session log. Product + commands such as listing sessions execute on that runtime through HostInvoke. + Never route a controller's subscription back onto the peer. -2. **Fail-closed cloud import must skip Peer Mode.** - `FlowChatStore.loadSessionHistory` calls `accountFetchSessionTurns` and - throws on failure for incomplete relay imports. In Peer Mode that command is - paused — **skip the call** when `isPeerDeviceModeActive()` is true, then - restore via the peer. Do not reintroduce “throw on any fetch error” without - a Peer Mode gate (regression: 2026-07-19 session harden commit). +2. **Peer history has one owner.** `loadSessionHistory` uses + `RelaySessionHistory`; it must not fall back to `restore_session_view`, cloud + imports, or a local transcript after a Relay failure. -3. **Backend peer pauses must soft-succeed for hydrate paths.** Prefer - `Ok(false)` / empty success over hard `Err` for - `account_fetch_session_turns` / `account_auto_sync` while the controller is - in Peer Mode, so accidental callers do not abort UI restore. +3. **Failures stay explicit and recoverable.** Failed reads leave loading state, + retain previously rendered records, and expose retry. An empty success is not + a substitute for an unavailable runtime or unsupported capability. 4. **Clear `FlowChatManager.currentWorkspacePath` on peer switch.** Stale controller paths (e.g. Windows) must not be reused for `create_session` on a @@ -159,118 +131,26 @@ Still to migrate, in order: the interaction mailbox, then history positions. the tree and reject traversal-like entry names. 11. **Terminal traffic stays interactive and observable.** All `terminal_*` - commands are high priority, low-priority polling leaves one transport slot - available, and both local and SSH-backed PTY events on B must fan out to A. + commands are high priority within each submission burst and use the shared + transport admission policy. Both local and SSH-backed PTY cursors on B must + notify A without placing raw output into an unbounded event queue. Remote `SIGINT` / `SIGTSTP` map to PTY control bytes instead of silently succeeding without affecting the process. -12. **Active chat attaches to a Runtime-owned Turn projection.** DeviceEvent is - the low-latency path, not the owner of current-Turn state. Desktop and CLI - Peer Hosts materialize eligible current Turns after their ordered delivery - boundary and expose them - from `restore_session_view` as `runtimeEventSnapshot` with a per-Session - cursor and Runtime-process `streamId`. While restore is in flight, - `runtimeSessionEventGate` queues live events by - `(DeviceSurfaceId, SessionId)`; replay starts from an empty active-Turn base, - then the gate drops cursor-covered events and releases newer events in - order. Held events pass through the same ordering and ownership checks as - live events. A detected gap or rejected projection remains stale through - subsequent deliveries until a successful read repairs it. Since delivery - may already have advanced beyond the missing event, gap repair uses the - journal prefix; a suffix after that cursor cannot fill the hole. - Never compare cursors across different `streamId` values. This is - gated on - `isSurfaceReconcileEnabled()`, **not** on Peer Mode: once a window has - switched surface, a turn left running on the local device also needs the - same attach, because its live events were dropped by surface routing while - another device was rendered. Attach is requested as soon as a Session on - this surface has a usable live projection: `historyState === 'ready'` - after disk hydrate, or `historyState === 'new'` for a session created in - this window (those never become `ready` via hydrate). The gate is per - `(DeviceSurfaceId, SessionId)`, not per the focused tab — a dropped-event - refresh must still attach a background session that kept running here. - The 3s loop is only a liveness retry and an older-Host fallback. The Peer Host must - overlay its live in-memory session state on the persisted view; otherwise - an in-progress turn is normalized as interrupted history and later chunks - are dropped by the controller state machine. Surface epoch checks reject a - restore from a device no longer rendered. Older hosts may omit the Runtime - projection; their persisted snapshot must still never overwrite newer live - content. - - **Delivery is not acceptance, and persist is not the current Turn.** A - live event that the state machine drops still advances the gate cursor. - Mark that projection stale so the next attach replays the journal instead - of treating the cursor as current and leaving in-progress tool cards - frozen. `finish()` may cover live events only after replay (or an - equivalent apply) proves the painted tools/text have caught up with the - Host journal — a matching cursor is not that proof. Overlapping attach - transfers the fence instead of draining it onto a state machine that is - about to reset. A 3s `staleOnly` tick, a hidden document, or a - `FINISHING` machine must not skip repair while `hasGap` is set; TextChunk - heartbeats are not evidence that a dropped ToolEnd was applied. - `loadSessionHistory` and `refreshPeerSessionSnapshot` both read - `runtimeEventSnapshot`: the persisted checkpoint of an executing Turn is - only identity, never the painted rounds. A session-object identity change - during restore must still return the journal — hiding it used to abort - attach and freeze the receiver while the Host kept streaming. - A CLI Peer Host still filters Host-local turns with `owns()`; that is a - CLI Host limitation, not a reason for a Desktop receiver to freeze. - - **Terminal delivery is not the durability fence.** The Host may publish - `DialogTurnCompleted` before its complete generation journal has been - committed. After the commit it publishes `SessionHistoryChanged`; local and - Peer surfaces then reconcile the terminal tail from that Host. The - controller must not echo a shorter painted prefix over the settled record, - even when the prefix has the same round and item counts. - - **The subscription and the attach loop must never be able to disable each - other.** The agentic subscription is this window's only live view of a - running Turn, and a surface switch tears it down. Rebuilding it used to be - a side effect of `FlowChatManager.initialize()`, which a newer switch is - allowed to supersede — so a rapid switch could leave the window with no - subscription and nothing to retry. The attach loop then *refused to run - while the subscription was down*, disabling the only path that could repair - it, and the chat froze permanently with no live output and no snapshot - repair (regression: 2026-08-16). `FlowChatManager` therefore re-arms on - `onSurfaceActivated` and retries a failed start on its own, the attach loop - treats a dead subscription as a reason to reconcile **and** re-arm rather - than to bail, and callers of `initialize()` must not report a superseded - bootstrap as a product failure. Any new gate on subscription readiness has - to keep both halves independently recoverable. - **Controller presence is not Turn ownership.** A controller lease gates - submission and interaction responses, but once a Peer Host accepts a Turn, - the Host keeps executing and materializing it through a zero-controller - device-switch interval. Detach/presence loss must not cancel that Turn; - only an actual host event-stream continuity failure may fail it closed. - **Blocking interactions are owner mailboxes, not one-shot UI events.** The - Runtime retains native `AskUserQuestion` and interactive permission - requests until answer/cancel/drop, and `restore_session_view` returns their - additive, revisioned `interactionSnapshot` from both Desktop and CLI Peer - Hosts. Keep its frontend projection per Surface, fence it with the captured - Surface epoch and newer event state, replay it after the Turn projection, - and use it only to reconstruct UI in the owning Turn/round. Reattachment - must never restart or cancel the - running Session. Older peers may omit the field; absence is not an empty - authoritative mailbox. Answering an `AskUserQuestion` is separately gated - by `peer_mode_ping.capabilities.user_question_response`: legacy Desktop - hosts already support the command, while legacy CLI hosts must show an - explicit unsupported/upgrade state. Current controllers include the owning - Session id with the mutation and hosts reject stale cross-Session answers; - newer hosts still accept the legacy Tool-id-only form. A - question interaction may disable its unattended deadline through the - session-scoped `start_user_question_interaction` command, gated by - `user_question_interaction_v1`. It is idempotent and does not answer the - question. Older peers keep their answer path and display an explicit warning - if interaction cannot stop the deadline. The Runtime snapshot's additive - `interactionStarted` flag survives controller reattachment; changing devices - does not restart a timer. Activity and answer commands use the reserved - high-priority control slot so ordinary HostInvoke work cannot queue them - past the deadline. Trusted execution lineage registers parent Session - controllers for child questions; reattachment projects the same pending - question into the parent's turn without granting unrelated Sessions access. - Any new - interaction that can suspend execution is incomplete until its owner - exposes equivalent replayable attach state and a negotiated response path. +12. **Canonical records and control events have different owners.** Completed + semantic text/tool blocks enter the runtime's durable session log. Replayed + pages and live records use the same replica, revision rules and tombstones. + A disconnected controller resumes from its receive cursor and deduplicates + records; it does not reconstruct a running turn from a second full snapshot. + Lifecycle and approval events may update controls, but raw token/tool-body + events cannot overwrite the canonical content owner. + + `get_session_interaction_mailbox` returns the Runtime's revisioned pending + questions and permissions on attach/reconnect. Apply it with a captured + Surface scope and newer-event fence. Answers carry the owning session and + request/tool identity; the runtime validates them. A controller switch does + not restart a question deadline or cancel an accepted turn. Any interaction + that can suspend execution needs a recoverable mailbox and response path. 13. **Weak links use bounded, idempotency-aware recovery.** Presence gaps and product RPC timeouts keep an attached peer's surface selected and request @@ -284,27 +164,20 @@ Still to migrate, in order: the interaction mailbox, then history positions. surface epoch, discarding state, or resubmitting work. Only explicit disconnect or logout disposes an attachment. - Default Peer HostInvoke concurrency is four with one slot reserved from normal/low - traffic. Read-only commands have a real 10s deadline and four - exponential-backoff retries. Mutations have a 30s deadline and are never - replayed automatically without an idempotency contract. Dialog submission - is the explicit exception: `start_dialog_turn` and - `start_acp_dialog_turn` reuse `(sessionId, turnId)`, and the host - coalesces/caches duplicate execution attempts. The controller must observe - the matching `idempotent_dialog_submit` capability in `peer_mode_ping` - before replaying either command; an older host stays single-shot. A failed - session list must leave its loading state and offer an explicit retry. - -14. **Catalog-backed history stays windowed across the peer boundary.** - `restore_session_view` returns the compact `turnCatalog` plus the restored - tail; the controller must not follow it with an unconditional full restore. - `load_session_turn_window` is a high-priority, retryable read and carries - the same session/workspace scope as restore. Sequential history scrolling - and turn-rail navigation request bounded windows. Search and older Hosts - that reject the window command use the shared explicit full-history ensure - fallback. Targeted rollback is separately capability-gated and never falls - back to a controller-local or numeric rollback path. Never include catalog - preview text in Peer request/response logs. + Request admission and byte budgets belong to the shared transport owner. + Avoid independent UI slot limits or polling loops that compete with recovery. + Mutations are not automatically replayed without an idempotency contract. + Dialog submissions reuse `(sessionId, turnId)` so an ambiguous acknowledgement + does not create another turn. A failed session list must leave loading state + and offer an explicit retry. + +14. **History navigation uses the same canonical replica.** Latest-page loading, + older-page prefetch and explicit full-history requests share the subscription. + The controller builds the turn catalog from canonical turn identities after + older pages are complete, preserving actual storage indices. Turn navigation + must not introduce a second `restore_session_view` or window-RPC content owner. + Rollback remains an explicit runtime operation; it never falls back to a + controller-local mutation. Never log catalog preview text. 15. **Git ownership trust is read on the peer, granted at the machine.** `git_get_repository_trust` is a read-only probe and routes to the peer @@ -339,12 +212,6 @@ Still to migrate, in order: the interaction mailbox, then history positions. controller fails before RPC. Never omit the files, fall back to a local Agent, or run the prompt without its declared context. -## Related account-login guards - -Incomplete login (cloud vs local settings choice) must not persist a session -until `account_finalize_login`. See comments on -`PENDING_SYNC_CHOICE` in `src/apps/desktop/src/api/remote_connect_api.rs`. - 18. **WSL belongs to the selected Windows host.** `wsl_workspaces_v1` gates `ssh_list_wsl_distributions` and SSH profile requests with a `wsl` target before RPC. Missing capability means unsupported, including older Desktop diff --git a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts index 626980400a..6aac0f41d1 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts @@ -33,6 +33,10 @@ export const PEER_EVENT_WRAPPED_PAYLOAD_KEY = '__openbitfunSourcePayload'; */ const SURFACE_SCOPED_EVENTS = new Set([ 'terminal_event', + 'relay://session-gap', + 'relay://session-ready', + 'session-record', + 'session-state', 'file-system-changed', 'permission://event', 'account://settings-applied', diff --git a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts index 654edc3fa5..fe1e8ff7f0 100644 --- a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts +++ b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { ConnectionResult, RemoteConnectStatus, RemoteConnectionMethod } from '../api/service-api/RemoteConnectAPI'; import { remoteNetworkMethod, selectRemoteNetworkConnection, invitationRelayUrl } from './remoteConnectionState'; -const official = 'https://remote.openbitfun.com/v/1.0.0'; +const official = 'https://remote.openbitfun.com/v/1.0.1'; const lan = 'http://192.168.1.2:9700'; const method = (url: string): RemoteConnectionMethod => url === official ? 'openbitfun_server' : { lan: { ip: '192.168.1.2' } }; const status = (url: string): RemoteConnectStatus => ({ diff --git a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts index 4073443d1a..db8b5f30c0 100644 --- a/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts +++ b/src/web-ui/src/infrastructure/remote-connect/remoteConnectionState.ts @@ -1,7 +1,7 @@ import type { ConnectionResult, RemoteConnectionMethod, RemoteConnectStatus } from '../api/service-api/RemoteConnectAPI'; export type RemoteNetworkMethod = 'lan' | 'openbitfun_server'; -export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/v/1.0.0'; +export const OFFICIAL_RELAY_URL = 'https://remote.openbitfun.com/v/1.0.1'; export function remoteNetworkMethod(method: RemoteConnectionMethod | null | undefined): RemoteNetworkMethod | null { if (typeof method === 'object' && method !== null && 'lan' in method) return 'lan'; diff --git a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts index 84507a594f..6ccc489234 100644 --- a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts +++ b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { decodeBase64FileChunk, + writeAllToLocalFile, + readPeerFileChunks, isSafePeerTransferEntryName, joinWorkspaceTargetPath, normalizeClipboardLocalPaths, @@ -95,3 +97,64 @@ describe("workspaceFileTransfer", () => { ).toBe("/tmp/project/src"); }); }); + +const native = vi.hoisted(() => ({ request: vi.fn() })); +vi.mock("@/infrastructure/api/adapters", () => ({ getTransportAdapter: () => native, createTransportAdapter: () => native })); + +describe("atomic peer download sink", () => { + beforeEach(() => { + vi.resetAllMocks(); + native.request.mockResolvedValue({ id: 7 }); + }); + async function* content() { yield new Uint8Array([1, 2, 3]); } + it("commits only after the full stream is validated", async () => { + const progress = vi.fn(); + await writeAllToLocalFile("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/external/existing.bin", content(), progress); + expect(native.request.mock.calls).toEqual([ + ["local_file_download", {request: {action: "begin", destination: "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/external/existing.bin"}}], + ["local_file_download", {request: {action: "write", id: 7, offset: 0, bytes: [1, 2, 3]}}], + ["local_file_download", {request: {action: "finish", id: 7, size: 3}}], + ]); + expect(progress).toHaveBeenCalledExactlyOnceWith(3); + }); + it("cancels staging when the remote stream fails", async () => { + async function* broken() { yield new Uint8Array([1]); throw new Error("revision changed"); } + await expect(writeAllToLocalFile("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/external/existing.bin", broken(), vi.fn())).rejects.toThrow("revision changed"); + expect(native.request.mock.calls.map((call) => call[1].request.action)).toEqual(["begin", "write", "cancel"]); + }); + it("preserves replacement errors even if cleanup reports the resource closed", async () => { + native.request.mockImplementation(async (_command, {request}) => { + if (request.action === "finish") throw new Error("permission denied"); + if (request.action === "cancel") throw new Error("already closed"); + return { id: 7 }; + }); + await expect(writeAllToLocalFile("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/external/existing.bin", content(), vi.fn())).rejects.toThrow("permission denied"); + }); +}); + +describe("fixed peer download identity", () => { + it("retains the workspace and SSH identity on every request", async () => { + const requestPeerCommand = vi.fn() + .mockResolvedValueOnce({ resp: "file_info", size: 2 }) + .mockResolvedValueOnce({ resp: "file_chunk", offset: 0, chunk_size: 1, total_size: 2, chunk_base64: "AQ==", revision: "r1" }) + .mockResolvedValueOnce({ resp: "file_chunk", offset: 1, chunk_size: 1, total_size: 2, chunk_base64: "Ag==", revision: "r1" }); + const adapter = { requestPeerCommand } as unknown as Parameters[0]; + const bytes: number[] = []; + for await (const chunk of readPeerFileChunks(adapter, "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/workspace/file", vi.fn(), {workspace_path: "/workspace", remote_connection_id: "saved-ssh"})) bytes.push(...chunk); + expect(bytes).toEqual([1, 2]); + for (const [request] of requestPeerCommand.mock.calls) { + expect(request).toMatchObject({path: "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/workspace/file", workspace_path: "/workspace", remote_connection_id: "saved-ssh", session_id: null}); + } + }); + + it("rejects changed revisions before installing mixed content", async () => { + const requestPeerCommand = vi.fn() + .mockResolvedValueOnce({ resp: "file_info", size: 2 }) + .mockResolvedValueOnce({ resp: "file_chunk", offset: 0, chunk_size: 1, total_size: 2, chunk_base64: "AQ==", revision: "r1" }) + .mockResolvedValueOnce({ resp: "file_chunk", offset: 1, chunk_size: 1, total_size: 2, chunk_base64: "Ag==", revision: "r2" }); + const adapter = { requestPeerCommand } as unknown as Parameters[0]; + const stream = readPeerFileChunks(adapter, "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/workspace/file", vi.fn(), {workspace_path: "/workspace"}); + await stream.next(); + await expect(stream.next()).rejects.toThrow("changed during download"); + }); +}); diff --git a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts index 607bc30634..df1a6a24fb 100644 --- a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts +++ b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts @@ -5,7 +5,7 @@ import { PhysicalPosition } from "@tauri-apps/api/dpi"; import { sshApi } from "@/features/ssh-remote/sshApi"; import { workspaceAPI } from "@/infrastructure/api"; -import { getTransportAdapter } from "@/infrastructure/api/adapters"; +import { createTransportAdapter, getTransportAdapter } from "@/infrastructure/api/adapters"; import { PeerDeviceTransportAdapter, type PeerDeviceCommandResponse, @@ -64,6 +64,7 @@ interface PeerFileChunkResponse extends PeerDeviceCommandResponse { chunk_size: number; total_size: number; mime_type: string; + revision: string; } interface PeerDownloadEntry { @@ -98,41 +99,41 @@ function currentPeerAdapter(): PeerDeviceTransportAdapter | null { return adapter instanceof PeerDeviceTransportAdapter ? adapter : null; } -async function writeAllToLocalFile( +export async function writeAllToLocalFile( destinationPath: string, chunks: AsyncIterable, onChunkWritten: (bytes: number) => void, ): Promise { - const { open } = await import("@tauri-apps/plugin-fs"); - const file = await open(destinationPath, { - write: true, - create: true, - truncate: true, - }); + // The local sink must remain reachable for cleanup after peer logout. + const adapter = createTransportAdapter("tauri"); + const send = (request: Record) => + adapter.request<{id: number}>("local_file_download", { request }); + const { id } = await send({ action: "begin", destination: destinationPath }); + let offset = 0; try { for await (const chunk of chunks) { - const written = await file.write(chunk); - if (written !== chunk.byteLength) { - throw new Error( - `Incomplete local file write (${written}/${chunk.byteLength} bytes)`, - ); - } - onChunkWritten(written); + await send({ action: "write", id, offset, bytes: Array.from(chunk) }); + offset += chunk.byteLength; + onChunkWritten(chunk.byteLength); } - } finally { - await file.close(); + await send({ action: "finish", id, size: offset }); + } catch (error) { + await send({ action: "cancel", id }).catch(() => undefined); + throw error; } } -async function* readPeerFileChunks( +export async function* readPeerFileChunks( adapter: PeerDeviceTransportAdapter, sourcePath: string, onFileSize: (size: number) => void, + identity: {workspace_path: string; remote_connection_id?: string}, ): AsyncGenerator { const info = await adapter.requestPeerCommand({ cmd: "get_file_info", path: sourcePath, session_id: null, + ...identity, }); if (info.resp !== "file_info" || !Number.isSafeInteger(info.size) || info.size < 0) { throw new Error(`Invalid peer file info response for '${sourcePath}'`); @@ -140,11 +141,13 @@ async function* readPeerFileChunks( onFileSize(info.size); let offset = 0; + let revision: string | undefined; while (offset < info.size) { const response = await adapter.requestPeerCommand({ cmd: "read_file_chunk", path: sourcePath, session_id: null, + ...identity, offset, limit: PEER_FILE_CHUNK_BYTES, }); @@ -159,6 +162,10 @@ async function* readPeerFileChunks( ) { throw new Error(`Invalid peer file chunk response for '${sourcePath}'`); } + if (typeof response.revision !== "string" || !response.revision || (revision !== undefined && revision !== response.revision)) { + throw new Error("Peer file changed during download"); + } + revision = response.revision; const bytes = decodeBase64FileChunk(response.chunk_base64); if ( bytes.byteLength !== response.chunk_size || @@ -173,21 +180,10 @@ async function* readPeerFileChunks( return info.size; } -async function* readPeerSshFile( - sourcePath: string, - remoteConnectionId: string | undefined, -): AsyncGenerator { - const content = await workspaceAPI.readFileContent( - sourcePath, - "base64", - remoteConnectionId, - ); - yield decodeBase64FileChunk(content); -} - async function collectPeerDirectoryEntries( sourceDirectory: string, destinationDirectory: string, + remoteConnectionId?: string, ): Promise { const { mkdir } = await import("@tauri-apps/plugin-fs"); const pending = [{ source: sourceDirectory, destination: destinationDirectory }]; @@ -196,7 +192,7 @@ async function collectPeerDirectoryEntries( while (pending.length > 0) { const current = pending.shift()!; await mkdir(current.destination, { recursive: true }); - const children = await workspaceAPI.getDirectoryChildren(current.source); + const children = await workspaceAPI.getDirectoryChildren(current.source, remoteConnectionId); for (const child of children) { if (!isSafePeerTransferEntryName(child.name)) { throw new Error(`Unsafe peer file name: '${child.name}'`); @@ -230,8 +226,14 @@ async function downloadPeerWorkspacePathToDisk( isDirectory: boolean, onProgress: (state: TransferProgressState | null) => void, ): Promise { + if (!workspace?.rootPath) throw new Error("A fixed peer workspace is required for download"); + if (isRemoteWorkspace(workspace) && !workspace.connectionId) { + throw new Error(i18nService.t("panels/files:transfer.missingConnection")); + } + const identity = {workspace_path: workspace.rootPath, + remote_connection_id: isRemoteWorkspace(workspace) ? workspace.connectionId : undefined}; const entries = isDirectory - ? await collectPeerDirectoryEntries(sourcePath, destinationPath) + ? await collectPeerDirectoryEntries(sourcePath, destinationPath, identity.remote_connection_id) : [{ sourcePath, destinationPath, @@ -252,9 +254,7 @@ async function downloadPeerWorkspacePathToDisk( bytesTotal += size - expectedEntrySize; expectedEntrySize = size; }; - const chunks = isRemoteWorkspace(workspace) - ? readPeerSshFile(entry.sourcePath, workspace?.connectionId) - : readPeerFileChunks(adapter, entry.sourcePath, updateExpectedEntrySize); + const chunks = readPeerFileChunks(adapter, entry.sourcePath, updateExpectedEntrySize, identity); await writeAllToLocalFile(entry.destinationPath, chunks, (written) => { entryWritten += written; bytesTransferred = entryStart + entryWritten; @@ -548,6 +548,7 @@ export async function downloadWorkspaceFileToDisk( const picked = await open({ title: i18nService.t("common:file.downloadSaveTitle"), directory: true, + recursive: true, }); if (picked === null) { return; diff --git a/src/web-ui/src/tools/terminal/utils/TerminalInputQueue.ts b/src/web-ui/src/tools/terminal/utils/TerminalInputQueue.ts index 09de386627..e637cfe0d8 100644 --- a/src/web-ui/src/tools/terminal/utils/TerminalInputQueue.ts +++ b/src/web-ui/src/tools/terminal/utils/TerminalInputQueue.ts @@ -1,82 +1 @@ -/** - * Coalesces terminal input into batched, sequentially-ordered writes. - * - * Problem: xterm.js fires `onData` once per keystroke. If each call triggers a - * separate fire-and-forget `invoke('terminal_write')`, rapid typing creates a - * burst of concurrent IPC calls. On macOS (WKWebView) this can cause character - * loss because: - * - concurrent async command handlers are not guaranteed FIFO ordering after - * the `get_or_init_api` mutex releases, - * - WKWebView IPC has higher latency and bursts of concurrent invokes can - * interfere with each other. - * - * Solution: buffer incoming data and flush it as a single batched write. Only - * one flush is in flight at a time, so ordering is guaranteed and IPC overhead - * is reduced. The first keystroke is dispatched via a microtask (near-zero - * latency); subsequent keystrokes that arrive while a flush is in flight are - * automatically coalesced into the next flush. - * - * IME safety: xterm.js fires `onData` only after IME composition completes, so - * this queue never interferes with input-method candidate selection or the - * Enter-key guard in chat input components. - */ -export class TerminalInputQueue { - private buffer = ''; - private flushing = false; - private readonly write: (data: string) => Promise; - private readonly onError: (error: unknown) => void; - - constructor( - write: (data: string) => Promise, - onError: (error: unknown) => void, - ) { - this.write = write; - this.onError = onError; - } - - /** - * Append data to the buffer and schedule a flush if one is not already in - * progress. - */ - enqueue(data: string): void { - this.buffer += data; - if (!this.flushing) { - this.flushing = true; - queueMicrotask(() => { - void this.drain(); - }); - } - } - - /** - * Flush the current buffer contents to the write function. After the write - * completes, if more data arrived during the flush, drain again; otherwise - * mark the queue as idle. - */ - private async drain(): Promise { - const data = this.buffer; - this.buffer = ''; - try { - if (data) { - await this.write(data); - } - } catch (err) { - this.onError(err); - } finally { - if (this.buffer) { - // More data arrived during the flush — drain again. - void this.drain(); - } else { - this.flushing = false; - } - } - } - - /** - * Discard any buffered data that has not been flushed yet. Data that is - * already mid-flush is unaffected. - */ - clear(): void { - this.buffer = ''; - } -} +export { TerminalInputQueue } from '../../../../../shared/terminal/TerminalInputQueue'; From f9aa59188970ddb3b88496cd7e2747b521594690 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 14 Sep 2026 21:54:11 +0800 Subject: [PATCH 03/11] feat(mobile-web): unify realtime sessions and refine workspace tools UI --- package.json | 3 +- pnpm-lock.yaml | 84 +++++++ scripts/build-mobile-terminal.mjs | 16 ++ src/mobile-web/AGENTS.md | 11 + src/mobile-web/README.md | 12 + src/mobile-web/index.html | 2 +- src/mobile-web/package.json | 18 +- src/mobile-web/src/App.tsx | 14 ++ .../src/components/ChatModelControls.tsx | 13 -- .../components/ChatToolApprovalActions.tsx | 13 +- .../src/components/ChatToolDetails.tsx | 6 + .../src/components/ChatTranscript.tsx | 5 +- .../src/components/CompactSettingsSheet.tsx | 20 +- .../src/components/PermissionMailbox.tsx | 70 ++++++ .../src/components/WorkspaceFileEditor.tsx | 35 +++ .../src/components/WorkspaceFiles.scss | 160 +++++++++++++ .../src/components/WorkspaceFiles.tsx | 170 ++++++++++++++ .../src/components/WorkspaceFolderPicker.tsx | 69 ++++++ .../src/components/WorkspaceTerminal.tsx | 113 +++++++++ src/mobile-web/src/i18n/messages.ts | 105 +++++++++ src/mobile-web/src/pages/ChatPage.tsx | 101 +++----- src/mobile-web/src/pages/DeviceToolsPage.tsx | 61 +++++ src/mobile-web/src/pages/DevicesPage.tsx | 29 +-- src/mobile-web/src/pages/SessionListPage.tsx | 158 +++++-------- src/mobile-web/src/pages/WorkspacePage.scss | 137 +++++++++++ src/mobile-web/src/pages/WorkspacePage.tsx | 74 ++++-- .../src/services/HostCatalogSubscription.ts | 51 ++++ .../src/services/RelayHttpClient.ts | 85 ++++--- .../src/services/RemoteSessionManager.ts | 218 +++++------------- .../src/services/RuntimeFileDownload.ts | 72 ++++++ .../src/services/RuntimeFileUpload.ts | 53 +++++ .../src/services/SessionEventReducer.ts | 76 ++++++ .../src/services/SessionRecordPresentation.ts | 31 +++ .../src/services/SessionSynchronizer.ts | 82 +++++++ src/mobile-web/src/services/pairingLink.ts | 2 +- src/mobile-web/src/services/store.ts | 18 +- .../src/services/workspaceIdentity.ts | 8 +- .../src/styles/components/chat-input.scss | 7 + .../src/styles/components/harmony-native.scss | 156 ++++++++++--- .../src/styles/components/tool-card.scss | 48 ++++ src/mobile-web/tests/account-login.test.mjs | 14 +- src/mobile-web/tests/browser-account.test.mjs | 18 ++ .../harmony-file-editor-browser.test.mjs | 22 ++ .../tests/helpers/browser-account-harness.mjs | 2 +- .../tests/host-catalog-subscription.test.mjs | 63 +++++ .../interaction-mailbox-browser.test.mjs | 33 +++ .../tests/invalidation-sync.test.mjs | 31 +++ .../native-terminal-renderer-browser.test.mjs | 30 +++ src/mobile-web/tests/realtime-sync.test.mjs | 140 +++++++++++ .../tests/runtime-file-download.test.mjs | 48 ++++ .../tests/runtime-file-upload.test.mjs | 47 ++++ .../tests/session-stream-browser.test.mjs | 66 ++++++ .../tests/terminal-browser.test.mjs | 38 +++ 53 files changed, 2494 insertions(+), 464 deletions(-) create mode 100644 scripts/build-mobile-terminal.mjs create mode 100644 src/mobile-web/src/components/PermissionMailbox.tsx create mode 100644 src/mobile-web/src/components/WorkspaceFileEditor.tsx create mode 100644 src/mobile-web/src/components/WorkspaceFiles.scss create mode 100644 src/mobile-web/src/components/WorkspaceFiles.tsx create mode 100644 src/mobile-web/src/components/WorkspaceFolderPicker.tsx create mode 100644 src/mobile-web/src/components/WorkspaceTerminal.tsx create mode 100644 src/mobile-web/src/pages/DeviceToolsPage.tsx create mode 100644 src/mobile-web/src/pages/WorkspacePage.scss create mode 100644 src/mobile-web/src/services/HostCatalogSubscription.ts create mode 100644 src/mobile-web/src/services/RuntimeFileDownload.ts create mode 100644 src/mobile-web/src/services/RuntimeFileUpload.ts create mode 100644 src/mobile-web/src/services/SessionEventReducer.ts create mode 100644 src/mobile-web/src/services/SessionRecordPresentation.ts create mode 100644 src/mobile-web/src/services/SessionSynchronizer.ts create mode 100644 src/mobile-web/tests/harmony-file-editor-browser.test.mjs create mode 100644 src/mobile-web/tests/host-catalog-subscription.test.mjs create mode 100644 src/mobile-web/tests/interaction-mailbox-browser.test.mjs create mode 100644 src/mobile-web/tests/invalidation-sync.test.mjs create mode 100644 src/mobile-web/tests/native-terminal-renderer-browser.test.mjs create mode 100644 src/mobile-web/tests/realtime-sync.test.mjs create mode 100644 src/mobile-web/tests/runtime-file-download.test.mjs create mode 100644 src/mobile-web/tests/runtime-file-upload.test.mjs create mode 100644 src/mobile-web/tests/session-stream-browser.test.mjs create mode 100644 src/mobile-web/tests/terminal-browser.test.mjs diff --git a/package.json b/package.json index 5279e540db..ee506e3e87 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,7 @@ "jszip": "^3.10.1", "pdf-lib": "^1.17.1", "pnpm": "^10.32.1", - "pptxgenjs": "^4.0.1" + "pptxgenjs": "^4.0.1", + "socket.io-client": "4.8.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cc3725a2c..197f0ca7c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: pptxgenjs: specifier: ^4.0.1 version: 4.0.1 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 devDependencies: '@noble/hashes': specifier: ^2.0.1 @@ -265,6 +268,12 @@ importers: '@openbitfun/ui': specifier: workspace:^ version: link:../../design-system/packages/ui + '@xterm/addon-fit': + specifier: 0.12.0-beta.141 + version: 0.12.0-beta.141(@xterm/xterm@6.1.0-beta.141) + '@xterm/xterm': + specifier: 6.1.0-beta.141 + version: 6.1.0-beta.141 lucide-react: specifier: ^0.541.0 version: 0.541.0(react@18.3.1) @@ -283,6 +292,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + socket.io-client: + specifier: 4.8.3 + version: 4.8.3 zustand: specifier: ^5.0.10 version: 5.0.11(@types/react@18.3.27)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) @@ -1806,6 +1818,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3432,6 +3447,13 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-client@6.6.6: + resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -5238,6 +5260,14 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} + engines: {node: '>=10.0.0'} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -5889,6 +5919,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -5896,6 +5938,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: + resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} + engines: {node: '>=0.4.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -7041,6 +7087,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} '@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -9002,6 +9050,20 @@ snapshots: dependencies: once: 1.4.0 + engine.io-client@6.6.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.21.3 + xmlhttprequest-ssl: 2.1.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + engine.io-parser@5.2.3: {} + entities@4.5.0: {} entities@6.0.1: {} @@ -11375,6 +11437,24 @@ snapshots: smart-buffer@4.2.0: {} + socket.io-client@4.8.3: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + engine.io-client: 6.6.6 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -12085,10 +12165,14 @@ snapshots: ws@8.19.0: {} + ws@8.21.3: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/scripts/build-mobile-terminal.mjs b/scripts/build-mobile-terminal.mjs new file mode 100644 index 0000000000..0dcf7a332b --- /dev/null +++ b/scripts/build-mobile-terminal.mjs @@ -0,0 +1,16 @@ +import {createRequire} from 'node:module'; +import {mkdir,readFile,writeFile,copyFile} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +const root=fileURLToPath(new URL('../',import.meta.url)); +const require=createRequire(path.join(root,'src/mobile-web/package.json')); +const {build}=createRequire(require.resolve('vite'))('esbuild'); +const output=path.join(root,'src/shared/terminal/webview/generated'); +await mkdir(output,{recursive:true}); +await build({entryPoints:[path.join(root,'src/shared/terminal/webview/index.ts')],outfile:path.join(output,'terminal.js'),bundle:true,minify:true,format:'iife',platform:'browser',target:'es2020',legalComments:'inline',nodePaths:[path.join(root,'src/mobile-web/node_modules')]}); +await writeFile(path.join(output,'index.html'),`
`); +if(process.argv.includes('--harmony')){ + const dest=path.join(root,'src/apps/mobile/harmonyos/entry/src/main/resources/rawfile/terminal');await mkdir(dest,{recursive:true}); + for(const name of ['index.html','terminal.js','terminal.css'])await copyFile(path.join(output,name),path.join(dest,name)); +} +console.log('Built shared native terminal assets.'); diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md index 67dc6f1b45..7114a86850 100644 --- a/src/mobile-web/AGENTS.md +++ b/src/mobile-web/AGENTS.md @@ -48,7 +48,12 @@ Mobile web is the browser-based remote control client for OpenBitFun desktop ses Run the focused mobile-web checks after changes: ```bash +pnpm --dir src/mobile-web run test:runtime-files # streaming file sinks and resumable uploads +pnpm --dir src/mobile-web run test:interaction-mailbox # independent question/permission owner +pnpm --dir src/mobile-web run test:terminal-browser # real xterm keyboard, ANSI and native bridge pnpm --dir src/mobile-web run test:ui-components +pnpm --dir src/mobile-web run test:session-stream-browser # real IndexedDB latest/backward pages and visibility catch-up +pnpm --dir src/mobile-web run test:realtime-sync # ordered live updates, gap recovery, replica failures pnpm --dir src/mobile-web run test:account-login # account login without an online desktop pnpm --dir src/mobile-web run test:account-browser # real Chrome tabs, persistence, migration, races; simulated Relay pnpm --dir src/mobile-web run test:images # image preparation and upload limits @@ -67,3 +72,9 @@ The browser suite uses an installed Chrome/Chromium (or `PUPPETEER_EXECUTABLE_PATH`) with disposable profiles. It never uses the user's browser profile or live GitHub credentials. See [README.md](README.md) for the account scope and the live-host verification flow. + +For browser file transfer sink backpressure, target switches, and partial-write aborts: + +```bash +node --test src/mobile-web/tests/runtime-file-download.test.mjs src/mobile-web/tests/runtime-file-upload.test.mjs +``` diff --git a/src/mobile-web/README.md b/src/mobile-web/README.md index 1b2d8e633b..ec88e1aa7f 100644 --- a/src/mobile-web/README.md +++ b/src/mobile-web/README.md @@ -56,3 +56,15 @@ desktops in the two tabs, disconnect one tab, then sign out from Devices and confirm both tabs return to sign-in. A separate browser profile should remain independent. Repeat on LAN HTTP and official HTTPS; fixture tests alone are not evidence of a live Relay/desktop deployment. + +## Controlled runtime capabilities + +Mobile web is a controller: workspace paths, files, terminals, and SSH operations execute on the selected Desktop or CLI runtime. The workspace picker offers that runtime and its saved SSH connections; it does not create connections or collect SSH credentials. Product operations use the same `host_invoke` registry as peer control. A missing remote connection identity must never fall back to the runtime’s local filesystem. + +Conversation history and live updates share revisioned `session-record` entries. The controller opens the latest encrypted message page, prefetches older pages in a single background flight shared with explicit older-page requests, and applies WebSocket messages directly when their sequence follows the committed cursor. Sequence gaps, reconnects, and returning to a visible tab trigger forward recovery. Stable record identities and revisioned deletion markers make backward pages safe to merge with newer edits. Cache fragments, the forward cursor, and the older-page boundary commit atomically in IndexedDB. A separate host transcript snapshot is not mixed into this stream. + +The workspace tools provide directory browsing, text-file reading/editing, file/folder creation, file renaming/deletion, binary upload/download, and an xterm PTY renderer. Saving a read file supplies its original SHA-256 to the runtime, which rejects conflicting writes. Terminal output follows durable notifications and resumes the runtime's output cursor. These controls do not create a phone/browser runtime. + +Pending questions and permissions use the runtime interaction mailbox independently of transcript history. Initial attachment, reconnect, foreground recovery, and permission control events refresh that mailbox. Requests are answered by `requestId`, including requests with no tool-call attachment; edited approval input is sent to the same runtime permission owner. + +File uploads stream 3 MiB chunks through the runtime transfer owner, use a whole-file digest, and resolve a lost append acknowledgment through the transfer cursor. Downloads stream chunks to the browser writable-file picker with backpressure; browsers without that API retain Blob parts until their download API accepts the file. Preview buffers remain separate from downloads. Terminal input is coalesced and serialized, resize keeps the latest dimensions, and ANSI output is rendered by xterm rather than interpreted by a custom parser. diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html index 097ad946f2..3cd45ec838 100644 --- a/src/mobile-web/index.html +++ b/src/mobile-web/index.html @@ -5,7 +5,7 @@ - + diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 421d288d31..061eca9272 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -16,21 +16,29 @@ "test:workspace-identity": "node --test tests/workspace-identity.test.mjs", "type-check": "tsc --noEmit", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:realtime-sync": "node --test tests/realtime-sync.test.mjs", + "test:session-stream-browser": "node --test tests/session-stream-browser.test.mjs", + "test:runtime-files": "node --test tests/runtime-file-upload.test.mjs tests/runtime-file-download.test.mjs", + "test:interaction-mailbox": "node --test tests/interaction-mailbox-browser.test.mjs", + "test:terminal-browser": "node --test tests/terminal-browser.test.mjs tests/native-terminal-renderer-browser.test.mjs" }, "dependencies": { - "@openbitfun/theme-openbitfun": "workspace:^", - "@openbitfun/ui": "workspace:^", "@noble/ciphers": "^2.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@openbitfun/theme-openbitfun": "workspace:^", + "@openbitfun/ui": "workspace:^", + "@xterm/addon-fit": "0.12.0-beta.141", + "@xterm/xterm": "6.1.0-beta.141", + "lucide-react": "^0.541.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", - "zustand": "^5.0.10", - "lucide-react": "^0.541.0" + "socket.io-client": "4.8.3", + "zustand": "^5.0.10" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index ff4d1cfe51..6d576b4672 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -2,6 +2,7 @@ import React, { Suspense, lazy, useState, useCallback, useRef, useEffect } from import { MobileBanner, MobileButton, MobileScrim, MobileStatus } from '@openbitfun/ui/mobile'; import PairingPage, { type BrowserAccountBinding } from './pages/PairingPage'; import WorkspacePage from './pages/WorkspacePage'; +import DeviceToolsPage from './pages/DeviceToolsPage'; import SessionListPage from './pages/SessionListPage'; import DevicesPage from './pages/DevicesPage'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -226,7 +227,16 @@ const AppContent: React.FC = () => { return () => window.removeEventListener('popstate', onPopState); }, [accountDirectoryOpen, doPopFromChat, doPopFromWorkspace, doPopFromDevices]); + const [deviceToolsOpen, setDeviceToolsOpen] = useState(false); + const handleOpenDeviceTools = useCallback(() => { + setDeviceToolsOpen(true); + setCompactSidebarOpen(false); + navigateTo('workspace', 'push'); + }, [navigateTo]); + const handleOpenWorkspace = useCallback(() => { + setDeviceToolsOpen(false); + setCompactSidebarOpen(false); navigateTo('workspace', 'push'); }, [navigateTo]); @@ -240,6 +250,7 @@ const AppContent: React.FC = () => { isNew?: boolean, agentType = 'Standard', ) => { + useMobileStore.getState().setError(null); setActiveSessionId(sessionId); setActiveSessionName(sessionName || 'Session'); setActiveSessionAgentType(agentType); @@ -265,6 +276,7 @@ const AppContent: React.FC = () => { }, [navigateTo]); const handleControlTargetChanged = useCallback(() => { + setDeviceToolsOpen(false); setAccountDirectoryOpen(false); clearTimeout(timerRef.current); const restored = navigationRef.current?.restored; @@ -428,6 +440,7 @@ const AppContent: React.FC = () => { activeSessionId={activeSessionId} onSelectSession={handleSelectSession} onOpenWorkspace={handleOpenWorkspace} + onOpenDeviceTools={handleOpenDeviceTools} onDisconnect={handleDisconnect} onOpenDevices={() => navigateTo('devices', 'push')} onControlTargetChanged={handleControlTargetChanged} @@ -436,6 +449,7 @@ const AppContent: React.FC = () => { const renderDetailPage = () => { if (currentPage === 'workspace' && sessionMgrRef.current) { + if (deviceToolsOpen) return ; return ( } {!query.trim() &&

{t('chat.modelDefaults')}

- void handleSelect('auto')} - > - - {t('chat.modelAuto')} - {t('chat.modelAutoDesc')} - - Promise; + onApprove?: (toolId: string, updatedInput?: Record) => Promise; onReject?: (toolId: string) => Promise; } @@ -22,6 +22,8 @@ export default function ChatToolApprovalActions({ const { t } = useI18n(); const [pendingAction, setPendingAction] = useState<'approve' | 'reject' | null>(null); + const [editing, setEditing] = useState(false); + const [editedInput, setEditedInput] = useState(''); const [failed, setFailed] = useState(false); const [submitted, setSubmitted] = useState(false); @@ -29,6 +31,7 @@ export default function ChatToolApprovalActions({ setPendingAction(null); setFailed(false); setSubmitted(false); + setEditing(false); }, [tool.id, tool.status]); if (!isToolAwaitingApproval(tool) || !onApprove || !onReject) return null; @@ -41,7 +44,9 @@ export default function ChatToolApprovalActions({ setPendingAction(action); setFailed(false); try { - await (action === 'approve' ? onApprove(tool.id) : onReject(tool.id)); + const updated = editing && action === 'approve' ? JSON.parse(editedInput) : undefined; + if (updated !== undefined && (updated === null || typeof updated !== 'object' || Array.isArray(updated))) throw new Error('Approval input must be a JSON object'); + await (action === 'approve' ? onApprove(tool.id, updated) : onReject(tool.id)); setSubmitted(true); setPendingAction(null); } catch { @@ -54,6 +59,8 @@ export default function ChatToolApprovalActions({

{t('chat.approvalRequired')}

{requestText &&
{requestText.length > 240 ? `${requestText.slice(0, 240)}…` : requestText}
} + {!editing && { setEditedInput(requestText || '{}'); setEditing(true); }}>{t('chat.editApproval')}} + {editing && setEditedInput(event.target.value)} disabled={pendingAction !== null || submitted} rows={6} />} {failed &&

{t('chat.approvalFailed')}

} {submitted &&

{t('chat.approvalSubmitted')}

}
diff --git a/src/mobile-web/src/components/ChatToolDetails.tsx b/src/mobile-web/src/components/ChatToolDetails.tsx index a777a7fca2..f943a94eeb 100644 --- a/src/mobile-web/src/components/ChatToolDetails.tsx +++ b/src/mobile-web/src/components/ChatToolDetails.tsx @@ -10,6 +10,7 @@ export default function ChatToolDetails({ tool, label }: { tool: RemoteToolStatu const [open, setOpen] = useState(false); const request = tool.tool_input == null ? tool.input_preview : tool.tool_input; const text = typeof request === 'string' ? request : request == null ? '' : JSON.stringify(request, null, 2); + const output = typeof tool.tool_output === 'string' ? tool.tool_output : tool.tool_output == null ? '' : JSON.stringify(tool.tool_output, null, 2); return ( <> setOpen(true)}> @@ -25,6 +26,11 @@ export default function ChatToolDetails({ tool, label }: { tool: RemoteToolStatu

{t('chat.toolRequest')}

{tool.tool_input == null &&

{t(text ? 'chat.requestPreview' : 'chat.requestUnavailable')}

} {text &&
{text}
} + {(output || tool.error_preview) && <> +

{t('chat.toolResult')}

+ {tool.error_preview &&

{tool.error_preview}

} + {output &&
{output}
} + } ); diff --git a/src/mobile-web/src/components/ChatTranscript.tsx b/src/mobile-web/src/components/ChatTranscript.tsx index cfcf6a3f4b..c9f1696ee1 100644 --- a/src/mobile-web/src/components/ChatTranscript.tsx +++ b/src/mobile-web/src/components/ChatTranscript.tsx @@ -1,4 +1,5 @@ import { + Brain as LucideBrain, Check as LucideCheck, ChevronDown as LucideChevronDown, ChevronRight as LucideChevronRight, @@ -20,7 +21,7 @@ import ChatAskQuestionCard from './ChatAskQuestionCard'; import { MarkdownContent } from './ChatMarkdown'; import ChatToolApprovalActions, { isToolAwaitingApproval } from './ChatToolApprovalActions'; -type ToolApprovalHandler = (toolId: string) => Promise; +type ToolApprovalHandler = (toolId: string, updatedInput?: Record) => Promise; function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; @@ -91,7 +92,7 @@ export const ThinkingBlock: React.FC<{ : t('chat.thoughtCharacters', { count: charCount }); return ( - +