reallink-cli 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rust/src/main.rs CHANGED
@@ -1,16 +1,26 @@
1
1
  use anyhow::{anyhow, Context, Result};
2
+ use axum::extract::State;
3
+ use axum::http::StatusCode as AxumStatusCode;
4
+ use axum::routing::{get, post};
5
+ use axum::{Json, Router};
6
+ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
7
+ use base64::Engine;
2
8
  use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
9
+ use iroh::{endpoint::presets, Endpoint, EndpointAddr};
3
10
  use regex::RegexBuilder;
4
11
  use reqwest::{Method, StatusCode};
5
12
  use serde::{Deserialize, Serialize};
6
13
  use sha2::Digest;
7
14
  use std::fs;
8
15
  use std::io::{self, Read, SeekFrom, Write};
16
+ use std::net::SocketAddr;
9
17
  use std::path::{Path, PathBuf};
10
18
  use std::process::Command;
19
+ use std::sync::Arc;
11
20
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
21
  use tokio::fs as tokio_fs;
13
22
  use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader};
23
+ use tokio::sync::RwLock;
14
24
  use tokio::time::sleep;
15
25
 
16
26
  mod generated;
@@ -18,7 +28,7 @@ mod logs;
18
28
  mod unreal;
19
29
  use unreal::{
20
30
  LinkConnectArgs, LinkDoctorArgs, LinkOpenArgs, LinkP2PCreateArgs, LinkP2PGetArgs,
21
- LinkP2PListArgs, LinkP2PSignalArgs, LinkP2PWaitArgs, LinkPathsArgs, LinkPluginInstallArgs,
31
+ LinkP2PExecuteArgs, LinkP2PListArgs, LinkP2PSignalArgs, LinkP2PWaitArgs, LinkPathsArgs, LinkPluginInstallArgs,
22
32
  LinkPluginListArgs, LinkRemoveArgs, LinkRunArgs, LinkSourceArgs, LinkUnrealArgs, LinkUseArgs,
23
33
  PluginIndexFile, SourceLinkRecord, SourceLinksConfig, UnrealLinkRecord, UnrealLinksConfig,
24
34
  };
@@ -31,6 +41,11 @@ const UNREAL_LINKS_FILE_NAME: &str = "unreal-links.json";
31
41
  const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
32
42
  const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
33
43
  const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
44
+ const P2P_EXECUTE_SESSION_FETCH_ATTEMPTS: usize = 3;
45
+ const P2P_EXECUTE_ENDPOINT_ATTEMPTS: usize = 3;
46
+ const P2P_EXECUTE_RETRY_DELAY_MS: u64 = 350;
47
+ const P2P_IROH_ALPN: &[u8] = b"reallink-p2p-direct-v1";
48
+ const P2P_IROH_MAX_MESSAGE_BYTES: usize = 1024 * 1024;
34
49
 
35
50
  #[derive(Parser)]
36
51
  #[command(
@@ -7209,6 +7224,34 @@ pub(crate) async fn link_connect_command(
7209
7224
  args.transport.trim().to_lowercase()
7210
7225
  )
7211
7226
  });
7227
+ let registered_client_id = Arc::new(RwLock::new(None::<String>));
7228
+ let p2p_iroh_endpoint = if args.transport.trim().eq_ignore_ascii_case("p2p") {
7229
+ Some(create_p2p_iroh_endpoint().await?)
7230
+ } else {
7231
+ None
7232
+ };
7233
+ let p2p_iroh_addr_json = if let Some(endpoint) = &p2p_iroh_endpoint {
7234
+ Some(serde_json::to_string(&endpoint.addr()).context("encode p2p iroh addr")?)
7235
+ } else {
7236
+ None
7237
+ };
7238
+ let p2p_direct_servers = if args.transport.trim().eq_ignore_ascii_case("p2p") {
7239
+ Some(
7240
+ spawn_p2p_direct_server(
7241
+ &args.p2p_listen,
7242
+ &args.p2p_tcp_listen,
7243
+ p2p_iroh_endpoint.clone().expect("missing p2p iroh endpoint"),
7244
+ Arc::new(allowed_roots.clone()),
7245
+ registered_client_id.clone(),
7246
+ project_id.clone(),
7247
+ base_url.to_string(),
7248
+ access_token.to_string(),
7249
+ )
7250
+ .await?,
7251
+ )
7252
+ } else {
7253
+ None
7254
+ };
7212
7255
 
7213
7256
  // Reconnect loop with exponential backoff
7214
7257
  let mut backoff_ms: u64 = 1000;
@@ -7264,6 +7307,8 @@ pub(crate) async fn link_connect_command(
7264
7307
  "capabilities": ["stat", "read", "readRange", "readLines", "list", "search", "write", "mkdir", "delete"],
7265
7308
  "metadata": {
7266
7309
  "cliVersion": env!("CARGO_PKG_VERSION"),
7310
+ "irohAddrJson": p2p_iroh_addr_json,
7311
+ "p2pCarrier": if args.transport.trim().eq_ignore_ascii_case("p2p") { Some("iroh-quic-v1") } else { None },
7267
7312
  }
7268
7313
  });
7269
7314
  if let Err(e) = ws_stream
@@ -7287,7 +7332,7 @@ pub(crate) async fn link_connect_command(
7287
7332
  msg_opt = ws_stream.next() => {
7288
7333
  match msg_opt {
7289
7334
  Some(Ok(Message::Text(text))) => {
7290
- if let Err(e) = handle_ws_message(&text, &allowed_roots, &mut ws_stream).await {
7335
+ if let Err(e) = handle_ws_message(&text, &allowed_roots, &registered_client_id, &mut ws_stream).await {
7291
7336
  eprintln!("Error handling message: {}", e);
7292
7337
  }
7293
7338
  },
@@ -7311,6 +7356,11 @@ pub(crate) async fn link_connect_command(
7311
7356
  },
7312
7357
  _ = tokio::signal::ctrl_c() => {
7313
7358
  eprintln!("\nDisconnecting...");
7359
+ if let Some(servers) = &p2p_direct_servers {
7360
+ for server in servers {
7361
+ server.abort();
7362
+ }
7363
+ }
7314
7364
  let _ = ws_stream.close(None).await;
7315
7365
  return Ok(());
7316
7366
  }
@@ -7539,9 +7589,711 @@ pub(crate) async fn link_p2p_signal_command(
7539
7589
  Ok(())
7540
7590
  }
7541
7591
 
7592
+ #[derive(Clone)]
7593
+ struct P2PDirectServerState {
7594
+ allowed_roots: Arc<Vec<std::path::PathBuf>>,
7595
+ client_id: Arc<RwLock<Option<String>>>,
7596
+ project_id: String,
7597
+ base_url: String,
7598
+ access_token: String,
7599
+ }
7600
+
7601
+ #[derive(Debug, Deserialize)]
7602
+ #[serde(rename_all = "camelCase")]
7603
+ struct P2PDirectExecuteRequest {
7604
+ session_id: String,
7605
+ tool: String,
7606
+ #[serde(default)]
7607
+ input: serde_json::Value,
7608
+ }
7609
+
7610
+ #[derive(Debug, Deserialize)]
7611
+ #[serde(rename_all = "camelCase")]
7612
+ struct P2PSessionEnvelope {
7613
+ session: P2PSessionRecord,
7614
+ }
7615
+
7616
+ #[derive(Debug, Deserialize)]
7617
+ #[serde(rename_all = "camelCase")]
7618
+ struct P2PSessionRecord {
7619
+ session_id: String,
7620
+ status: String,
7621
+ signals: Vec<P2PSignalRecord>,
7622
+ }
7623
+
7624
+ #[derive(Debug, Deserialize)]
7625
+ #[serde(rename_all = "camelCase")]
7626
+ struct P2PSignalRecord {
7627
+ role: String,
7628
+ signal_type: String,
7629
+ from_client_id: Option<String>,
7630
+ payload: serde_json::Value,
7631
+ }
7632
+
7633
+ #[derive(Debug, Serialize)]
7634
+ #[serde(rename_all = "camelCase")]
7635
+ struct P2PDirectPeerInfo {
7636
+ endpoint: String,
7637
+ iroh_addr_json: Option<String>,
7638
+ role: String,
7639
+ from_client_id: Option<String>,
7640
+ }
7641
+
7642
+ #[derive(Debug, Serialize, Deserialize)]
7643
+ #[serde(rename_all = "camelCase")]
7644
+ struct P2PDirectTcpEnvelope {
7645
+ session_id: String,
7646
+ tool: String,
7647
+ #[serde(default)]
7648
+ input: serde_json::Value,
7649
+ auth_token: String,
7650
+ }
7651
+
7652
+ fn value_as_trimmed_string(value: Option<&serde_json::Value>) -> Option<String> {
7653
+ value.and_then(|item| item.as_str()).map(|item| item.trim().to_string()).filter(|item| !item.is_empty())
7654
+ }
7655
+
7656
+ async fn fetch_p2p_session(
7657
+ client: &reqwest::Client,
7658
+ base_url: &str,
7659
+ access_token: &str,
7660
+ project_id: &str,
7661
+ session_id: &str,
7662
+ ) -> Result<P2PSessionRecord> {
7663
+ let endpoint = format!(
7664
+ "{}/v1/projects/{}/connect/p2p/sessions/{}",
7665
+ base_url.trim_end_matches('/'),
7666
+ urlencoding::encode(project_id),
7667
+ urlencoding::encode(session_id)
7668
+ );
7669
+ let response = client
7670
+ .get(endpoint)
7671
+ .bearer_auth(access_token)
7672
+ .send()
7673
+ .await
7674
+ .context("fetch p2p session")?;
7675
+ let status = response.status();
7676
+ let payload: serde_json::Value = response.json().await.unwrap_or_else(|_| serde_json::json!({}));
7677
+ if !status.is_success() {
7678
+ return Err(anyhow!(
7679
+ "fetch p2p session failed with status {}: {}",
7680
+ status,
7681
+ payload
7682
+ ));
7683
+ }
7684
+ let parsed: P2PSessionEnvelope = serde_json::from_value(payload)
7685
+ .context("parse p2p session response")?;
7686
+ Ok(parsed.session)
7687
+ }
7688
+
7689
+ fn p2p_should_retry_status(status: StatusCode) -> bool {
7690
+ matches!(
7691
+ status,
7692
+ StatusCode::REQUEST_TIMEOUT
7693
+ | StatusCode::TOO_MANY_REQUESTS
7694
+ | StatusCode::BAD_GATEWAY
7695
+ | StatusCode::SERVICE_UNAVAILABLE
7696
+ | StatusCode::GATEWAY_TIMEOUT
7697
+ ) || status.is_server_error()
7698
+ }
7699
+
7700
+ fn resolve_target_signal(
7701
+ session: &P2PSessionRecord,
7702
+ target_role: &str,
7703
+ ) -> Result<(P2PSignalRecord, P2PDirectPeerInfo, String)> {
7704
+ for signal in session.signals.iter().rev() {
7705
+ if signal.role != target_role {
7706
+ continue;
7707
+ }
7708
+ let endpoint = value_as_trimmed_string(signal.payload.get("endpoint"))
7709
+ .ok_or_else(|| anyhow!("target {} signal missing endpoint", target_role))?;
7710
+ let iroh_addr_json = value_as_trimmed_string(signal.payload.get("irohAddrJson"));
7711
+ let auth_token = value_as_trimmed_string(signal.payload.get("authToken"))
7712
+ .ok_or_else(|| anyhow!("target {} signal missing authToken", target_role))?;
7713
+ return Ok((
7714
+ P2PSignalRecord {
7715
+ role: signal.role.clone(),
7716
+ signal_type: signal.signal_type.clone(),
7717
+ from_client_id: signal.from_client_id.clone(),
7718
+ payload: signal.payload.clone(),
7719
+ },
7720
+ P2PDirectPeerInfo {
7721
+ endpoint,
7722
+ iroh_addr_json,
7723
+ role: signal.role.clone(),
7724
+ from_client_id: signal.from_client_id.clone(),
7725
+ },
7726
+ auth_token,
7727
+ ));
7728
+ }
7729
+ Err(anyhow!("no target {} signal with endpoint/authToken found", target_role))
7730
+ }
7731
+
7732
+ fn candidate_peer_endpoints(endpoint: &str) -> Vec<String> {
7733
+ let mut endpoints = vec![endpoint.to_string()];
7734
+ if endpoint.contains("host.docker.internal") {
7735
+ endpoints.push(endpoint.replace("host.docker.internal", "127.0.0.1"));
7736
+ endpoints.push(endpoint.replace("host.docker.internal", "localhost"));
7737
+ }
7738
+ endpoints.dedup();
7739
+ endpoints
7740
+ }
7741
+
7742
+ async fn invoke_p2p_http_endpoint(
7743
+ client: &reqwest::Client,
7744
+ endpoint: &str,
7745
+ auth_token: &str,
7746
+ session_id: &str,
7747
+ tool: &str,
7748
+ input: &serde_json::Value,
7749
+ ) -> Result<(StatusCode, serde_json::Value)> {
7750
+ let response = client
7751
+ .post(endpoint.to_string())
7752
+ .header("x-reallink-p2p-token", auth_token)
7753
+ .json(&serde_json::json!({
7754
+ "sessionId": session_id,
7755
+ "tool": tool,
7756
+ "input": input,
7757
+ }))
7758
+ .send()
7759
+ .await?;
7760
+ let status = response.status();
7761
+ let payload = response.json().await.unwrap_or_else(|_| serde_json::json!({}));
7762
+ Ok((status, payload))
7763
+ }
7764
+
7765
+ async fn invoke_p2p_tcp_endpoint(
7766
+ endpoint: &str,
7767
+ auth_token: &str,
7768
+ session_id: &str,
7769
+ tool: &str,
7770
+ input: &serde_json::Value,
7771
+ ) -> Result<(StatusCode, serde_json::Value)> {
7772
+ let tcp_target = endpoint.trim_start_matches("tcp://");
7773
+ let mut stream = tokio::net::TcpStream::connect(tcp_target).await
7774
+ .with_context(|| format!("connect tcp p2p endpoint {}", endpoint))?;
7775
+ let envelope = serde_json::to_string(&P2PDirectTcpEnvelope {
7776
+ session_id: session_id.to_string(),
7777
+ tool: tool.to_string(),
7778
+ input: input.clone(),
7779
+ auth_token: auth_token.to_string(),
7780
+ })?;
7781
+ stream.write_all(envelope.as_bytes()).await?;
7782
+ stream.write_all(b"\n").await?;
7783
+ let mut reader = BufReader::new(stream);
7784
+ let mut line = String::new();
7785
+ reader.read_line(&mut line).await?;
7786
+ let payload: serde_json::Value = serde_json::from_str(line.trim())
7787
+ .unwrap_or_else(|_| serde_json::json!({ "ok": false, "error": "invalid tcp response" }));
7788
+ let status = if payload.get("ok").and_then(|value| value.as_bool()).unwrap_or(false) {
7789
+ StatusCode::OK
7790
+ } else {
7791
+ StatusCode::BAD_GATEWAY
7792
+ };
7793
+ Ok((status, payload))
7794
+ }
7795
+
7796
+ fn parse_iroh_addr(addr_json: &str) -> Result<EndpointAddr> {
7797
+ serde_json::from_str(addr_json).context("parse iroh endpoint addr")
7798
+ }
7799
+
7800
+ async fn create_p2p_iroh_endpoint() -> Result<Endpoint> {
7801
+ let endpoint = Endpoint::builder(presets::N0)
7802
+ .alpns(vec![P2P_IROH_ALPN.to_vec()])
7803
+ .bind()
7804
+ .await
7805
+ .context("bind iroh endpoint")?;
7806
+ endpoint.online().await;
7807
+ Ok(endpoint)
7808
+ }
7809
+
7810
+ async fn invoke_p2p_iroh_endpoint(
7811
+ iroh_addr_json: &str,
7812
+ auth_token: &str,
7813
+ session_id: &str,
7814
+ tool: &str,
7815
+ input: &serde_json::Value,
7816
+ ) -> Result<(StatusCode, serde_json::Value)> {
7817
+ let endpoint_addr = parse_iroh_addr(iroh_addr_json)?;
7818
+ let endpoint = create_p2p_iroh_endpoint().await?;
7819
+ let connection = endpoint
7820
+ .connect(endpoint_addr, P2P_IROH_ALPN)
7821
+ .await
7822
+ .context("connect iroh endpoint")?;
7823
+ let (mut send, mut recv) = connection.open_bi().await.context("open iroh request stream")?;
7824
+ let envelope = serde_json::to_vec(&P2PDirectTcpEnvelope {
7825
+ session_id: session_id.to_string(),
7826
+ tool: tool.to_string(),
7827
+ input: input.clone(),
7828
+ auth_token: auth_token.to_string(),
7829
+ })?;
7830
+ send.write_u32(envelope.len() as u32)
7831
+ .await
7832
+ .context("write iroh request length")?;
7833
+ send.write_all(&envelope).await.context("write iroh request")?;
7834
+ send.flush().await.context("flush iroh request")?;
7835
+ send.finish().context("finish iroh request stream")?;
7836
+ let response_len = recv
7837
+ .read_u32()
7838
+ .await
7839
+ .context("read iroh response length")? as usize;
7840
+ if response_len > P2P_IROH_MAX_MESSAGE_BYTES {
7841
+ return Err(anyhow!(
7842
+ "iroh response too large: {} bytes exceeds limit {}",
7843
+ response_len,
7844
+ P2P_IROH_MAX_MESSAGE_BYTES
7845
+ ));
7846
+ }
7847
+ let mut response_bytes = vec![0; response_len];
7848
+ recv.read_exact(&mut response_bytes)
7849
+ .await
7850
+ .map_err(|err| anyhow!("read iroh response: {:#}", err))?;
7851
+ let payload: serde_json::Value = serde_json::from_slice(&response_bytes)
7852
+ .unwrap_or_else(|_| serde_json::json!({ "ok": false, "error": "invalid iroh response" }));
7853
+ let status = if payload.get("ok").and_then(|value| value.as_bool()).unwrap_or(false) {
7854
+ StatusCode::OK
7855
+ } else {
7856
+ StatusCode::BAD_GATEWAY
7857
+ };
7858
+ connection.close(0u32.into(), b"done");
7859
+ endpoint.close().await;
7860
+ Ok((status, payload))
7861
+ }
7862
+
7863
+ async fn p2p_direct_health() -> Json<serde_json::Value> {
7864
+ Json(serde_json::json!({ "ok": true }))
7865
+ }
7866
+
7867
+ async fn p2p_direct_execute(
7868
+ State(state): State<P2PDirectServerState>,
7869
+ headers: axum::http::HeaderMap,
7870
+ Json(payload): Json<P2PDirectExecuteRequest>,
7871
+ ) -> Result<Json<serde_json::Value>, (AxumStatusCode, Json<serde_json::Value>)> {
7872
+ let presented_token = headers
7873
+ .get("x-reallink-p2p-token")
7874
+ .and_then(|value| value.to_str().ok())
7875
+ .map(|value| value.trim().to_string())
7876
+ .filter(|value| !value.is_empty())
7877
+ .ok_or_else(|| {
7878
+ (
7879
+ AxumStatusCode::UNAUTHORIZED,
7880
+ Json(serde_json::json!({ "ok": false, "error": "missing p2p token" })),
7881
+ )
7882
+ })?;
7883
+
7884
+ let registered_client_id = state.client_id.read().await.clone().ok_or_else(|| {
7885
+ (
7886
+ AxumStatusCode::SERVICE_UNAVAILABLE,
7887
+ Json(serde_json::json!({ "ok": false, "error": "p2p client not registered yet" })),
7888
+ )
7889
+ })?;
7890
+
7891
+ let http_client = reqwest::Client::new();
7892
+ let session = fetch_p2p_session(
7893
+ &http_client,
7894
+ &state.base_url,
7895
+ &state.access_token,
7896
+ &state.project_id,
7897
+ &payload.session_id,
7898
+ )
7899
+ .await
7900
+ .map_err(|err| {
7901
+ (
7902
+ AxumStatusCode::BAD_GATEWAY,
7903
+ Json(serde_json::json!({ "ok": false, "error": err.to_string() })),
7904
+ )
7905
+ })?;
7906
+
7907
+ if session.status != "ready" {
7908
+ return Err((
7909
+ AxumStatusCode::CONFLICT,
7910
+ Json(serde_json::json!({ "ok": false, "error": format!("p2p session {} not ready", session.session_id) })),
7911
+ ));
7912
+ }
7913
+
7914
+ let authorized = session.signals.iter().rev().find(|signal| {
7915
+ signal.from_client_id.as_deref() == Some(registered_client_id.as_str())
7916
+ && value_as_trimmed_string(signal.payload.get("authToken")).as_deref() == Some(presented_token.as_str())
7917
+ });
7918
+ if authorized.is_none() {
7919
+ return Err((
7920
+ AxumStatusCode::UNAUTHORIZED,
7921
+ Json(serde_json::json!({ "ok": false, "error": "p2p token not authorized for this client/session" })),
7922
+ ));
7923
+ }
7924
+
7925
+ let result = execute_local_tool(&payload.tool, &payload.input, state.allowed_roots.as_slice()).await;
7926
+ Ok(Json(result))
7927
+ }
7928
+
7929
+ async fn execute_p2p_direct_request(
7930
+ state: &P2PDirectServerState,
7931
+ session_id: &str,
7932
+ auth_token: &str,
7933
+ tool: &str,
7934
+ input: &serde_json::Value,
7935
+ ) -> Result<serde_json::Value> {
7936
+ let registered_client_id = state.client_id.read().await.clone()
7937
+ .ok_or_else(|| anyhow!("p2p client not registered yet"))?;
7938
+ let http_client = reqwest::Client::new();
7939
+ let session = fetch_p2p_session(
7940
+ &http_client,
7941
+ &state.base_url,
7942
+ &state.access_token,
7943
+ &state.project_id,
7944
+ session_id,
7945
+ )
7946
+ .await?;
7947
+ if session.status != "ready" {
7948
+ return Err(anyhow!("p2p session {} not ready", session.session_id));
7949
+ }
7950
+ let authorized = session.signals.iter().rev().find(|signal| {
7951
+ signal.from_client_id.as_deref() == Some(registered_client_id.as_str())
7952
+ && value_as_trimmed_string(signal.payload.get("authToken")).as_deref() == Some(auth_token)
7953
+ });
7954
+ if authorized.is_none() {
7955
+ return Err(anyhow!("p2p token not authorized for this client/session"));
7956
+ }
7957
+ Ok(execute_local_tool(tool, input, state.allowed_roots.as_slice()).await)
7958
+ }
7959
+
7960
+ async fn spawn_p2p_tcp_server(
7961
+ listen_addr: &str,
7962
+ state: P2PDirectServerState,
7963
+ ) -> Result<tokio::task::JoinHandle<()>> {
7964
+ let listener = tokio::net::TcpListener::bind(listen_addr)
7965
+ .await
7966
+ .with_context(|| format!("bind p2p tcp listen address {}", listen_addr))?;
7967
+ let local_addr: SocketAddr = listener.local_addr().context("resolve p2p tcp local addr")?;
7968
+ eprintln!("P2P TCP server listening on tcp://{}", local_addr);
7969
+ let shared = Arc::new(state);
7970
+ Ok(tokio::spawn(async move {
7971
+ loop {
7972
+ let (stream, _peer_addr) = match listener.accept().await {
7973
+ Ok(value) => value,
7974
+ Err(err) => {
7975
+ eprintln!("P2P TCP accept failed: {}", err);
7976
+ continue;
7977
+ }
7978
+ };
7979
+ let state = shared.clone();
7980
+ tokio::spawn(async move {
7981
+ let (reader, mut writer) = stream.into_split();
7982
+ let mut reader = BufReader::new(reader);
7983
+ let mut line = String::new();
7984
+ let response = match reader.read_line(&mut line).await {
7985
+ Ok(0) => serde_json::json!({ "ok": false, "error": "empty tcp request" }),
7986
+ Ok(_) => match serde_json::from_str::<P2PDirectTcpEnvelope>(line.trim()) {
7987
+ Ok(payload) => match execute_p2p_direct_request(
7988
+ &state,
7989
+ &payload.session_id,
7990
+ &payload.auth_token,
7991
+ &payload.tool,
7992
+ &payload.input,
7993
+ )
7994
+ .await
7995
+ {
7996
+ Ok(result) => result,
7997
+ Err(err) => serde_json::json!({ "ok": false, "error": err.to_string() }),
7998
+ },
7999
+ Err(err) => serde_json::json!({ "ok": false, "error": format!("invalid tcp request: {}", err) }),
8000
+ },
8001
+ Err(err) => serde_json::json!({ "ok": false, "error": format!("tcp read failed: {}", err) }),
8002
+ };
8003
+ let payload = match serde_json::to_string(&response) {
8004
+ Ok(value) => value,
8005
+ Err(err) => serde_json::json!({ "ok": false, "error": format!("tcp response encode failed: {}", err) }).to_string(),
8006
+ };
8007
+ let _ = writer.write_all(payload.as_bytes()).await;
8008
+ let _ = writer.write_all(b"\n").await;
8009
+ let _ = writer.shutdown().await;
8010
+ });
8011
+ }
8012
+ }))
8013
+ }
8014
+
8015
+ async fn spawn_p2p_iroh_server(
8016
+ endpoint: Endpoint,
8017
+ state: P2PDirectServerState,
8018
+ ) -> Result<tokio::task::JoinHandle<()>> {
8019
+ let local_addr_json = serde_json::to_string(&endpoint.addr()).context("encode iroh endpoint addr")?;
8020
+ eprintln!("P2P iroh server online: {}", local_addr_json);
8021
+ let shared = Arc::new(state);
8022
+ Ok(tokio::spawn(async move {
8023
+ loop {
8024
+ let Some(connecting) = endpoint.accept().await else {
8025
+ break;
8026
+ };
8027
+ let state = shared.clone();
8028
+ tokio::spawn(async move {
8029
+ let response = async {
8030
+ let connection = connecting.await.context("accept iroh connection")?;
8031
+ let (mut send, mut recv) =
8032
+ connection.accept_bi().await.context("accept iroh request stream")?;
8033
+ let request_len = recv
8034
+ .read_u32()
8035
+ .await
8036
+ .context("read iroh request length")? as usize;
8037
+ if request_len > P2P_IROH_MAX_MESSAGE_BYTES {
8038
+ return Err(anyhow!(
8039
+ "iroh request too large: {} bytes exceeds limit {}",
8040
+ request_len,
8041
+ P2P_IROH_MAX_MESSAGE_BYTES
8042
+ ));
8043
+ }
8044
+ let mut request_bytes = vec![0; request_len];
8045
+ recv.read_exact(&mut request_bytes)
8046
+ .await
8047
+ .context("read iroh request")?;
8048
+ let payload: P2PDirectTcpEnvelope = serde_json::from_slice(&request_bytes)
8049
+ .context("decode iroh request")?;
8050
+ let result = execute_p2p_direct_request(
8051
+ &state,
8052
+ &payload.session_id,
8053
+ &payload.auth_token,
8054
+ &payload.tool,
8055
+ &payload.input,
8056
+ )
8057
+ .await
8058
+ .unwrap_or_else(|err| serde_json::json!({ "ok": false, "error": err.to_string() }));
8059
+ let encoded = serde_json::to_vec(&result).context("encode iroh response")?;
8060
+ send.write_u32(encoded.len() as u32)
8061
+ .await
8062
+ .context("write iroh response length")?;
8063
+ send.write_all(&encoded).await.context("write iroh response")?;
8064
+ send.flush().await.context("flush iroh response")?;
8065
+ send.finish().context("finish iroh response stream")?;
8066
+ connection.closed().await;
8067
+ eprintln!("P2P iroh response sent ({} bytes)", encoded.len());
8068
+ Ok::<(), anyhow::Error>(())
8069
+ }
8070
+ .await;
8071
+ if let Err(err) = response {
8072
+ eprintln!("P2P iroh connection failed: {:#}", err);
8073
+ }
8074
+ });
8075
+ }
8076
+ }))
8077
+ }
8078
+
8079
+ async fn spawn_p2p_direct_server(
8080
+ listen_addr: &str,
8081
+ tcp_listen_addr: &str,
8082
+ iroh_endpoint: Endpoint,
8083
+ allowed_roots: Arc<Vec<std::path::PathBuf>>,
8084
+ client_id: Arc<RwLock<Option<String>>>,
8085
+ project_id: String,
8086
+ base_url: String,
8087
+ access_token: String,
8088
+ ) -> Result<Vec<tokio::task::JoinHandle<()>>> {
8089
+ let listener = tokio::net::TcpListener::bind(listen_addr)
8090
+ .await
8091
+ .with_context(|| format!("bind p2p listen address {}", listen_addr))?;
8092
+ let state = P2PDirectServerState {
8093
+ allowed_roots,
8094
+ client_id,
8095
+ project_id,
8096
+ base_url,
8097
+ access_token,
8098
+ };
8099
+ let app = Router::new()
8100
+ .route("/health", get(p2p_direct_health))
8101
+ .route("/execute", post(p2p_direct_execute))
8102
+ .with_state(state.clone());
8103
+ let local_addr: SocketAddr = listener.local_addr().context("resolve p2p server local addr")?;
8104
+ eprintln!("P2P direct server listening on http://{}", local_addr);
8105
+ let server = tokio::spawn(async move {
8106
+ if let Err(err) = axum::serve(listener, app).await {
8107
+ eprintln!("P2P direct server stopped: {}", err);
8108
+ }
8109
+ });
8110
+ let tcp_server = spawn_p2p_tcp_server(tcp_listen_addr, state.clone()).await?;
8111
+ let iroh_server = spawn_p2p_iroh_server(iroh_endpoint, state).await?;
8112
+ Ok(vec![server, tcp_server, iroh_server])
8113
+ }
8114
+
8115
+ pub(crate) async fn link_p2p_execute_command(
8116
+ client: &reqwest::Client,
8117
+ args: LinkP2PExecuteArgs,
8118
+ ) -> Result<()> {
8119
+ let auth = resolve_agent_auth(&AgentAuthArgs {
8120
+ base_url: args.base_url.clone(),
8121
+ access_token: args.access_token.clone(),
8122
+ })?;
8123
+ let (base_url, access_token) = match &auth {
8124
+ AgentAuth::Session(session) => (session.base_url.clone(), session.access_token.clone()),
8125
+ AgentAuth::Token {
8126
+ base_url,
8127
+ access_token,
8128
+ } => (base_url.clone(), access_token.clone()),
8129
+ };
8130
+ let mut session: Option<P2PSessionRecord> = None;
8131
+ let mut last_session_error: Option<anyhow::Error> = None;
8132
+ for attempt in 1..=P2P_EXECUTE_SESSION_FETCH_ATTEMPTS {
8133
+ match fetch_p2p_session(
8134
+ client,
8135
+ &base_url,
8136
+ &access_token,
8137
+ &args.project_id,
8138
+ &args.session_id,
8139
+ )
8140
+ .await
8141
+ {
8142
+ Ok(value) => {
8143
+ session = Some(value);
8144
+ last_session_error = None;
8145
+ break;
8146
+ }
8147
+ Err(err) => {
8148
+ last_session_error = Some(err.context(format!(
8149
+ "fetch p2p session attempt {} of {}",
8150
+ attempt, P2P_EXECUTE_SESSION_FETCH_ATTEMPTS
8151
+ )));
8152
+ if attempt < P2P_EXECUTE_SESSION_FETCH_ATTEMPTS {
8153
+ sleep(Duration::from_millis(P2P_EXECUTE_RETRY_DELAY_MS * attempt as u64)).await;
8154
+ }
8155
+ }
8156
+ }
8157
+ }
8158
+ let session = session.ok_or_else(|| {
8159
+ last_session_error.unwrap_or_else(|| anyhow!("failed to fetch p2p session"))
8160
+ })?;
8161
+ if session.status != "ready" {
8162
+ return Err(anyhow!("p2p session {} is not ready", session.session_id));
8163
+ }
8164
+ let (_signal, peer, auth_token) =
8165
+ resolve_target_signal(&session, args.target_role.as_api_str())?;
8166
+ let input = load_optional_json_body(args.input.as_deref())?
8167
+ .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
8168
+ let input = serde_json::Value::Object(parse_object_from_value(input, "p2p execute input")?);
8169
+ let mut last_error: Option<anyhow::Error> = None;
8170
+ let mut response_payload = serde_json::json!({});
8171
+ let mut status = StatusCode::BAD_GATEWAY;
8172
+ let mut resolved_endpoint = peer.endpoint.clone();
8173
+ if let Some(iroh_addr_json) = peer.iroh_addr_json.as_deref() {
8174
+ for attempt in 1..=P2P_EXECUTE_ENDPOINT_ATTEMPTS {
8175
+ match invoke_p2p_iroh_endpoint(
8176
+ iroh_addr_json,
8177
+ &auth_token,
8178
+ &args.session_id,
8179
+ &args.tool,
8180
+ &input,
8181
+ )
8182
+ .await
8183
+ {
8184
+ Ok((response_status, payload)) => {
8185
+ status = response_status;
8186
+ response_payload = payload;
8187
+ resolved_endpoint = "iroh://direct".to_string();
8188
+ if status.is_success() || !p2p_should_retry_status(status) || attempt == P2P_EXECUTE_ENDPOINT_ATTEMPTS {
8189
+ last_error = None;
8190
+ break;
8191
+ }
8192
+ }
8193
+ Err(err) => {
8194
+ eprintln!("P2P iroh direct attempt failed: {:#}", err);
8195
+ last_error = Some(anyhow!(err).context(format!(
8196
+ "call p2p iroh endpoint attempt {} of {}",
8197
+ attempt, P2P_EXECUTE_ENDPOINT_ATTEMPTS
8198
+ )));
8199
+ if attempt < P2P_EXECUTE_ENDPOINT_ATTEMPTS {
8200
+ sleep(Duration::from_millis(P2P_EXECUTE_RETRY_DELAY_MS * attempt as u64)).await;
8201
+ continue;
8202
+ }
8203
+ }
8204
+ }
8205
+ }
8206
+ }
8207
+ if status.is_success() {
8208
+ let output = serde_json::json!({
8209
+ "ok": true,
8210
+ "peer": {
8211
+ "endpoint": resolved_endpoint,
8212
+ "role": peer.role,
8213
+ "fromClientId": peer.from_client_id,
8214
+ },
8215
+ "status": status.as_u16(),
8216
+ "body": response_payload,
8217
+ });
8218
+ print_json(&output)?;
8219
+ return Ok(());
8220
+ }
8221
+ for endpoint in candidate_peer_endpoints(&peer.endpoint) {
8222
+ for attempt in 1..=P2P_EXECUTE_ENDPOINT_ATTEMPTS {
8223
+ let result = if endpoint.starts_with("tcp://") {
8224
+ invoke_p2p_tcp_endpoint(
8225
+ &endpoint,
8226
+ &auth_token,
8227
+ &args.session_id,
8228
+ &args.tool,
8229
+ &input,
8230
+ )
8231
+ .await
8232
+ } else {
8233
+ invoke_p2p_http_endpoint(
8234
+ client,
8235
+ &endpoint,
8236
+ &auth_token,
8237
+ &args.session_id,
8238
+ &args.tool,
8239
+ &input,
8240
+ )
8241
+ .await
8242
+ };
8243
+ match result {
8244
+ Ok((response_status, payload)) => {
8245
+ status = response_status;
8246
+ response_payload = payload;
8247
+ resolved_endpoint = endpoint.clone();
8248
+ if status.is_success() || !p2p_should_retry_status(status) || attempt == P2P_EXECUTE_ENDPOINT_ATTEMPTS {
8249
+ last_error = None;
8250
+ break;
8251
+ }
8252
+ sleep(Duration::from_millis(P2P_EXECUTE_RETRY_DELAY_MS * attempt as u64)).await;
8253
+ }
8254
+ Err(err) => {
8255
+ last_error = Some(anyhow!(err).context(format!(
8256
+ "call p2p direct endpoint {} attempt {} of {}",
8257
+ endpoint, attempt, P2P_EXECUTE_ENDPOINT_ATTEMPTS
8258
+ )));
8259
+ if attempt < P2P_EXECUTE_ENDPOINT_ATTEMPTS {
8260
+ sleep(Duration::from_millis(P2P_EXECUTE_RETRY_DELAY_MS * attempt as u64)).await;
8261
+ continue;
8262
+ }
8263
+ }
8264
+ }
8265
+ if status.is_success() || !p2p_should_retry_status(status) {
8266
+ break;
8267
+ }
8268
+ }
8269
+ if status.is_success() || !p2p_should_retry_status(status) {
8270
+ break;
8271
+ }
8272
+ }
8273
+ if let Some(err) = last_error {
8274
+ return Err(err);
8275
+ }
8276
+ let output = serde_json::json!({
8277
+ "ok": status.is_success() && response_payload.get("ok").and_then(|value| value.as_bool()).unwrap_or(false),
8278
+ "peer": {
8279
+ "endpoint": resolved_endpoint,
8280
+ "role": peer.role,
8281
+ "fromClientId": peer.from_client_id,
8282
+ },
8283
+ "status": status.as_u16(),
8284
+ "body": response_payload,
8285
+ });
8286
+ print_json(&output)?;
8287
+ if !status.is_success() {
8288
+ return Err(anyhow!("link p2p execute failed with status {}", status));
8289
+ }
8290
+ Ok(())
8291
+ }
8292
+
7542
8293
  async fn handle_ws_message(
7543
8294
  text: &str,
7544
8295
  allowed_roots: &[std::path::PathBuf],
8296
+ registered_client_id: &Arc<RwLock<Option<String>>>,
7545
8297
  ws_stream: &mut (impl futures_util::Sink<
7546
8298
  tokio_tungstenite::tungstenite::Message,
7547
8299
  Error = tokio_tungstenite::tungstenite::Error,
@@ -7562,6 +8314,7 @@ async fn handle_ws_message(
7562
8314
  match msg_type {
7563
8315
  "registered" => {
7564
8316
  let client_id = msg.get("clientId").and_then(|v| v.as_str()).unwrap_or("?");
8317
+ *registered_client_id.write().await = Some(client_id.to_string());
7565
8318
  eprintln!("Registered as {}", client_id);
7566
8319
  }
7567
8320
  "tool_request" => {
@@ -7635,14 +8388,22 @@ fn resolve_safe_path_allow_missing(
7635
8388
  ) -> Option<std::path::PathBuf> {
7636
8389
  for root in allowed_roots {
7637
8390
  let candidate = root.join(path_str);
7638
- if let Some(parent) = candidate.parent() {
7639
- if let Ok(resolved_parent) = parent.canonicalize() {
7640
- if resolved_parent.starts_with(root) {
8391
+ let canonical_root = match root.canonicalize() {
8392
+ Ok(value) => value,
8393
+ Err(_) => continue,
8394
+ };
8395
+ if !candidate.starts_with(root) {
8396
+ continue;
8397
+ }
8398
+ let mut probe = Some(candidate.as_path());
8399
+ while let Some(current) = probe {
8400
+ if let Ok(resolved_current) = current.canonicalize() {
8401
+ if resolved_current.starts_with(&canonical_root) {
7641
8402
  return Some(candidate);
7642
8403
  }
8404
+ break;
7643
8405
  }
7644
- } else if candidate.starts_with(root) {
7645
- return Some(candidate);
8406
+ probe = current.parent();
7646
8407
  }
7647
8408
  }
7648
8409
  None
@@ -7686,6 +8447,7 @@ async fn execute_read_tool(
7686
8447
  return execute_read_range_tool(input, allowed_roots).await;
7687
8448
  }
7688
8449
  let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
8450
+ let encoding = input.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf8");
7689
8451
  if path_str.is_empty() {
7690
8452
  return serde_json::json!({"ok": false, "error": "path required"});
7691
8453
  }
@@ -7693,8 +8455,17 @@ async fn execute_read_tool(
7693
8455
  Some(p) => p,
7694
8456
  None => return serde_json::json!({"ok": false, "error": "path not within allowed folders"}),
7695
8457
  };
7696
- match tokio::fs::read_to_string(&resolved).await {
7697
- Ok(content) => serde_json::json!({"ok": true, "data": content}),
8458
+ match tokio::fs::read(&resolved).await {
8459
+ Ok(content) => {
8460
+ if encoding == "base64" {
8461
+ serde_json::json!({"ok": true, "data": BASE64_STANDARD.encode(content)})
8462
+ } else {
8463
+ match String::from_utf8(content) {
8464
+ Ok(text) => serde_json::json!({"ok": true, "data": text}),
8465
+ Err(error) => serde_json::json!({"ok": false, "error": format!("file is not valid utf-8: {}", error)}),
8466
+ }
8467
+ }
8468
+ }
7698
8469
  Err(e) => serde_json::json!({"ok": false, "error": e.to_string()}),
7699
8470
  }
7700
8471
  }
@@ -8003,7 +8774,22 @@ async fn execute_write_tool(
8003
8774
  allowed_roots: &[std::path::PathBuf],
8004
8775
  ) -> serde_json::Value {
8005
8776
  let path_str = input.get("path").and_then(|v| v.as_str()).unwrap_or("");
8006
- let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("");
8777
+ let encoding = input.get("encoding").and_then(|v| v.as_str()).unwrap_or_else(|| {
8778
+ if input.get("contentBase64").and_then(|v| v.as_str()).is_some() {
8779
+ "base64"
8780
+ } else {
8781
+ "utf8"
8782
+ }
8783
+ });
8784
+ let content = if encoding == "base64" {
8785
+ input
8786
+ .get("contentBase64")
8787
+ .and_then(|v| v.as_str())
8788
+ .or_else(|| input.get("content").and_then(|v| v.as_str()))
8789
+ .unwrap_or("")
8790
+ } else {
8791
+ input.get("content").and_then(|v| v.as_str()).unwrap_or("")
8792
+ };
8007
8793
  let create_parents = input
8008
8794
  .get("createParents")
8009
8795
  .and_then(|v| v.as_bool())
@@ -8034,7 +8820,16 @@ async fn execute_write_tool(
8034
8820
  return serde_json::json!({"ok": false, "error": format!("file already exists: {}", path_str)});
8035
8821
  }
8036
8822
 
8037
- match tokio::fs::write(&resolved, content).await {
8823
+ let payload = if encoding == "base64" {
8824
+ match BASE64_STANDARD.decode(content) {
8825
+ Ok(bytes) => bytes,
8826
+ Err(error) => return serde_json::json!({"ok": false, "error": format!("invalid base64 content: {}", error)}),
8827
+ }
8828
+ } else {
8829
+ content.as_bytes().to_vec()
8830
+ };
8831
+
8832
+ match tokio::fs::write(&resolved, payload).await {
8038
8833
  Ok(_) => match tokio::fs::metadata(&resolved).await {
8039
8834
  Ok(metadata) => serde_json::json!({
8040
8835
  "ok": true,