diff --git a/capsules/home-cli/browser/home-cli.js b/capsules/home-cli/browser/home-cli.js index 4fe7f687e..ff03556c7 100644 --- a/capsules/home-cli/browser/home-cli.js +++ b/capsules/home-cli/browser/home-cli.js @@ -58,12 +58,7 @@ globalThis.addEventListener?.("pagehide", () => { setRuntimeTerminalMode(false); boot().catch((error) => { console.error("Home CLI terminal could not start", error); - showFallback("Home CLI could not start. Return to the Desktop and try again.", { - allowDesktopRecovery: true, - allowRefresh: true, - focusDesktopRecovery: true, - includeRetainedTerminalOutput: true, - }); + showTerminalStartFailure(error, "Home CLI could not start. Return to the Desktop and try again."); }); async function boot() { @@ -441,12 +436,7 @@ function reattachHomeCliTerminal(message = "") { globalThis.setTimeout?.(() => { startRuntimeTerminal().catch((error) => { console.error("Home CLI could not restart", error); - showFallback("Home CLI could not reconnect. Return to the Desktop and try again.", { - allowDesktopRecovery: true, - allowRefresh: true, - focusDesktopRecovery: true, - includeRetainedTerminalOutput: true, - }); + showTerminalStartFailure(error, "Home CLI could not reconnect. Return to the Desktop and try again."); }); }, 0); } @@ -458,12 +448,19 @@ function retryHomeCliTerminalFromFallback() { showFallback("Starting Home CLI terminal...", { includeRetainedTerminalOutput: false }); startRuntimeTerminal().catch((error) => { console.error("Home CLI retry failed", error); - showFallback("Home CLI could not start. Return to the Desktop and try again.", { - allowDesktopRecovery: true, - allowRefresh: true, - focusDesktopRecovery: true, - includeRetainedTerminalOutput: true, - }); + showTerminalStartFailure(error, "Home CLI could not start. Return to the Desktop and try again."); + }); +} + +function showTerminalStartFailure(error, message) { + const refused = error?.status === 403; + showFallback(refused + ? "Runtime refused web terminal access. The host operator can check developer mode and guest registration. Return to the Desktop." + : message, { + allowDesktopRecovery: true, + allowRefresh: !refused, + focusDesktopRecovery: true, + includeRetainedTerminalOutput: true, }); } @@ -687,7 +684,9 @@ async function fetchJson(url, init = {}) { const response = await fetch(url, init); if (!response.ok) { const detail = await response.text().catch(() => ""); - throw new Error(detail || `request failed: ${response.status}`); + const error = new Error(detail || `request failed: ${response.status}`); + error.status = response.status; + throw error; } return response.json(); } diff --git a/docs/COMMAND_MATRIX.md b/docs/COMMAND_MATRIX.md index ec3388d8d..5b7ba40dd 100644 --- a/docs/COMMAND_MATRIX.md +++ b/docs/COMMAND_MATRIX.md @@ -55,6 +55,21 @@ target. | Updates | `elastos update`, `elastos upgrade` | `upgrade` dispatches to the same update handler. Discovery may use Carrier or explicit gateways, but no local runtime is required. | | Offline principal-root maintenance | hidden `elastos principal-root-migrate`, hidden `elastos principal-root-upgrade` | Operates on an explicit data directory. The Runtime must be offline and the command requires explicit backup inputs. | +The host operator controls web terminal access with +`elastos config set developer_mode true` and +`elastos config set developer_mode false`. Runtime stores this boolean in +`config.toml` in its data folder. The default is off. Home and apps use Runtime +routes and app storage roots; the host CLI owns this setting. The existing +`dev_mode` setting controls capsule signature checks separately. + +Runtime refuses all web terminal routes with HTTP 403 while developer mode is +off or guest registration is enabled. This includes signed-in owners. A refused +start request creates no host process. The next request uses the current +setting. Runtime checks existing web terminal sessions each second and closes +them when the policy refuses access. It also checks the policy before each +input message. Home CLI shows a refusal with host operator guidance and a +Return to Desktop button. The host CLI `elastos home` remains available. + The host operator sets accepted public gateway authorities with `elastos config set gateway_allowed_hosts '["home.example.com"]'`, then restarts the gateway. Each authority includes its port when it uses a non-default port. diff --git a/elastos/crates/elastos-server/src/api/gateway.rs b/elastos/crates/elastos-server/src/api/gateway.rs index 2de428c60..1404b4057 100644 --- a/elastos/crates/elastos-server/src/api/gateway.rs +++ b/elastos/crates/elastos-server/src/api/gateway.rs @@ -1212,7 +1212,7 @@ fn gateway_router_with_api_url(state: GatewayState, gateway_api_url: String) -> "/apps/:app/*path", get(super::browser_capsules::serve_browser_app_asset), ) - .with_state(state) + .with_state(state.clone()) .layer(Extension(TrustedGatewayApiUrl(Arc::from(gateway_api_url)))) .layer(axum::middleware::from_fn(refuse_content_api_resources)) .layer(axum::middleware::from_fn_with_state( @@ -1220,6 +1220,11 @@ fn gateway_router_with_api_url(state: GatewayState, gateway_api_url: String) -> gateway_frontdoor::gateway_admission, )) .layer(Extension(frontdoor)) + // Terminal policy also gates browser preflight before its early response. + .layer(axum::middleware::from_fn_with_state( + state.data_dir.clone(), + home_cli_terminal_access, + )) } // --------------------------------------------------------------------------- diff --git a/elastos/crates/elastos-server/src/api/gateway_home_terminal.rs b/elastos/crates/elastos-server/src/api/gateway_home_terminal.rs index f182516b5..70d505965 100644 --- a/elastos/crates/elastos-server/src/api/gateway_home_terminal.rs +++ b/elastos/crates/elastos-server/src/api/gateway_home_terminal.rs @@ -10,12 +10,14 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::Context; use rand::RngCore; use tokio::process::{Child, Command}; -use tokio::sync::{broadcast, Mutex, Notify}; +use tokio::sync::{broadcast, watch, Mutex, Notify}; #[cfg(unix)] use std::fs::File; #[cfg(unix)] use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(unix)] +use tokio::io::unix::AsyncFd; const HOME_CLI_CAPSULE_ID: &str = "home-cli"; const HOME_TERMINAL_CONTRACT_SCHEMA: &str = "elastos.home-cli.terminal-contract/v1"; @@ -27,6 +29,7 @@ const HOME_TERMINAL_EVENT_SCHEMA: &str = "elastos.home-cli.terminal-event/v1"; const HOME_TERMINAL_HOST_INTENT_SCHEMA: &str = "elastos.home.terminal-host-intent/v1"; const HOME_TERMINAL_INTENT_SCHEMA: &str = "elastos.home-cli.terminal-intent/v1"; const HOME_TERMINAL_EVENT_KEEPALIVE_SECS: u64 = 15; +const HOME_TERMINAL_POLICY_CHECK_INTERVAL_SECS: u64 = 1; const HOME_TERMINAL_EVENT_DISCONNECT_GRACE_SECS: u64 = 3; const HOME_TERMINAL_PENDING_ATTACH_TIMEOUT_SECS: u64 = 20; const HOME_TERMINAL_EXIT_AUTH_GRACE_MS: u64 = 2_000; @@ -55,6 +58,43 @@ static HOME_TERMINAL_SESSIONS: OnceLock> = OnceLock::new(); +// Runtime owns this setting outside the app storage roots. The host CLI owns +// changes to developer_mode; the gateway reads the value for each request. +fn home_terminal_policy_allows(data_dir: &FsPath) -> bool { + let developer_mode = std::fs::read_to_string(data_dir.join("config.toml")) + .ok() + .and_then(|contents| contents.parse::().ok()) + .and_then(|config| config.get("developer_mode").and_then(toml::Value::as_bool)) + .unwrap_or(false); + developer_mode + && !crate::auth::guest_registration_enabled(&home_launch_auth_data_dir(data_dir)) + .unwrap_or(true) +} + +pub(super) async fn home_cli_terminal_access( + State(data_dir): State, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + if request + .uri() + .path() + .starts_with("/api/apps/home-cli/terminal/") + && !home_terminal_policy_allows(&data_dir) + { + return home_terminal_policy_refusal(); + } + next.run(request).await +} + +fn home_terminal_policy_refusal() -> Response { + ( + StatusCode::FORBIDDEN, + "Web terminal requires host developer mode and closed guest registration.", + ) + .into_response() +} + #[derive(Debug, Deserialize)] pub(super) struct HomeTerminalStreamQuery { ticket: Option, @@ -167,6 +207,7 @@ struct HomeTerminalSession { child_pid: Option, created_at_ms: u64, input: Mutex>, + input_closed: watch::Sender, child: Mutex, events: broadcast::Sender, replay: StdMutex, @@ -178,7 +219,7 @@ struct HomeTerminalSession { enum HomeTerminalInput { #[cfg(unix)] - Pty(File), + Pty(AsyncFd), } #[derive(Debug, Clone, Copy)] @@ -215,6 +256,7 @@ struct HomeTerminalCleanupTarget { } enum HomeTerminalStartError { + PolicyRefused, Capacity(HomeTerminalCapacityError), Runtime(anyhow::Error), } @@ -380,6 +422,10 @@ pub(super) async fn home_cli_terminal_start( headers: HeaderMap, start: Option>, ) -> Response { + // Body extraction can wait while the host operator changes the policy. + if !home_terminal_policy_allows(&state.data_dir) { + return home_terminal_policy_refusal(); + } let context = match require_home_launch_token_for_any_context( &state.data_dir, &headers, @@ -392,8 +438,17 @@ pub(super) async fn home_cli_terminal_start( Ok(size) => size, Err(response) => return response, }; - match start_home_terminal_session(context, size, Some(gateway_api_url.0.to_string())).await { - Ok(session) => Json(serde_json::json!({ + match start_home_terminal_session( + context, + size, + Some(gateway_api_url.0.to_string()), + &state.data_dir, + ) + .await + { + Ok(session) => { + spawn_home_terminal_policy_watchdog(session.session_id.clone(), state.data_dir); + Json(serde_json::json!({ "schema": HOME_TERMINAL_SESSION_SCHEMA, "session_id": session.session_id, "transport": "runtime_pty_stream", @@ -425,7 +480,9 @@ pub(super) async fn home_cli_terminal_start( "principal_id": session.principal_id } })) - .into_response(), + .into_response() + } + Err(HomeTerminalStartError::PolicyRefused) => home_terminal_policy_refusal(), Err(HomeTerminalStartError::Capacity(err)) => home_terminal_capacity_response(err), Err(HomeTerminalStartError::Runtime(err)) => home_error_response(err), } @@ -499,6 +556,7 @@ pub(super) async fn home_cli_terminal_events( } pub(super) async fn home_cli_terminal_input_socket( + State(state): State, Path(session_id): Path, Query(query): Query, socket: WebSocketUpgrade, @@ -515,7 +573,9 @@ pub(super) async fn home_cli_terminal_input_socket( .input_stream_generation .fetch_add(1, Ordering::AcqRel) + 1; - socket.on_upgrade(move |socket| home_terminal_input_socket(socket, session, generation)) + socket.on_upgrade(move |socket| { + home_terminal_input_socket(socket, session, generation, state.data_dir) + }) } pub(super) fn home_terminal_input_ticket_matches(presented: Option<&str>, expected: &str) -> bool { @@ -526,8 +586,13 @@ async fn home_terminal_input_socket( mut socket: WebSocket, session: Arc, generation: u64, + data_dir: PathBuf, ) { while let Some(Ok(message)) = socket.recv().await { + if !home_terminal_policy_allows(&data_dir) { + close_home_terminal_session(&session.session_id, "terminal access disabled").await; + break; + } if session.input_stream_generation.load(Ordering::Acquire) != generation { break; } @@ -544,10 +609,15 @@ async fn home_terminal_input_socket( break; } let mut input = session.input.lock().await; + if !home_terminal_policy_allows(&data_dir) { + drop(input); + close_home_terminal_session(&session.session_id, "terminal access disabled").await; + break; + } let Some(input) = input.as_mut() else { break; }; - if input.write_all(&data).await.is_err() { + if input.write_all(&data, &session.input_closed).await.is_err() { break; } } @@ -559,6 +629,9 @@ pub(super) async fn home_cli_terminal_resize( headers: HeaderMap, Json(resize): Json, ) -> Response { + if !home_terminal_policy_allows(&state.data_dir) { + return home_terminal_policy_refusal(); + } if let Some(schema) = resize.schema.as_deref() { if schema != HOME_TERMINAL_RESIZE_SCHEMA { return ( @@ -606,6 +679,9 @@ pub(super) async fn home_cli_terminal_intent( headers: HeaderMap, Json(intent): Json, ) -> Response { + if !home_terminal_policy_allows(&state.data_dir) { + return home_terminal_policy_refusal(); + } cleanup_stale_home_terminal_sessions(now_unix_ms()).await; let context = match require_home_launch_token_for_any_context( &state.data_dir, @@ -663,6 +739,7 @@ async fn start_home_terminal_session( _context: HomeLaunchTokenContext, _size: HomeTerminalSize, _gateway_api_url: Option, + _data_dir: &FsPath, ) -> Result, HomeTerminalStartError> { Err(HomeTerminalStartError::Runtime(anyhow::anyhow!( "Runtime PTY terminal is not supported on this platform" @@ -674,6 +751,7 @@ async fn start_home_terminal_session( context: HomeLaunchTokenContext, size: HomeTerminalSize, gateway_api_url: Option, + data_dir: &FsPath, ) -> Result, HomeTerminalStartError> { let cleanup = prepare_home_terminal_start(&context, now_unix_ms()) .await @@ -715,6 +793,11 @@ async fn start_home_terminal_session( }); } + // Session planning and cleanup can yield. Check again at the process gate, + // with no asynchronous operation between this check and spawn. + if !home_terminal_policy_allows(data_dir) { + return Err(HomeTerminalStartError::PolicyRefused); + } let child = command .spawn() .with_context(|| format!("failed to start terminal process {}", command_spec.label))?; @@ -730,6 +813,7 @@ async fn start_home_terminal_session( child_pid, created_at_ms: now_unix_ms(), input: Mutex::new(Some(pty.input)), + input_closed: watch::channel(false).0, child: Mutex::new(child), events, replay: StdMutex::new(HomeTerminalReplayLog::default()), @@ -759,7 +843,7 @@ async fn start_home_terminal_session( #[cfg(unix)] struct HomeTerminalPty { input: HomeTerminalInput, - reader: File, + reader: AsyncFd, slave_stdin: File, slave_stdout: File, slave_stderr: File, @@ -796,9 +880,33 @@ fn open_home_terminal_pty(size: HomeTerminalSize) -> anyhow::Result anyhow::Result io::Result<()> { + async fn write_all(&mut self, mut data: &[u8], closed: &watch::Sender) -> io::Result<()> { match self { #[cfg(unix)] HomeTerminalInput::Pty(file) => { - let mut writer = file.try_clone()?; - let data = data.to_vec(); - tokio::task::spawn_blocking(move || writer.write_all(&data)) - .await - .map_err(io::Error::other)? + let mut closed = closed.subscribe(); + while !data.is_empty() { + let mut ready = tokio::select! { + biased; + _ = closed.wait_for(|closed| *closed) => { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "terminal input closed")); + } + ready = file.writable() => ready?, + }; + match ready.try_io(|file| file.get_ref().write(data)) { + Ok(Ok(0)) => return Err(io::ErrorKind::WriteZero.into()), + Ok(Ok(written)) => data = &data[written..], + Ok(Err(err)) if err.kind() == io::ErrorKind::Interrupted => continue, + Ok(Err(err)) => return Err(err), + Err(_) => continue, + } + } + Ok(()) } } } @@ -900,12 +1021,19 @@ fn home_terminal_command() -> anyhow::Result { } #[cfg(unix)] -fn spawn_home_terminal_pty_reader(session: Arc, mut reader: File) { - tokio::task::spawn_blocking(move || { +fn spawn_home_terminal_pty_reader(session: Arc, reader: AsyncFd) { + tokio::spawn(async move { let mut buffer = [0u8; 4096]; let mut decoder = HomeTerminalUtf8Decoder::default(); loop { - match reader.read(&mut buffer) { + let read = match reader.readable().await { + Ok(mut ready) => match ready.try_io(|reader| reader.get_ref().read(&mut buffer)) { + Ok(read) => read, + Err(_) => continue, + }, + Err(err) => Err(err), + }; + match read { Ok(0) => { if let Some(data) = decoder.flush_lossy() { send_home_terminal_stdout(&session, data); @@ -917,6 +1045,7 @@ fn spawn_home_terminal_pty_reader(session: Arc, mut reader: send_home_terminal_stdout(&session, data); } } + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, Err(err) => { emit_home_terminal_event( &session, @@ -1009,6 +1138,7 @@ fn send_home_terminal_stdout(session: &HomeTerminalSession, data: String) { fn spawn_home_terminal_waiter(session: Arc) { tokio::spawn(async move { let status = session.child.lock().await.wait().await; + session.input_closed.send_replace(true); { let mut input_handle = session.input.lock().await; input_handle.take(); @@ -1036,6 +1166,24 @@ fn spawn_home_terminal_waiter(session: Arc) { }); } +fn spawn_home_terminal_policy_watchdog(session_id: String, data_dir: PathBuf) { + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs( + HOME_TERMINAL_POLICY_CHECK_INTERVAL_SECS, + )) + .await; + if home_terminal_session(&session_id).await.is_none() { + break; + } + if !home_terminal_policy_allows(&data_dir) { + close_home_terminal_session(&session_id, "terminal access disabled").await; + break; + } + } + }); +} + async fn wait_home_terminal_pty_reader_drained(session: &HomeTerminalSession) { if session.pty_reader_drained.load(Ordering::SeqCst) { return; @@ -1317,6 +1465,11 @@ async fn home_terminal_session(session_id: &str) -> Option Option { + home_terminal_session(session_id).await?.child_pid +} + async fn close_home_terminal_session(session_id: &str, message: &str) -> Option<()> { let session = remove_home_terminal_session(session_id).await?; close_home_terminal_session_handle(session, message).await; @@ -1325,11 +1478,14 @@ async fn close_home_terminal_session(session_id: &str, message: &str) -> Option< async fn close_home_terminal_session_handle(session: Arc, message: &str) { let session_id = session.session_id.clone(); + // Runtime cancels pending input before waiting for its owner. Slave closure + // alone can leave a master write blocked on Linux. + session.input_closed.send_replace(true); + kill_home_terminal_process(session.child_pid); { let mut input_handle = session.input.lock().await; input_handle.take(); } - kill_home_terminal_process(session.child_pid); emit_home_terminal_event( &session, HomeTerminalEvent { @@ -1767,6 +1923,260 @@ fn now_unix_ms() -> u64 { mod tests { use super::*; + #[tokio::test] + #[cfg(unix)] + async fn test_home_cli_terminal_pty_descriptors_are_close_on_exec() { + let pty = open_home_terminal_pty(HomeTerminalSize { + cols: 100, + rows: 32, + }) + .unwrap(); + let HomeTerminalInput::Pty(input) = &pty.input; + for fd in [ + pty.reader.as_raw_fd(), + input.as_raw_fd(), + pty.slave_stdin.as_raw_fd(), + pty.slave_stdout.as_raw_fd(), + pty.slave_stderr.as_raw_fd(), + ] { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + assert_ne!(flags, -1); + assert_ne!(flags & libc::FD_CLOEXEC, 0); + } + } + + #[tokio::test] + #[cfg(unix)] + async fn test_home_cli_terminal_fixture_cleanup_kills_child_on_panic() { + let pty = test_raw_terminal_pty(); + let mut child = Command::new("/bin/sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut cleanup = TestBlockedPtyCleanup { + child_pid: child.id(), + writer: None, + }; + let closed = watch::channel(false).0; + let mut writer = tokio::spawn(async move { + let mut input = pty.input; + input + .write_all(&vec![b'x'; HOME_TERMINAL_INPUT_MAX_BYTES * 32], &closed) + .await + }); + cleanup.writer = Some(writer.abort_handle()); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut writer) + .await + .is_err() + ); + let panic = std::panic::catch_unwind(|| { + let _cleanup = cleanup; + panic!("exercise terminal fixture assertion cleanup"); + }); + assert!(panic.is_err()); + assert!(tokio::time::timeout(Duration::from_secs(2), writer) + .await + .expect("panic cleanup must release the blocked fixture writer") + .unwrap_err() + .is_cancelled()); + let status = tokio::time::timeout(Duration::from_secs(2), child.wait()) + .await + .expect("panic cleanup must terminate and reap the fixture child") + .unwrap(); + use std::os::unix::process::ExitStatusExt; + assert_eq!(status.signal(), Some(libc::SIGKILL)); + } + + #[cfg(unix)] + fn test_raw_terminal_pty() -> HomeTerminalPty { + let pty = open_home_terminal_pty(HomeTerminalSize { + cols: 100, + rows: 32, + }) + .unwrap(); + unsafe { + let mut termios = std::mem::zeroed(); + assert_eq!( + libc::tcgetattr(pty.slave_stdin.as_raw_fd(), &mut termios), + 0 + ); + libc::cfmakeraw(&mut termios); + assert_eq!( + libc::tcsetattr(pty.slave_stdin.as_raw_fd(), libc::TCSANOW, &termios), + 0 + ); + } + pty + } + + #[tokio::test] + #[cfg(unix)] + async fn test_home_cli_terminal_policy_revocation_releases_blocked_pty_input() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("config.toml"), "developer_mode = true\n").unwrap(); + let pty = test_raw_terminal_pty(); + let mut command = Command::new("/bin/sleep"); + command + .arg("30") + .stdin(Stdio::from(pty.slave_stdin)) + .stdout(Stdio::from(pty.slave_stdout)) + .stderr(Stdio::from(pty.slave_stderr)) + .kill_on_drop(true); + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = command.spawn().unwrap(); + drop(command); + let session = test_terminal_session(child, Some(pty.input)); + let mut cleanup = TestBlockedPtyCleanup { + child_pid: session.child_pid, + writer: None, + }; + insert_home_terminal_session(session.clone()).await; + let writing_session = session.clone(); + let completed_writes = Arc::new(AtomicU64::new(0)); + let progress = completed_writes.clone(); + let (locked_tx, locked_rx) = tokio::sync::oneshot::channel(); + let writer = tokio::spawn(async move { + let mut input = writing_session.input.lock().await; + let input = input.as_mut().unwrap(); + locked_tx.send(()).unwrap(); + // A finite flood reaches backpressure without relying on an I/O + // error to stop generating new writes after the slave closes. + for _ in 0..32 { + if input + .write_all( + &[b'x'; HOME_TERMINAL_INPUT_MAX_BYTES], + &writing_session.input_closed, + ) + .await + .is_err() + { + break; + } + progress.fetch_add(1, Ordering::SeqCst); + } + }); + cleanup.writer = Some(writer.abort_handle()); + tokio::time::timeout(Duration::from_secs(2), locked_rx) + .await + .expect("writer must acquire the input lock") + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let before = completed_writes.load(Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(100)).await; + if completed_writes.load(Ordering::SeqCst) == before { + break; + } + } + }) + .await + .expect("raw PTY input must reach backpressure"); + assert!(session.input.try_lock().is_err()); + assert!(!writer.is_finished()); + std::fs::write(dir.path().join("config.toml"), "developer_mode = false\n").unwrap(); + spawn_home_terminal_policy_watchdog(session.session_id.clone(), dir.path().to_path_buf()); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + // Release the child lock on each poll so cleanup can use it. + if session.child.lock().await.try_wait().unwrap().is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("policy revocation must terminate the child despite a blocked write"); + cleanup.child_pid = None; + tokio::time::timeout(Duration::from_secs(2), writer) + .await + .expect("policy revocation must cancel the blocked PTY writer") + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if session.input.lock().await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("policy revocation must close input after the blocked writer releases it"); + test_cleanup_terminal_artifacts(&session.session_id).await; + drop(pty.reader); + } + + #[tokio::test] + #[cfg(unix)] + async fn test_home_cli_terminal_nonblocking_reader_waits_for_output_and_drains() { + let mut pty = test_raw_terminal_pty(); + let child = Command::new("/usr/bin/true") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let session = test_terminal_session(child, Some(pty.input)); + session.pty_reader_drained.store(false, Ordering::SeqCst); + let mut events = session.events.subscribe(); + spawn_home_terminal_pty_reader(session.clone(), pty.reader); + assert!( + tokio::time::timeout(Duration::from_millis(20), events.recv()) + .await + .is_err() + ); + assert!(!session.pty_reader_drained.load(Ordering::SeqCst)); + pty.slave_stdout.write_all(b"terminal output").unwrap(); + let event = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .expect("reader must resume when output arrives") + .unwrap(); + assert_eq!(event.payload.stream, "stdout"); + assert_eq!(event.payload.data.as_deref(), Some("terminal output")); + drop(pty.slave_stdin); + drop(pty.slave_stdout); + drop(pty.slave_stderr); + tokio::time::timeout(Duration::from_secs(2), async { + while !session.pty_reader_drained.load(Ordering::SeqCst) { + session.pty_reader_drained_notify.notified().await; + } + }) + .await + .expect("reader must drain when the slave closes"); + session.child.lock().await.wait().await.unwrap(); + } + + #[cfg(unix)] + struct TestBlockedPtyCleanup { + child_pid: Option, + writer: Option, + } + + #[cfg(unix)] + impl Drop for TestBlockedPtyCleanup { + fn drop(&mut self) { + // This runs during assertion unwinding, before Tokio shuts down. + // Input uses cancellable async I/O, so abort releases its descriptor. + // The guard also terminates the fixture's owned child on failure. + kill_home_terminal_process(self.child_pid); + if let Some(writer) = &self.writer { + writer.abort(); + } + } + } + #[test] fn terminal_utf8_decoder_keeps_split_multibyte_text_intact() { let mut decoder = HomeTerminalUtf8Decoder::default(); @@ -2406,9 +2816,16 @@ mod tests { .stderr(Stdio::null()) .kill_on_drop(true) .spawn()?; + Ok(test_terminal_session(child, None)) + } + + fn test_terminal_session( + child: Child, + input: Option, + ) -> Arc { let child_pid = child.id(); let (events, _receiver) = broadcast::channel(32); - Ok(Arc::new(HomeTerminalSession { + Arc::new(HomeTerminalSession { session_id: format!("term-test-{}", random_hex_token()), stream_ticket: format!("ticket-test-{}", random_hex_token()), input_ticket: format!("input-test-{}", random_hex_token()), @@ -2417,7 +2834,8 @@ mod tests { grant_id: "grant-test".to_string(), child_pid, created_at_ms: now_unix_ms(), - input: Mutex::new(None), + input: Mutex::new(input), + input_closed: watch::channel(false).0, child: Mutex::new(child), events, replay: StdMutex::new(HomeTerminalReplayLog::default()), @@ -2425,6 +2843,6 @@ mod tests { input_stream_generation: AtomicU64::new(0), pty_reader_drained: AtomicBool::new(true), pty_reader_drained_notify: Notify::new(), - })) + }) } } diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs b/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs index 74f3f9c24..444023029 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs @@ -654,11 +654,265 @@ async fn test_home_entry_rejects_path_traversal() { } } +#[tokio::test] +#[cfg(unix)] +async fn test_home_cli_terminal_delayed_body_policy_switch_refuses_spawn() { + let _guard = HOME_GATEWAY_TEST_ENV_LOCK.lock().await; + let fixture = tempfile::tempdir().unwrap(); + let marker = fixture.path().join("terminal-started"); + let _program = EnvRestore::set("ELASTOS_HOME_CLI_TERMINAL_PROGRAM", "/bin/sh".to_string()); + let _args = EnvRestore::set( + "ELASTOS_HOME_CLI_TERMINAL_ARGS_JSON", + serde_json::json!([ + "-c", + "printf started > \"$1\"; exec sleep 30", + "terminal-policy-fixture", + marker.to_str().unwrap() + ]) + .to_string(), + ); + for enable_registration in [false, true] { + for route in [ + "sessions", + "sessions/missing/resize", + "sessions/missing/intent", + ] { + let dir = tempfile::tempdir().unwrap(); + let owner = passkey_authority(dir.path()); + std::fs::write(dir.path().join("config.toml"), "developer_mode = true\n").unwrap(); + let app = gateway_router(test_state(dir.path())); + let token = app_token_for_authority(dir.path(), HOME_CLI_CAPSULE_ID_FOR_TEST, &owner); + let (reading_tx, reading_rx) = tokio::sync::oneshot::channel(); + let (body_tx, body_rx) = tokio::sync::oneshot::channel(); + let body = Body::from_stream(futures_lite::stream::unfold( + Some((reading_tx, body_rx)), + |state| async move { + let (reading_tx, body_rx) = state?; + reading_tx.send(()).unwrap(); + let body = body_rx.await.unwrap(); + Some((Ok::(body), None)) + }, + )); + let pending = tokio::spawn( + app.oneshot( + test_browser_request("localhost:61180", "null") + .method("POST") + .uri(format!("/api/apps/home-cli/terminal/{route}")) + .header("x-elastos-home-token", &token) + .header(CONTENT_TYPE, "application/json") + .body(body) + .unwrap(), + ), + ); + // Body polling proves the request passed the outer policy middleware. + reading_rx.await.unwrap(); + if enable_registration { + crate::auth::set_guest_registration_enabled( + dir.path(), + true, + crate::auth::now_ts(), + ) + .unwrap(); + } else { + std::fs::write(dir.path().join("config.toml"), "developer_mode = false\n").unwrap(); + } + body_tx.send(Bytes::from_static(b"{}")).unwrap(); + let response = pending.await.unwrap().unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !marker.exists(), + "a delayed refused request started a process" + ); + } + } +} + +#[tokio::test] +#[cfg(unix)] +async fn test_home_cli_terminal_developer_mode_policy_matrix() { + let _guard = HOME_GATEWAY_TEST_ENV_LOCK.lock().await; + let fixture = tempfile::tempdir().unwrap(); + let marker = fixture.path().join("terminal-started"); + let _program = EnvRestore::set("ELASTOS_HOME_CLI_TERMINAL_PROGRAM", "/bin/sh".to_string()); + let _args = EnvRestore::set( + "ELASTOS_HOME_CLI_TERMINAL_ARGS_JSON", + serde_json::json!([ + "-c", + "printf started > \"$1\"; exec sleep 30", + "terminal-policy-fixture", + marker.to_str().unwrap() + ]) + .to_string(), + ); + + for developer_mode in [false, true] { + for registration_enabled in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let owner = passkey_authority(dir.path()); + let guest = passkey_authority_with_name_role( + dir.path(), + Some("terminal policy guest"), + crate::auth::RuntimePrincipalRole::Guest, + ); + std::fs::write( + dir.path().join("config.toml"), + format!("developer_mode = {developer_mode}\n"), + ) + .unwrap(); + crate::auth::set_guest_registration_enabled( + dir.path(), + registration_enabled, + crate::auth::now_ts(), + ) + .unwrap(); + let app = gateway_router(test_state(dir.path())); + for authority in [&owner, &guest] { + let token = + app_token_for_authority(dir.path(), HOME_CLI_CAPSULE_ID_FOR_TEST, authority); + if developer_mode && !registration_enabled { + let contract = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "null") + .uri("/api/apps/home-cli/terminal/contract") + .header("x-elastos-home-token", &token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(contract.status(), StatusCode::OK); + continue; + } + assert_terminal_routes_refused(&app, &token).await; + } + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!marker.exists(), "a refused request started a host process"); + + // The same command fixture must start when the policy permits the owner. + // Then each policy switch must close the existing process as well. + for enable_registration in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let owner = passkey_authority(dir.path()); + std::fs::write(dir.path().join("config.toml"), "developer_mode = true\n").unwrap(); + let app = gateway_router(test_state(dir.path())); + let token = app_token_for_authority(dir.path(), HOME_CLI_CAPSULE_ID_FOR_TEST, &owner); + let started = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "null") + .method("POST") + .uri("/api/apps/home-cli/terminal/sessions") + .header("x-elastos-home-token", &token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(started.status(), StatusCode::OK); + let body = axum::body::to_bytes(started.into_body(), usize::MAX) + .await + .unwrap(); + let started: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let session_id = started["session_id"].as_str().unwrap(); + let pid = gateway_home_terminal::home_terminal_process_id(session_id) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + if enable_registration { + crate::auth::set_guest_registration_enabled(dir.path(), true, crate::auth::now_ts()) + .unwrap(); + } else { + std::fs::write(dir.path().join("config.toml"), "developer_mode = false\n").unwrap(); + } + assert_terminal_routes_refused(&app, &token).await; + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if gateway_home_terminal::home_terminal_process_id(session_id) + .await + .is_none() + && unsafe { libc::kill(pid as libc::pid_t, 0) } == -1 + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("policy switch must retire the owned terminal process"); + std::fs::remove_file(&marker).unwrap(); + } +} + +async fn assert_terminal_routes_refused(app: &axum::Router, token: &str) { + for (method, route) in [ + ("GET", "contract"), + ("POST", "sessions"), + ("GET", "sessions/missing/events?ticket=fixture"), + ("GET", "sessions/missing/input?ticket=fixture"), + ("POST", "sessions/missing/resize"), + ("POST", "sessions/missing/intent"), + ("POST", "sessions/missing/close"), + ("OPTIONS", "sessions"), + ] { + for body in ["{}", "invalid JSON"] { + let response = app + .clone() + .oneshot( + test_browser_request("localhost:61180", "null") + .method(method) + .uri(format!("/api/apps/home-cli/terminal/{route}")) + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .header("access-control-request-method", "POST") + .header("access-control-request-headers", "x-elastos-home-token") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{method} {route}"); + } + } +} + +#[tokio::test] +async fn test_home_cli_terminal_defaults_off_and_malformed_settings_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + let owner = passkey_authority(dir.path()); + let token = app_token_for_authority(dir.path(), HOME_CLI_CAPSULE_ID_FOR_TEST, &owner); + let app = gateway_router(test_state(dir.path())); + assert_terminal_routes_refused(&app, &token).await; + for contents in [ + "dev_mode = true\n", + "developer_mode = false\n", + "developer_mode = 'true'\n", + "developer_mode = 1\n", + "developer_mode = true\nbroken = [", + ] { + std::fs::write(dir.path().join("config.toml"), contents).unwrap(); + assert_terminal_routes_refused(&app, &token).await; + } + std::fs::remove_file(dir.path().join("config.toml")).unwrap(); + std::fs::create_dir(dir.path().join("config.toml")).unwrap(); + assert_terminal_routes_refused(&app, &token).await; +} + #[tokio::test] #[cfg(unix)] async fn test_home_cli_terminal_stream_requires_cli_launch_token() { let _guard = HOME_GATEWAY_TEST_ENV_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("config.toml"), "developer_mode = true\n").unwrap(); let _program = EnvRestore::set("ELASTOS_HOME_CLI_TERMINAL_PROGRAM", "/bin/sh".to_string()); let _args = EnvRestore::set( "ELASTOS_HOME_CLI_TERMINAL_ARGS_JSON", diff --git a/elastos/crates/elastos-server/src/config_cmd.rs b/elastos/crates/elastos-server/src/config_cmd.rs index f98a8eada..a9460cd25 100644 --- a/elastos/crates/elastos-server/src/config_cmd.rs +++ b/elastos/crates/elastos-server/src/config_cmd.rs @@ -33,6 +33,11 @@ pub fn run_config(cmd: crate::ConfigCommand) -> anyhow::Result<()> { } fn updated_config(contents: &str, key: &str, value: &str) -> anyhow::Result { + if key == "developer_mode" { + value + .parse::() + .context("developer_mode requires true or false")?; + } if key == "gateway_public_publisher_bootstrap" { value .parse::() @@ -92,6 +97,25 @@ fn render_config_show(path: &Path, contents: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn developer_mode_update_requires_a_boolean_and_preserves_settings() { + for enabled in [true, false] { + let updated = super::updated_config( + "dev_mode = true\ncarrier_bind_addr = '127.0.0.1:61967'\n", + "developer_mode", + &enabled.to_string(), + ) + .unwrap(); + let table: toml::Table = updated.parse().unwrap(); + assert_eq!(table["developer_mode"].as_bool(), Some(enabled)); + assert_eq!(table["dev_mode"].as_bool(), Some(true)); + assert_eq!(table["carrier_bind_addr"].as_str(), Some("127.0.0.1:61967")); + } + for invalid in ["1", "yes", "True", ""] { + assert!(super::updated_config("", "developer_mode", invalid).is_err()); + } + } + #[test] fn gateway_hosts_are_explicit_authorities_and_preserve_other_settings() { let updated = super::updated_config( diff --git a/elastos/crates/elastos-server/src/main.rs b/elastos/crates/elastos-server/src/main.rs index a1d13aaff..727744b98 100755 --- a/elastos/crates/elastos-server/src/main.rs +++ b/elastos/crates/elastos-server/src/main.rs @@ -1113,7 +1113,7 @@ enum ConfigCommand { Show, /// Set a configuration value Set { - /// Key to set (e.g. "dev_mode", "enable_cache") + /// Key to set (e.g. "developer_mode", "enable_cache") key: String, /// Value to set value: String, diff --git a/scripts/home-cli-fallback-recovery-smoke.mjs b/scripts/home-cli-fallback-recovery-smoke.mjs index 7703f2c13..d13171c57 100644 --- a/scripts/home-cli-fallback-recovery-smoke.mjs +++ b/scripts/home-cli-fallback-recovery-smoke.mjs @@ -10,6 +10,7 @@ const timers = []; let terminalStartCount = 0; let activeShellRequestCount = 0; let resolveRetriedTerminalStart = null; +let terminalPolicyRefused = false; class FakeElement { constructor(selector = "") { @@ -203,6 +204,9 @@ globalThis.fetch = async (url, init = {}) => { requests.push({ url: String(url), method: init.method || "GET", headers: init.headers || {}, body }); if (url === "/api/apps/home-cli/terminal/sessions") { terminalStartCount += 1; + if (terminalPolicyRefused) { + return failedResponse(403, "Web terminal requires host developer mode and closed guest registration."); + } if (terminalStartCount === 1 || terminalStartCount === 3) { return failedResponse(503, "simulated terminal start failure"); } @@ -429,4 +433,22 @@ assert( parentMessages, ); +terminalPolicyRefused = true; +for (const listener of fallbackRefresh.listeners.get("click") || []) { + listener(); +} +await waitFor( + () => output.textContent.includes("Runtime refused web terminal access."), + "home-cli did not explain a terminal policy refusal", + () => ({ output: output.textContent, requests }), +); +assert(output.textContent.includes("developer mode and guest registration"), "home-cli policy refusal lost operator guidance"); +assert(fallbackRefresh.hidden === true, "home-cli offered repeated refresh after a policy refusal"); +assert(fallbackDesktop.hidden === false, "home-cli hid Desktop recovery after a policy refusal"); +assert(document.activeElement === fallbackDesktop, "home-cli policy refusal lost Desktop recovery focus"); +for (const listener of fallbackDesktop.listeners.get("click") || []) { + await listener(); +} +assert(activeShellRequestCount === 3, "home-cli policy refusal blocked the Runtime-owned Desktop recovery"); + console.log("[home-cli-fallback-recovery] PASS");