From 9584c8567704baed96ea8831f3983ceec3ae292f Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 07:11:18 +0200 Subject: [PATCH 01/21] docs(adr): propose SSH Computers started like Termux Add ADR-0069: an App Shell installs, starts, and keeps the App Server on a Linux computer over SSH, holds its token, and forwards its loopback endpoint through the SSH connection. Covers version cases with confirmed updates, a keeper client for lifetime, system ssh on Desktop and an embedded client on Android. Add the SSH Computer term to CONTEXT.md. --- CONTEXT.md | 4 ++ ...9-ssh-computers-are-started-like-termux.md | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 docs/adr/0069-ssh-computers-are-started-like-termux.md diff --git a/CONTEXT.md b/CONTEXT.md index 31b6848d..eb869421 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -299,6 +299,10 @@ _Avoid_: Account, login, linked phone A short plain-text code, also drawn as a QR code, that adds a Remote Device. Either a trusted client shows a single-use code the new device reads, or the new device shows a code a trusted client reads and confirms. _Avoid_: Password, token, invite link +**SSH Computer**: +A Linux computer reached over SSH where an App Shell installs, starts, and keeps the App Server, and holds its token like a same-machine App Shell. It is not a Remote Device: no Pairing Code is involved. +_Avoid_: Remote host, server, SSH remote + **Desktop Update**: A newer official OpenAIDE Desktop App release offered inside the installed Desktop App. The user may review its Release Notes, initiate download and staging, and restart OpenAIDE to apply it. _Avoid_: Silently replacing a running release, treating a downloaded update as already applied diff --git a/docs/adr/0069-ssh-computers-are-started-like-termux.md b/docs/adr/0069-ssh-computers-are-started-like-termux.md new file mode 100644 index 00000000..a075617c --- /dev/null +++ b/docs/adr/0069-ssh-computers-are-started-like-termux.md @@ -0,0 +1,60 @@ +# SSH Computers Are Started Like Termux + +Status: proposed + +The Desktop App and the Mobile App can make a Linux computer reachable over SSH the place where agents work. The App Shell installs the App Server there, starts it, and reaches it the way the Mobile App reaches its App Server in Termux: it holds the per-process token and is a client of that App Server, with the loopback endpoint carried through the SSH connection instead of a local socket. No Pairing Code, key exchange with the App Server, or relay is involved; the user's SSH access is the proof. Pairing with a computer ([ADR-0063](0063-desktop-can-use-another-computers-app-server.md)) stays the way to reach an App Server someone else started. + +## Choice + +An SSH Computer is one more choice next to this computer and paired computers. Exactly one is in use at a time, and changing it restarts the App Shell as ADR-0063 does. The first release supports Linux on x64 only. + +The App Server on an SSH Computer uses that computer's default state root, the one the `remote` command uses ([ADR-0064](0064-a-terminal-command-keeps-the-app-server-reachable.md)). A computer has one OpenAIDE: Projects and Tasks started over SSH are the ones its paired devices see. + +### Versions + +The App Shell installs its own App Server release next to earlier ones, under one directory per version in the user's home. On connecting it finds one of four cases: + +- **Same version running, or none**: it attaches, or starts one. +- **Older version, compatible protocol**: it attaches like a Remote Device, by protocol compatibility ([ADR-0062](0062-remote-devices-are-app-server-clients.md)), and offers an update. +- **Older version, incompatible protocol**: it cannot attach until the computer is updated. +- **Newer version**: it refuses and asks the user to update this App Shell. + +An update happens only after the user confirms. The confirmation lists the other clients attached to that App Server with their kind, name, and version, marks the ones the update will strand, and counts running and waiting Tasks. Confirming sends the existing replacement request. An App Shell never replaces a newer App Server with an older one, so two clients of different versions cannot replace each other in turn. + +### Lifetime + +The App Server's lifetime rule does not change: it stops after its last same-machine client leaves. The App Shell starts a windowless keeper on the SSH Computer, a client like the `remote` command, outside the SSH session. The keeper stays while any of these holds: + +- a Task is running; +- a Task has waited for the user for less than 24 hours; +- an App Shell was attached within the last 30 minutes. + +When none holds, the keeper leaves and the App Server stops by the existing rule. A Task that was waiting is then interrupted and is resumed later the way any Task with a Native Session is. The App Shell also offers an explicit stop, which warns about running Tasks. + +## Mechanism + +- One Rust implementation in `openaide-remote-client` does the launch: detect the system and architecture, upload and verify the release, start the App Server through the user's login shell, read the endpoint line, apply the version cases, start the keeper, and forward the endpoint. It needs only two things from SSH: running a command and opening a channel to a loopback port. +- The Desktop App provides those through the system `ssh`, so the user's SSH configuration, jump hosts, agent, hardware keys, and interactive prompts keep working. Prompts are shown in the App Shell and never stored. +- The Mobile App embeds an SSH client. The phone generates its own key, kept encrypted with Android Keystore like its pairing key, and shows the public key to add on the computer. A password may be used once to add that key, and is not stored. Jump hosts are not supported on the phone at first. +- The App Shell downloads the matching release asset once, checks it against the SHA-256 built into the App Shell, and uploads it over SSH. The SSH Computer needs no internet access to receive it. The Windows Desktop reuses the Linux App Server it already bundles for WSL. +- Agents are installed and signed in on the SSH Computer, as in Termux. When Node is missing, a pinned Node runtime is installed in the user's home without elevated rights. +- The launcher detects a login manager that ends a user's processes when the SSH session closes and names the setting that keeps the keeper alive. +- The App Server Protocol gains the list of attached clients with kind, name, product version, and protocol version, as a minor version change. +- On the Mobile App, Task notifications for an SSH Computer come from the same foreground-service watch used for a paired computer, held over the SSH connection. +- Each launch stage, from connecting to forwarding, emits structured start and end events with a correlation identifier, outcome, duration, attempt count, and classified error, and no host names, user names, paths, or command output. + +## Considered Options + +- **SSH only to install, then pairing over iroh.** Rejected as the main path: traffic would depend on public relays, which many networks block, while SSH already reaches the computer. Pairing from an attached App Shell remains available. +- **A separate state root for SSH.** Rejected: it would split one computer's Projects and Native Sessions in two. +- **An idle timer in the App Server.** Rejected for the reason ADR-0064 gives: a second lifetime rule. The keeper is a client and decides only when to leave. +- **Updating without confirmation.** Rejected: replacement ends running Tasks and can strand other clients. +- **One embedded SSH client everywhere.** Rejected for the Desktop, because it would break existing SSH configurations. +- **The SSH Computer downloading the release itself.** Rejected: it needs internet access on the computer and a second download path. + +## Consequences + +- Secrets a user enters for an Agent or MCP server stay in the App Shell that entered them (ADR-0063), so Tasks that run while no App Shell is attached cannot read them. The App Shell says so. Moving that storage to the App Server's computer is a separate decision. +- Linux on arm64, macOS, and Windows computers need their own App Server builds and launch paths. +- The macOS Desktop and the Mobile App need network access once per release to fetch the Linux App Server. +- A waiting Task can keep the App Server alive for up to a day after its user left. From ca3a5c789eb1378198556f66139a9257d37e1aa2 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 07:22:04 +0200 Subject: [PATCH 02/21] docs(adr): accept ADR-0069 SSH Computers --- docs/adr/0069-ssh-computers-are-started-like-termux.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0069-ssh-computers-are-started-like-termux.md b/docs/adr/0069-ssh-computers-are-started-like-termux.md index a075617c..8bc72b47 100644 --- a/docs/adr/0069-ssh-computers-are-started-like-termux.md +++ b/docs/adr/0069-ssh-computers-are-started-like-termux.md @@ -1,6 +1,6 @@ # SSH Computers Are Started Like Termux -Status: proposed +Status: accepted The Desktop App and the Mobile App can make a Linux computer reachable over SSH the place where agents work. The App Shell installs the App Server there, starts it, and reaches it the way the Mobile App reaches its App Server in Termux: it holds the per-process token and is a client of that App Server, with the loopback endpoint carried through the SSH connection instead of a local socket. No Pairing Code, key exchange with the App Server, or relay is involved; the user's SSH access is the proof. Pairing with a computer ([ADR-0063](0063-desktop-can-use-another-computers-app-server.md)) stays the way to reach an App Server someone else started. From da1bb7db0945e55c796008f940edacfac061ba5f Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 07:31:28 +0200 Subject: [PATCH 03/21] feat(protocol): report what replacing the App Server would interrupt Add client/replacementImpact (protocol 2.2). It lists the other attached clients with their kind, name, product version, protocol version, and whether they are reconnecting, plus active turns and pending Task requests, without starting a replacement. A client that installed another App Server version shows it before the user confirms the update (ADR-0069). The App Server now keeps each client's declared protocol version. --- openaide-rs/app-server-protocol/src/client.rs | 38 +++++- .../app-server-protocol/src/methods.rs | 13 +- .../app-server-protocol/src/methods/names.rs | 2 + .../app-server-protocol/src/methods/tests.rs | 1 + .../src/typescript/declarations.rs | 10 +- .../src/typescript/method_constants.rs | 48 ++++---- .../src/typescript/method_maps.rs | 8 ++ .../app-server/src/client_lifecycle.rs | 31 ++++- .../src/protocol_edge/client_handlers.rs | 50 +++++++- .../app-server/src/protocol_edge/responses.rs | 9 ++ .../app-server/src/protocol_edge/routing.rs | 46 +++---- .../app-server/src/protocol_edge/tests.rs | 1 + .../protocol_edge/tests/replacement_impact.rs | 116 ++++++++++++++++++ .../src/generated/protocol.ts | 32 ++++- 14 files changed, 351 insertions(+), 54 deletions(-) create mode 100644 openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs diff --git a/openaide-rs/app-server-protocol/src/client.rs b/openaide-rs/app-server-protocol/src/client.rs index d6765a3e..0c469aa7 100644 --- a/openaide-rs/app-server-protocol/src/client.rs +++ b/openaide-rs/app-server-protocol/src/client.rs @@ -8,7 +8,7 @@ use crate::snapshot::{ClientSnapshot, ProjectCollectionSnapshot, ProtocolVersion /// /// Bump `minor` when adding methods, fields, or events. Bump `major` and reset /// `minor` for any change an older client cannot ignore. -pub const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 2, minor: 1 }; +pub const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion { major: 2, minor: 2 }; #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] @@ -85,6 +85,42 @@ pub struct UpdateShutdownAbortParams { #[serde(rename_all = "camelCase")] pub struct UpdateShutdownAbortResult {} +/// Asks what replacing this App Server with another version would affect now. A client +/// that installed a different version shows the answer before the user confirms the +/// replacement (ADR-0069). +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientReplacementImpactParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientReplacementImpactResult { + /// Initialized clients other than the caller, including those inside their reconnect + /// grace, which a replacement would also disconnect. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub other_clients: Vec, + pub active_turns: u32, + pub pending_task_requests: u32, +} + +/// What a client reported about itself in `client/initialize`. Labels for the user, not +/// evidence. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct AttachedClientSummary { + pub kind: ShellKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// The product version the client reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_version: Option, + /// Omitted by clients installed together with their App Server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol_version: Option, + /// The client stopped responding and is inside its reconnect grace. + pub reconnecting: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] pub enum ClientProbeLifecycle { diff --git a/openaide-rs/app-server-protocol/src/methods.rs b/openaide-rs/app-server-protocol/src/methods.rs index acc91567..3297c3c7 100644 --- a/openaide-rs/app-server-protocol/src/methods.rs +++ b/openaide-rs/app-server-protocol/src/methods.rs @@ -29,9 +29,10 @@ use crate::attachment::{ use crate::client::{ ClientCapabilitiesChangedParams, ClientCapabilitiesChangedResult, ClientDetachParams, ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, ClientProbeParams, - ClientProbeResult, InitializeParams, InitializeResult, UpdateShutdownAbortParams, - UpdateShutdownAbortResult, UpdateShutdownCommitParams, UpdateShutdownCommitResult, - UpdateShutdownPrepareParams, UpdateShutdownPrepareResult, + ClientProbeResult, ClientReplacementImpactParams, ClientReplacementImpactResult, + InitializeParams, InitializeResult, UpdateShutdownAbortParams, UpdateShutdownAbortResult, + UpdateShutdownCommitParams, UpdateShutdownCommitResult, UpdateShutdownPrepareParams, + UpdateShutdownPrepareResult, }; use crate::diagnostics::{ RuntimeDiagnosticsParams, RuntimeDiagnosticsResult, SupportExportCreateParams, @@ -192,6 +193,12 @@ protocol_method!( UpdateShutdownAbortParams, UpdateShutdownAbortResult ); +protocol_method!( + ClientReplacementImpact, + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams, + ClientReplacementImpactResult +); protocol_method!( PendingRequestResolve, PENDING_REQUEST_RESOLVE, diff --git a/openaide-rs/app-server-protocol/src/methods/names.rs b/openaide-rs/app-server-protocol/src/methods/names.rs index 11597220..ffbcbbc6 100644 --- a/openaide-rs/app-server-protocol/src/methods/names.rs +++ b/openaide-rs/app-server-protocol/src/methods/names.rs @@ -10,6 +10,7 @@ pub const CLIENT_DETACH: &str = "client/detach"; pub const CLIENT_UPDATE_SHUTDOWN_PREPARE: &str = "client/updateShutdownPrepare"; pub const CLIENT_UPDATE_SHUTDOWN_COMMIT: &str = "client/updateShutdownCommit"; pub const CLIENT_UPDATE_SHUTDOWN_ABORT: &str = "client/updateShutdownAbort"; +pub const CLIENT_REPLACEMENT_IMPACT: &str = "client/replacementImpact"; pub const PENDING_REQUEST_RESOLVE: &str = "pendingRequest/resolve"; pub const STATE_SUBSCRIBE: &str = "state/subscribe"; pub const STATE_UNSUBSCRIBE: &str = "state/unsubscribe"; @@ -126,6 +127,7 @@ pub const CLIENT_METHODS: &[&str] = &[ CLIENT_UPDATE_SHUTDOWN_PREPARE, CLIENT_UPDATE_SHUTDOWN_COMMIT, CLIENT_UPDATE_SHUTDOWN_ABORT, + CLIENT_REPLACEMENT_IMPACT, PENDING_REQUEST_RESOLVE, STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, diff --git a/openaide-rs/app-server-protocol/src/methods/tests.rs b/openaide-rs/app-server-protocol/src/methods/tests.rs index d9cc0098..949e77ad 100644 --- a/openaide-rs/app-server-protocol/src/methods/tests.rs +++ b/openaide-rs/app-server-protocol/src/methods/tests.rs @@ -5,6 +5,7 @@ fn client_initialize_uses_slash_method_namespace() { assert_eq!(ClientProbe::METHOD, "client/probe"); assert_eq!(ClientInitialize::METHOD, "client/initialize"); assert_eq!(ClientDetach::METHOD, "client/detach"); + assert_eq!(ClientReplacementImpact::METHOD, "client/replacementImpact"); assert_eq!( ClientUpdateShutdownPrepare::METHOD, "client/updateShutdownPrepare" diff --git a/openaide-rs/app-server-protocol/src/typescript/declarations.rs b/openaide-rs/app-server-protocol/src/typescript/declarations.rs index ed73f80d..74f076f0 100644 --- a/openaide-rs/app-server-protocol/src/typescript/declarations.rs +++ b/openaide-rs/app-server-protocol/src/typescript/declarations.rs @@ -36,9 +36,10 @@ use crate::attachment::{ PreSendAttachment, }; use crate::client::{ - ClientCapabilities, ClientCapabilitiesChangedParams, ClientCapabilitiesChangedResult, - ClientDetachParams, ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, - ClientProbeLifecycle, ClientProbeParams, ClientProbeResult, ClientProtocolCapability, + AttachedClientSummary, ClientCapabilities, ClientCapabilitiesChangedParams, + ClientCapabilitiesChangedResult, ClientDetachParams, ClientDetachResult, ClientHeartbeatParams, + ClientHeartbeatResult, ClientProbeLifecycle, ClientProbeParams, ClientProbeResult, + ClientProtocolCapability, ClientReplacementImpactParams, ClientReplacementImpactResult, ClientWorkspaceRoot, InitializeParams, InitializeResult, RequestedSurface, SettingsSection, ShellCapability, ShellDescriptor, ShellKind, UpdateShutdownAbortParams, UpdateShutdownAbortResult, UpdateShutdownBlockedReason, UpdateShutdownCommitParams, @@ -238,6 +239,9 @@ pub(super) fn push_protocol_declarations(output: &mut String, config: &Config) { push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); + push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); push_decl::(output, config); diff --git a/openaide-rs/app-server-protocol/src/typescript/method_constants.rs b/openaide-rs/app-server-protocol/src/typescript/method_constants.rs index a1146ad7..3f91c561 100644 --- a/openaide-rs/app-server-protocol/src/typescript/method_constants.rs +++ b/openaide-rs/app-server-protocol/src/typescript/method_constants.rs @@ -8,27 +8,27 @@ use crate::methods::{ ATTACHMENT_CREATE_PASTED_IMAGE, ATTACHMENT_LIST_DIRECTORY, ATTACHMENT_LIST_ROOTS, ATTACHMENT_REFRESH_HANDLES, ATTACHMENT_RELEASE, ATTACHMENT_REVEAL, ATTACHMENT_REVEAL_SENT, CLIENT_CAPABILITIES_CHANGED, CLIENT_DETACH, CLIENT_HEARTBEAT, CLIENT_INITIALIZE, CLIENT_PROBE, - CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, CLIENT_UPDATE_SHUTDOWN_PREPARE, - DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, DIAGNOSTICS_LIST_SUPPORT_EXPORT, - FILE_VIEWER_OPEN, FILE_VIEWER_OPEN_FROM_HANDLE, FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, - MCP_CREATE_SERVER, MCP_DELETE_SERVER, MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, - MCP_UPDATE_SERVER, NATIVE_SESSION_ARCHIVE, NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, - NATIVE_SESSION_RESTORE, NATIVE_SESSION_SET_PINNED, NATIVE_SESSION_SET_TITLE, - PENDING_REQUEST_RESOLVE, PROJECT_ADD, PROJECT_REFRESH, PROJECT_REMOVE, PROJECT_RENAME, - SETTINGS_GET_AGENT_DETAILS, SETTINGS_GET_MCP_SERVERS, SETTINGS_GET_PREFERENCES, - SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, SETTINGS_GET_SKILL_DETAILS, - SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, SETTINGS_UPDATE_PREFERENCES, - SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, - SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, TASK_ACQUIRE_IN_WORKTREE, - TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, TASK_CANCEL, TASK_CHAT_PAGE, - TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, TASK_NAVIGATION_LOAD_MORE, - TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, - TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, TASK_RESTORE, TASK_SEARCH_FILES, - TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, TASK_SET_PINNED, TASK_SET_TITLE, - TASK_STOP_BACKGROUND_COMMAND, TASK_STOP_SUBAGENT, TASK_TOOL_IMAGE_PREVIEW, - WORKSPACE_LIST_DIRECTORY, WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, WORKTREE_LINKED_TASKS, - WORKTREE_RECREATE, WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, WORKTREE_REMOVE, - WORKTREE_RENAME, WORKTREE_RESOLVE_FOLDER, + CLIENT_REPLACEMENT_IMPACT, CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, + CLIENT_UPDATE_SHUTDOWN_PREPARE, DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, + DIAGNOSTICS_LIST_SUPPORT_EXPORT, FILE_VIEWER_OPEN, FILE_VIEWER_OPEN_FROM_HANDLE, + FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, MCP_CREATE_SERVER, MCP_DELETE_SERVER, + MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, MCP_UPDATE_SERVER, NATIVE_SESSION_ARCHIVE, + NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, NATIVE_SESSION_RESTORE, NATIVE_SESSION_SET_PINNED, + NATIVE_SESSION_SET_TITLE, PENDING_REQUEST_RESOLVE, PROJECT_ADD, PROJECT_REFRESH, + PROJECT_REMOVE, PROJECT_RENAME, SETTINGS_GET_AGENT_DETAILS, SETTINGS_GET_MCP_SERVERS, + SETTINGS_GET_PREFERENCES, SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, + SETTINGS_GET_SKILL_DETAILS, SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, + SETTINGS_UPDATE_PREFERENCES, SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, + STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, + TASK_ACQUIRE_IN_WORKTREE, TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, + TASK_CANCEL, TASK_CHAT_PAGE, TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, + TASK_NAVIGATION_LOAD_MORE, TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, + TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, + TASK_RESTORE, TASK_SEARCH_FILES, TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, + TASK_SET_PINNED, TASK_SET_TITLE, TASK_STOP_BACKGROUND_COMMAND, TASK_STOP_SUBAGENT, + TASK_TOOL_IMAGE_PREVIEW, WORKSPACE_LIST_DIRECTORY, WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, + WORKTREE_LINKED_TASKS, WORKTREE_RECREATE, WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, + WORKTREE_REMOVE, WORKTREE_RENAME, WORKTREE_RESOLVE_FOLDER, }; use crate::methods::{ DEVICES_APPROVE_JOIN_REQUEST, DEVICES_CANCEL_INVITE, DEVICES_CREATE_INVITE, @@ -88,9 +88,13 @@ pub(super) fn push_method_constants(output: &mut String) { CLIENT_UPDATE_SHUTDOWN_COMMIT )); output.push_str(&format!( - "export const CLIENT_UPDATE_SHUTDOWN_ABORT = {:?} as const;\n\n", + "export const CLIENT_UPDATE_SHUTDOWN_ABORT = {:?} as const;\n", CLIENT_UPDATE_SHUTDOWN_ABORT )); + output.push_str(&format!( + "export const CLIENT_REPLACEMENT_IMPACT = {:?} as const;\n\n", + CLIENT_REPLACEMENT_IMPACT + )); output.push_str(&format!( "export const PENDING_REQUEST_RESOLVE = {:?} as const;\n\n", PENDING_REQUEST_RESOLVE diff --git a/openaide-rs/app-server-protocol/src/typescript/method_maps.rs b/openaide-rs/app-server-protocol/src/typescript/method_maps.rs index 966e4ec7..ee93022b 100644 --- a/openaide-rs/app-server-protocol/src/typescript/method_maps.rs +++ b/openaide-rs/app-server-protocol/src/typescript/method_maps.rs @@ -11,6 +11,10 @@ pub(super) fn push_method_maps(output: &mut String) { output.len() - ";\n".len(), " | typeof DEVICES_CREATE_INVITE | typeof DEVICES_CANCEL_INVITE | typeof DEVICES_PREVIEW_JOIN_REQUEST | typeof DEVICES_APPROVE_JOIN_REQUEST | typeof DEVICES_REMOVE", ); + output.insert_str( + output.len() - ";\n".len(), + " | typeof CLIENT_REPLACEMENT_IMPACT", + ); output.push_str("export type RequestParamsByMethod = {\n"); output.push_str(" [FILE_VIEWER_LIST_DIRECTORY]: ProjectFilesParams;\n"); output.push_str(" [FILE_VIEWER_SEARCH]: ProjectFilesParams;\n"); @@ -25,6 +29,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str(" [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareParams;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitParams;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortParams;\n"); + output.push_str(" [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactParams;\n"); output.push_str(" [PENDING_REQUEST_RESOLVE]: PendingRequestResolveParams;\n"); output.push_str(" [STATE_SUBSCRIBE]: StateSubscribeParams;\n"); output.push_str(" [STATE_UNSUBSCRIBE]: StateUnsubscribeParams;\n"); @@ -148,6 +153,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str(" [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareResult;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitResult;\n"); output.push_str(" [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortResult;\n"); + output.push_str(" [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactResult;\n"); output.push_str(" [PENDING_REQUEST_RESOLVE]: PendingRequestResolveResult;\n"); output.push_str(" [STATE_SUBSCRIBE]: StateSubscribeResult;\n"); output.push_str(" [STATE_UNSUBSCRIBE]: StateUnsubscribeResult;\n"); @@ -274,6 +280,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str("export type ClientUpdateShutdownPrepareRequest = TypedClientRequest;\n"); output.push_str("export type ClientUpdateShutdownCommitRequest = TypedClientRequest;\n"); output.push_str("export type ClientUpdateShutdownAbortRequest = TypedClientRequest;\n"); + output.push_str("export type ClientReplacementImpactRequest = TypedClientRequest;\n"); output.push_str( "export type ClientCapabilitiesChangedRequest = TypedClientRequest;\n", ); @@ -288,6 +295,7 @@ pub(super) fn push_method_maps(output: &mut String) { output.push_str("export type ClientUpdateShutdownPrepareResponse = ResponseEnvelope;\n"); output.push_str("export type ClientUpdateShutdownCommitResponse = ResponseEnvelope;\n"); output.push_str("export type ClientUpdateShutdownAbortResponse = ResponseEnvelope;\n"); + output.push_str("export type ClientReplacementImpactResponse = ResponseEnvelope;\n"); output .push_str("export type StateSubscribeResponse = ResponseEnvelope;\n"); output.push_str( diff --git a/openaide-rs/app-server/src/client_lifecycle.rs b/openaide-rs/app-server/src/client_lifecycle.rs index 4ca0b61e..1ad50d9a 100644 --- a/openaide-rs/app-server/src/client_lifecycle.rs +++ b/openaide-rs/app-server/src/client_lifecycle.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; use openaide_app_server_protocol::client::{ - ClientCapabilities, RequestedSurface, ShellCapability, ShellDescriptor, + AttachedClientSummary, ClientCapabilities, RequestedSurface, ShellCapability, ShellDescriptor, }; use openaide_app_server_protocol::ids::ClientInstanceId; +use openaide_app_server_protocol::snapshot::ProtocolVersion; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ConnectionId(String); @@ -133,6 +134,8 @@ impl Delivery { #[derive(Debug, Clone)] struct ClientRecord { context: ClientContext, + /// The protocol version the client declared; reported, never used for routing. + protocol_version: Option, reconnect_expires_at: Option, liveness_expires_at: AppServerTime, } @@ -178,6 +181,7 @@ impl ClientHub { now: AppServerTime, ) -> InitializeClientOutcome { self.has_ever_initialized = true; + let protocol_version = params.protocol_version; let context = ClientContext { client_instance_id: params.client_instance_id, connection_id: connection_id.clone(), @@ -194,6 +198,7 @@ impl ClientHub { context.client_instance_id.clone(), ClientRecord { context: context.clone(), + protocol_version, reconnect_expires_at: None, liveness_expires_at: AppServerTime( now.0 + self.liveness_policy.heartbeat_timeout_ms, @@ -353,6 +358,30 @@ impl ClientHub { .any(|candidate| candidate != client_instance_id) } + /// Describes every initialized client except `client_instance_id`, reconnecting ones + /// included, in a stable order. Replacing the App Server disconnects all of them. + pub fn other_client_summaries( + &self, + client_instance_id: &ClientInstanceId, + ) -> Vec { + let mut others = self + .clients + .iter() + .filter(|(candidate, _)| *candidate != client_instance_id) + .collect::>(); + others.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + others + .into_iter() + .map(|(_, record)| AttachedClientSummary { + kind: record.context.shell.kind, + name: record.context.shell.name.clone(), + app_version: record.context.shell.version.clone(), + protocol_version: record.protocol_version, + reconnecting: record.reconnect_expires_at.is_some(), + }) + .collect() + } + pub fn has_ever_initialized_clients(&self) -> bool { self.has_ever_initialized } diff --git a/openaide-rs/app-server/src/protocol_edge/client_handlers.rs b/openaide-rs/app-server/src/protocol_edge/client_handlers.rs index ed5b800d..274f399c 100644 --- a/openaide-rs/app-server/src/protocol_edge/client_handlers.rs +++ b/openaide-rs/app-server/src/protocol_edge/client_handlers.rs @@ -1,6 +1,7 @@ use openaide_app_server_protocol::client::{ ClientCapabilitiesChangedParams, ClientCapabilitiesChangedResult, ClientDetachParams, - ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, UpdateShutdownAbortParams, + ClientDetachResult, ClientHeartbeatParams, ClientHeartbeatResult, + ClientReplacementImpactParams, ClientReplacementImpactResult, UpdateShutdownAbortParams, UpdateShutdownAbortResult, UpdateShutdownBlockedReason, UpdateShutdownCommitParams, UpdateShutdownCommitResult, UpdateShutdownPrepareParams, UpdateShutdownPrepareResult, }; @@ -123,6 +124,49 @@ impl RpcGateway { responses::result(connection_id, id, meta, ClientDetachResult {}) } + /// Reports who and what a replacement by another version would interrupt, without + /// starting one. The caller is excluded: it asked and already knows. + pub(super) fn handle_client_replacement_impact( + &mut self, + connection_id: ConnectionId, + id: String, + params: Value, + meta: RequestMeta, + ) -> GatewayOutcome { + if let Err(error) = serde_json::from_value::(params) { + return self.error(connection_id, id, meta, responses::invalid_params(error)); + } + let Some(context) = self.client_hub.context_for_connection(&connection_id) else { + return self.error( + connection_id, + id, + meta, + responses::not_initialized( + openaide_app_server_protocol::methods::CLIENT_REPLACEMENT_IMPACT.to_string(), + ), + ); + }; + let blockers = match self.shutdown.shutdown_blockers() { + Ok(blockers) => blockers, + Err(_error) => { + return self.error( + connection_id, + id, + meta, + responses::replacement_impact_failed(), + ) + } + }; + let result = ClientReplacementImpactResult { + other_clients: self + .client_hub + .other_client_summaries(&context.client_instance_id), + active_turns: saturating_u32(blockers.active_turns), + pending_task_requests: saturating_u32(blockers.pending_task_requests), + }; + responses::result(connection_id, id, meta, result) + } + pub(super) fn handle_update_shutdown_prepare( &mut self, connection_id: ConnectionId, @@ -299,6 +343,10 @@ impl RpcGateway { } } +fn saturating_u32(count: usize) -> u32 { + count.try_into().unwrap_or(u32::MAX) +} + fn valid_update_attempt_id(attempt_id: &str) -> bool { !attempt_id.is_empty() && attempt_id.len() <= 128 } diff --git a/openaide-rs/app-server/src/protocol_edge/responses.rs b/openaide-rs/app-server/src/protocol_edge/responses.rs index d7305b07..7f2811c2 100644 --- a/openaide-rs/app-server/src/protocol_edge/responses.rs +++ b/openaide-rs/app-server/src/protocol_edge/responses.rs @@ -189,6 +189,15 @@ pub fn update_shutdown_readiness_failed() -> ProtocolError { } } +pub fn replacement_impact_failed() -> ProtocolError { + ProtocolError { + code: ProtocolErrorCode::Internal, + message: "Could not determine what replacing the App Server would interrupt".to_string(), + recoverable: true, + target: None, + } +} + pub fn unsupported_method(method: &str) -> ProtocolError { ProtocolError { code: ProtocolErrorCode::InvalidRequest, diff --git a/openaide-rs/app-server/src/protocol_edge/routing.rs b/openaide-rs/app-server/src/protocol_edge/routing.rs index 8d8d6f6d..f5fd5834 100644 --- a/openaide-rs/app-server/src/protocol_edge/routing.rs +++ b/openaide-rs/app-server/src/protocol_edge/routing.rs @@ -8,27 +8,28 @@ use openaide_app_server_protocol::methods::{ ATTACHMENT_CREATE_PASTED_IMAGE, ATTACHMENT_LIST_DIRECTORY, ATTACHMENT_LIST_ROOTS, ATTACHMENT_REFRESH_HANDLES, ATTACHMENT_RELEASE, ATTACHMENT_REVEAL, ATTACHMENT_REVEAL_SENT, CLIENT_CAPABILITIES_CHANGED, CLIENT_DETACH, CLIENT_HEARTBEAT, CLIENT_INITIALIZE, CLIENT_PROBE, - CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, CLIENT_UPDATE_SHUTDOWN_PREPARE, - DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, DIAGNOSTICS_LIST_SUPPORT_EXPORT, - FILE_VIEWER_CHANGES, FILE_VIEWER_DIFF, FILE_VIEWER_LIST_DIRECTORY, FILE_VIEWER_OPEN, - FILE_VIEWER_OPEN_FROM_HANDLE, FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, FILE_VIEWER_SEARCH, - MCP_CREATE_SERVER, MCP_DELETE_SERVER, MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, - MCP_UPDATE_SERVER, NATIVE_SESSION_ARCHIVE, NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, - NATIVE_SESSION_RESTORE, NATIVE_SESSION_SET_PINNED, NATIVE_SESSION_SET_TITLE, - PENDING_REQUEST_RESOLVE, PROJECT_ADD, PROJECT_REFRESH, PROJECT_REMOVE, PROJECT_RENAME, - SETTINGS_GET_AGENT_DETAILS, SETTINGS_GET_MCP_SERVERS, SETTINGS_GET_PREFERENCES, - SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, SETTINGS_GET_SKILL_DETAILS, - SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, SETTINGS_UPDATE_PREFERENCES, - SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, - SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, TASK_ACQUIRE_IN_WORKTREE, - TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, TASK_CANCEL, TASK_CHAT_PAGE, - TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, TASK_NAVIGATION_LOAD_MORE, - TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, - TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, TASK_RESTORE, TASK_SEARCH_FILES, - TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, TASK_SET_PINNED, TASK_SET_TITLE, - TASK_TOOL_IMAGE_PREVIEW, WORKSPACE_LIST_DIRECTORY, WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, - WORKTREE_LINKED_TASKS, WORKTREE_RECREATE, WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, - WORKTREE_REMOVE, WORKTREE_RENAME, WORKTREE_RESOLVE_FOLDER, + CLIENT_REPLACEMENT_IMPACT, CLIENT_UPDATE_SHUTDOWN_ABORT, CLIENT_UPDATE_SHUTDOWN_COMMIT, + CLIENT_UPDATE_SHUTDOWN_PREPARE, DIAGNOSTICS_CREATE_SUPPORT_EXPORT, DIAGNOSTICS_GET_RUNTIME, + DIAGNOSTICS_LIST_SUPPORT_EXPORT, FILE_VIEWER_CHANGES, FILE_VIEWER_DIFF, + FILE_VIEWER_LIST_DIRECTORY, FILE_VIEWER_OPEN, FILE_VIEWER_OPEN_FROM_HANDLE, + FILE_VIEWER_REFRESH, FILE_VIEWER_RELEASE, FILE_VIEWER_SEARCH, MCP_CREATE_SERVER, + MCP_DELETE_SERVER, MCP_GET_SERVER_DETAILS, MCP_SET_SERVER_ENABLED, MCP_UPDATE_SERVER, + NATIVE_SESSION_ARCHIVE, NATIVE_SESSION_DELETE, NATIVE_SESSION_FORK, NATIVE_SESSION_RESTORE, + NATIVE_SESSION_SET_PINNED, NATIVE_SESSION_SET_TITLE, PENDING_REQUEST_RESOLVE, PROJECT_ADD, + PROJECT_REFRESH, PROJECT_REMOVE, PROJECT_RENAME, SETTINGS_GET_AGENT_DETAILS, + SETTINGS_GET_MCP_SERVERS, SETTINGS_GET_PREFERENCES, SETTINGS_GET_RUNTIME, SETTINGS_GET_SKILLS, + SETTINGS_GET_SKILL_DETAILS, SETTINGS_RESET_TASK_HISTORY, SETTINGS_UPDATE_NEW_TASK_DEFAULTS, + SETTINGS_UPDATE_PREFERENCES, SETTINGS_UPDATE_RUNTIME, SHELL_RESOLVE_FILE_REVEAL, + STATE_SUBSCRIBE, STATE_UNSUBSCRIBE, SUPPORT_RECOVER_STUCK_SESSIONS, TASK_ACQUIRE, + TASK_ACQUIRE_IN_WORKTREE, TASK_ADOPT_NATIVE_SESSION, TASK_ARCHIVE, TASK_ARCHIVE_OLDER, + TASK_CANCEL, TASK_CHAT_PAGE, TASK_CLOSE_PLAN, TASK_COMPOSER_HISTORY, TASK_LIST, TASK_MARK_READ, + TASK_NAVIGATION_LOAD_MORE, TASK_NAVIGATION_REFRESH, TASK_OPEN, TASK_QUEUE_APPEND, + TASK_QUEUE_MOVE, TASK_QUEUE_REMOVE, TASK_QUEUE_TAKE, TASK_RELEASE, TASK_RELOAD_NATIVE_SESSION, + TASK_RESTORE, TASK_SEARCH_FILES, TASK_SEND, TASK_SET_CONFIG_OPTION, TASK_SET_PERMISSION_POLICY, + TASK_SET_PINNED, TASK_SET_TITLE, TASK_TOOL_IMAGE_PREVIEW, WORKSPACE_LIST_DIRECTORY, + WORKSPACE_LIST_ROOTS, WORKTREE_CREATE, WORKTREE_LINKED_TASKS, WORKTREE_RECREATE, + WORKTREE_REFRESH, WORKTREE_REMOVAL_PREFLIGHT, WORKTREE_REMOVE, WORKTREE_RENAME, + WORKTREE_RESOLVE_FOLDER, }; use crate::client_lifecycle::{AppServerTime, ConnectionId}; @@ -154,6 +155,9 @@ impl RpcGateway { } CLIENT_HEARTBEAT => self.handle_client_heartbeat(connection_id, id, params, meta, now), CLIENT_DETACH => self.handle_client_detach(connection_id, id, params, meta, now), + CLIENT_REPLACEMENT_IMPACT => { + self.handle_client_replacement_impact(connection_id, id, params, meta) + } CLIENT_UPDATE_SHUTDOWN_PREPARE => { self.handle_update_shutdown_prepare(connection_id, id, params, meta) } diff --git a/openaide-rs/app-server/src/protocol_edge/tests.rs b/openaide-rs/app-server/src/protocol_edge/tests.rs index 2a4c4f3c..98999272 100644 --- a/openaide-rs/app-server/src/protocol_edge/tests.rs +++ b/openaide-rs/app-server/src/protocol_edge/tests.rs @@ -73,6 +73,7 @@ use super::*; mod client_probe; mod devices; mod gateway_lock_wait; +mod replacement_impact; #[test] fn rejects_product_request_before_initialize() { diff --git a/openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs b/openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs new file mode 100644 index 00000000..7588fad1 --- /dev/null +++ b/openaide-rs/app-server/src/protocol_edge/tests/replacement_impact.rs @@ -0,0 +1,116 @@ +use openaide_app_server_protocol::client::ClientReplacementImpactParams; +use openaide_app_server_protocol::methods::CLIENT_REPLACEMENT_IMPACT; +use openaide_app_server_protocol::snapshot::ProtocolVersion; +use serde_json::json; + +use super::*; + +#[test] +fn reports_other_clients_and_active_work_without_starting_a_replacement() { + let mut gateway = gateway(); + gateway.shutdown = Arc::new(FixedShutdownWithBlockers { + active_turns: 2, + pending_task_requests: 1, + }); + initialize_client(&mut gateway, ConnectionId::new("conn-1")); + let mut phone = init_params("client-2"); + phone.shell = ShellDescriptor { + kind: ShellKind::Mobile, + name: Some("Pixel".to_string()), + version: Some("0.9.0".to_string()), + }; + phone.protocol_version = Some(ProtocolVersion { major: 2, minor: 1 }); + response_value(gateway.handle_inbound( + ConnectionId::new("conn-2"), + request("2", CLIENT_INITIALIZE, phone), + AppServerTime(2), + )); + let mut terminal = init_params("client-3"); + terminal.shell = ShellDescriptor { + kind: ShellKind::Terminal, + name: None, + version: Some("0.10.0".to_string()), + }; + response_value(gateway.handle_inbound( + ConnectionId::new("conn-3"), + request("3", CLIENT_INITIALIZE, terminal), + AppServerTime(3), + )); + gateway.handle_transport_closed(&ConnectionId::new("conn-3"), AppServerTime(4)); + + let impact = response_value(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "4", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(5), + )); + + assert_eq!( + impact["result"], + json!({ + "otherClients": [ + { + "kind": "mobile", + "name": "Pixel", + "appVersion": "0.9.0", + "protocolVersion": { "major": 2, "minor": 1 }, + "reconnecting": false + }, + { "kind": "terminal", "appVersion": "0.10.0", "reconnecting": true } + ], + "activeTurns": 2, + "pendingTaskRequests": 1 + }) + ); + response_value(gateway.handle_inbound( + ConnectionId::new("conn-2"), + request( + "5", + STATE_SUBSCRIBE, + StateSubscribeParams { + scope: SubscriptionScope::Projects, + }, + ), + AppServerTime(6), + )); +} + +#[test] +fn a_lone_idle_client_sees_no_impact() { + let mut gateway = initialized_gateway("client-1", "conn-1"); + + let impact = response_value(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "2", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(2), + )); + + assert_eq!( + impact["result"], + json!({ "activeTurns": 0, "pendingTaskRequests": 0 }) + ); +} + +#[test] +fn requires_an_initialized_client() { + let mut gateway = gateway(); + + let error = response_error(gateway.handle_inbound( + ConnectionId::new("conn-1"), + request( + "1", + CLIENT_REPLACEMENT_IMPACT, + ClientReplacementImpactParams {}, + ), + AppServerTime(1), + )); + + assert_eq!(error.error.code, ProtocolErrorCode::NotInitialized); +} diff --git a/packages/app-server-client/src/generated/protocol.ts b/packages/app-server-client/src/generated/protocol.ts index 9fa2a7f9..cdd18fb9 100644 --- a/packages/app-server-client/src/generated/protocol.ts +++ b/packages/app-server-client/src/generated/protocol.ts @@ -13,6 +13,7 @@ export const CLIENT_DETACH = "client/detach" as const; export const CLIENT_UPDATE_SHUTDOWN_PREPARE = "client/updateShutdownPrepare" as const; export const CLIENT_UPDATE_SHUTDOWN_COMMIT = "client/updateShutdownCommit" as const; export const CLIENT_UPDATE_SHUTDOWN_ABORT = "client/updateShutdownAbort" as const; +export const CLIENT_REPLACEMENT_IMPACT = "client/replacementImpact" as const; export const PENDING_REQUEST_RESOLVE = "pendingRequest/resolve" as const; @@ -250,6 +251,29 @@ export type UpdateShutdownAbortParams = { attemptId: string, }; export type UpdateShutdownAbortResult = Record; +export type ClientReplacementImpactParams = Record; + +export type ClientReplacementImpactResult = { +/** + * Initialized clients other than the caller, including those inside their reconnect + * grace, which a replacement would also disconnect. + */ +otherClients?: Array, activeTurns: number, pendingTaskRequests: number, }; + +export type AttachedClientSummary = { kind: ShellKind, name?: string | null, +/** + * The product version the client reported. + */ +appVersion?: string | null, +/** + * Omitted by clients installed together with their App Server. + */ +protocolVersion?: ProtocolVersion | null, +/** + * The client stopped responding and is inside its reconnect grace. + */ +reconnecting: boolean, }; + export type ClientProbeLifecycle = "running" | "draining" | "stopping"; export type InitializeParams = { clientInstanceId: ClientInstanceId, shell: ShellDescriptor, requestedSurface: RequestedSurface, capabilities?: ClientCapabilities, @@ -1529,7 +1553,7 @@ export type PendingRequestScope = { "kind": "client", clientInstanceId: ClientIn export type PendingRequestKind = "permission" | "question" | "secret" | "shellCapability"; -export type ProtocolMethod = typeof CLIENT_PROBE | typeof CLIENT_INITIALIZE | typeof CLIENT_CAPABILITIES_CHANGED | typeof CLIENT_HEARTBEAT | typeof CLIENT_DETACH | typeof PENDING_REQUEST_RESOLVE | typeof STATE_SUBSCRIBE | typeof STATE_UNSUBSCRIBE | typeof DIAGNOSTICS_GET_RUNTIME | typeof SUPPORT_RECOVER_STUCK_SESSIONS | typeof AGENT_PROBE | typeof AGENT_AUTHENTICATE | typeof AGENT_LIST_SESSIONS | typeof AGENT_CREATE_CUSTOM | typeof AGENT_UPDATE_CUSTOM_METADATA | typeof AGENT_REPLACE_CUSTOM | typeof AGENT_DELETE_CUSTOM | typeof AGENT_SET_ENABLED | typeof SETTINGS_GET_AGENT_DETAILS | typeof SETTINGS_GET_MCP_SERVERS | typeof MCP_GET_SERVER_DETAILS | typeof MCP_CREATE_SERVER | typeof MCP_UPDATE_SERVER | typeof MCP_DELETE_SERVER | typeof MCP_SET_SERVER_ENABLED | typeof SETTINGS_GET_SKILLS | typeof SETTINGS_GET_SKILL_DETAILS | typeof SETTINGS_GET_PREFERENCES | typeof SETTINGS_UPDATE_PREFERENCES | typeof SETTINGS_UPDATE_NEW_TASK_DEFAULTS | typeof SETTINGS_GET_RUNTIME | typeof SETTINGS_UPDATE_RUNTIME | typeof ATTACHMENT_LIST_ROOTS | typeof ATTACHMENT_LIST_DIRECTORY | typeof ATTACHMENT_CREATE_FILE_REFERENCE | typeof ATTACHMENT_CREATE_LOCAL_FILE_REFERENCES | typeof ATTACHMENT_CREATE_PASTED_IMAGE | typeof ATTACHMENT_CREATE_EMBEDDED_CANDIDATE | typeof ATTACHMENT_CONFIRM_EMBEDDED | typeof ATTACHMENT_REFRESH_HANDLES | typeof ATTACHMENT_RELEASE | typeof ATTACHMENT_REVEAL | typeof ATTACHMENT_REVEAL_SENT | typeof SHELL_RESOLVE_FILE_REVEAL | typeof WORKSPACE_LIST_ROOTS | typeof WORKSPACE_LIST_DIRECTORY | typeof WORKTREE_REFRESH | typeof WORKTREE_CREATE | typeof WORKTREE_RECREATE | typeof WORKTREE_REMOVAL_PREFLIGHT | typeof WORKTREE_REMOVE | typeof WORKTREE_RENAME | typeof WORKTREE_RESOLVE_FOLDER | typeof WORKTREE_LINKED_TASKS | typeof TASK_ACQUIRE | typeof TASK_ACQUIRE_IN_WORKTREE | typeof TASK_SEARCH_FILES | typeof TASK_ADOPT_NATIVE_SESSION | typeof TASK_SEND | typeof TASK_RESOLVE_CONFIG_PREFERENCES | typeof TASK_SET_CONFIG_OPTION | typeof TASK_SET_TITLE | typeof TASK_CANCEL | typeof TASK_STOP_BACKGROUND_COMMAND | typeof TASK_STOP_SUBAGENT | typeof TASK_OPEN | typeof TASK_MARK_READ | typeof TASK_CHAT_PAGE | typeof TASK_LIST | typeof TASK_NAVIGATION_REFRESH | typeof TASK_NAVIGATION_LOAD_MORE | typeof NATIVE_SESSION_ARCHIVE | typeof NATIVE_SESSION_SET_TITLE | typeof NATIVE_SESSION_SET_PINNED | typeof NATIVE_SESSION_RESTORE | typeof TASK_RELEASE | typeof TASK_ARCHIVE | typeof TASK_RESTORE | typeof CLIENT_UPDATE_SHUTDOWN_PREPARE | typeof CLIENT_UPDATE_SHUTDOWN_COMMIT | typeof CLIENT_UPDATE_SHUTDOWN_ABORT | typeof DIAGNOSTICS_LIST_SUPPORT_EXPORT | typeof DIAGNOSTICS_CREATE_SUPPORT_EXPORT | typeof PROJECT_ADD | typeof PROJECT_RENAME | typeof PROJECT_REMOVE | typeof PROJECT_REFRESH | typeof TASK_QUEUE_APPEND | typeof TASK_QUEUE_REMOVE | typeof TASK_QUEUE_TAKE | typeof TASK_QUEUE_MOVE | typeof TASK_SET_PERMISSION_POLICY | typeof TASK_SET_PINNED | typeof TASK_CLOSE_PLAN | typeof TASK_TOOL_IMAGE_PREVIEW | typeof FILE_VIEWER_LIST_DIRECTORY | typeof FILE_VIEWER_SEARCH | typeof FILE_VIEWER_CHANGES | typeof FILE_VIEWER_DIFF | typeof FILE_VIEWER_OPEN | typeof FILE_VIEWER_OPEN_FROM_HANDLE | typeof FILE_VIEWER_REFRESH | typeof FILE_VIEWER_RELEASE | typeof TASK_COMPOSER_HISTORY | typeof SETTINGS_RESET_TASK_HISTORY | typeof NATIVE_SESSION_DELETE | typeof NATIVE_SESSION_FORK | typeof TASK_RELOAD_NATIVE_SESSION | typeof TASK_ARCHIVE_OLDER | typeof AGENT_CANCEL_AUTHENTICATE | typeof AGENT_LOGOUT | typeof AGENT_REFRESH_ACCOUNT_LIMITS | typeof TASK_QUEUE_RESUME | typeof DEVICES_CREATE_INVITE | typeof DEVICES_CANCEL_INVITE | typeof DEVICES_PREVIEW_JOIN_REQUEST | typeof DEVICES_APPROVE_JOIN_REQUEST | typeof DEVICES_REMOVE; +export type ProtocolMethod = typeof CLIENT_PROBE | typeof CLIENT_INITIALIZE | typeof CLIENT_CAPABILITIES_CHANGED | typeof CLIENT_HEARTBEAT | typeof CLIENT_DETACH | typeof PENDING_REQUEST_RESOLVE | typeof STATE_SUBSCRIBE | typeof STATE_UNSUBSCRIBE | typeof DIAGNOSTICS_GET_RUNTIME | typeof SUPPORT_RECOVER_STUCK_SESSIONS | typeof AGENT_PROBE | typeof AGENT_AUTHENTICATE | typeof AGENT_LIST_SESSIONS | typeof AGENT_CREATE_CUSTOM | typeof AGENT_UPDATE_CUSTOM_METADATA | typeof AGENT_REPLACE_CUSTOM | typeof AGENT_DELETE_CUSTOM | typeof AGENT_SET_ENABLED | typeof SETTINGS_GET_AGENT_DETAILS | typeof SETTINGS_GET_MCP_SERVERS | typeof MCP_GET_SERVER_DETAILS | typeof MCP_CREATE_SERVER | typeof MCP_UPDATE_SERVER | typeof MCP_DELETE_SERVER | typeof MCP_SET_SERVER_ENABLED | typeof SETTINGS_GET_SKILLS | typeof SETTINGS_GET_SKILL_DETAILS | typeof SETTINGS_GET_PREFERENCES | typeof SETTINGS_UPDATE_PREFERENCES | typeof SETTINGS_UPDATE_NEW_TASK_DEFAULTS | typeof SETTINGS_GET_RUNTIME | typeof SETTINGS_UPDATE_RUNTIME | typeof ATTACHMENT_LIST_ROOTS | typeof ATTACHMENT_LIST_DIRECTORY | typeof ATTACHMENT_CREATE_FILE_REFERENCE | typeof ATTACHMENT_CREATE_LOCAL_FILE_REFERENCES | typeof ATTACHMENT_CREATE_PASTED_IMAGE | typeof ATTACHMENT_CREATE_EMBEDDED_CANDIDATE | typeof ATTACHMENT_CONFIRM_EMBEDDED | typeof ATTACHMENT_REFRESH_HANDLES | typeof ATTACHMENT_RELEASE | typeof ATTACHMENT_REVEAL | typeof ATTACHMENT_REVEAL_SENT | typeof SHELL_RESOLVE_FILE_REVEAL | typeof WORKSPACE_LIST_ROOTS | typeof WORKSPACE_LIST_DIRECTORY | typeof WORKTREE_REFRESH | typeof WORKTREE_CREATE | typeof WORKTREE_RECREATE | typeof WORKTREE_REMOVAL_PREFLIGHT | typeof WORKTREE_REMOVE | typeof WORKTREE_RENAME | typeof WORKTREE_RESOLVE_FOLDER | typeof WORKTREE_LINKED_TASKS | typeof TASK_ACQUIRE | typeof TASK_ACQUIRE_IN_WORKTREE | typeof TASK_SEARCH_FILES | typeof TASK_ADOPT_NATIVE_SESSION | typeof TASK_SEND | typeof TASK_RESOLVE_CONFIG_PREFERENCES | typeof TASK_SET_CONFIG_OPTION | typeof TASK_SET_TITLE | typeof TASK_CANCEL | typeof TASK_STOP_BACKGROUND_COMMAND | typeof TASK_STOP_SUBAGENT | typeof TASK_OPEN | typeof TASK_MARK_READ | typeof TASK_CHAT_PAGE | typeof TASK_LIST | typeof TASK_NAVIGATION_REFRESH | typeof TASK_NAVIGATION_LOAD_MORE | typeof NATIVE_SESSION_ARCHIVE | typeof NATIVE_SESSION_SET_TITLE | typeof NATIVE_SESSION_SET_PINNED | typeof NATIVE_SESSION_RESTORE | typeof TASK_RELEASE | typeof TASK_ARCHIVE | typeof TASK_RESTORE | typeof CLIENT_UPDATE_SHUTDOWN_PREPARE | typeof CLIENT_UPDATE_SHUTDOWN_COMMIT | typeof CLIENT_UPDATE_SHUTDOWN_ABORT | typeof DIAGNOSTICS_LIST_SUPPORT_EXPORT | typeof DIAGNOSTICS_CREATE_SUPPORT_EXPORT | typeof PROJECT_ADD | typeof PROJECT_RENAME | typeof PROJECT_REMOVE | typeof PROJECT_REFRESH | typeof TASK_QUEUE_APPEND | typeof TASK_QUEUE_REMOVE | typeof TASK_QUEUE_TAKE | typeof TASK_QUEUE_MOVE | typeof TASK_SET_PERMISSION_POLICY | typeof TASK_SET_PINNED | typeof TASK_CLOSE_PLAN | typeof TASK_TOOL_IMAGE_PREVIEW | typeof FILE_VIEWER_LIST_DIRECTORY | typeof FILE_VIEWER_SEARCH | typeof FILE_VIEWER_CHANGES | typeof FILE_VIEWER_DIFF | typeof FILE_VIEWER_OPEN | typeof FILE_VIEWER_OPEN_FROM_HANDLE | typeof FILE_VIEWER_REFRESH | typeof FILE_VIEWER_RELEASE | typeof TASK_COMPOSER_HISTORY | typeof SETTINGS_RESET_TASK_HISTORY | typeof NATIVE_SESSION_DELETE | typeof NATIVE_SESSION_FORK | typeof TASK_RELOAD_NATIVE_SESSION | typeof TASK_ARCHIVE_OLDER | typeof AGENT_CANCEL_AUTHENTICATE | typeof AGENT_LOGOUT | typeof AGENT_REFRESH_ACCOUNT_LIMITS | typeof TASK_QUEUE_RESUME | typeof DEVICES_CREATE_INVITE | typeof DEVICES_CANCEL_INVITE | typeof DEVICES_PREVIEW_JOIN_REQUEST | typeof DEVICES_APPROVE_JOIN_REQUEST | typeof DEVICES_REMOVE | typeof CLIENT_REPLACEMENT_IMPACT; export type RequestParamsByMethod = { [FILE_VIEWER_LIST_DIRECTORY]: ProjectFilesParams; [FILE_VIEWER_SEARCH]: ProjectFilesParams; @@ -1543,6 +1567,7 @@ export type RequestParamsByMethod = { [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareParams; [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitParams; [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortParams; + [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactParams; [PENDING_REQUEST_RESOLVE]: PendingRequestResolveParams; [STATE_SUBSCRIBE]: StateSubscribeParams; [STATE_UNSUBSCRIBE]: StateUnsubscribeParams; @@ -1664,6 +1689,7 @@ export type ResponseResultByMethod = { [CLIENT_UPDATE_SHUTDOWN_PREPARE]: UpdateShutdownPrepareResult; [CLIENT_UPDATE_SHUTDOWN_COMMIT]: UpdateShutdownCommitResult; [CLIENT_UPDATE_SHUTDOWN_ABORT]: UpdateShutdownAbortResult; + [CLIENT_REPLACEMENT_IMPACT]: ClientReplacementImpactResult; [PENDING_REQUEST_RESOLVE]: PendingRequestResolveResult; [STATE_SUBSCRIBE]: StateSubscribeResult; [STATE_UNSUBSCRIBE]: StateUnsubscribeResult; @@ -1786,6 +1812,7 @@ export type ClientDetachRequest = TypedClientRequest; export type ClientUpdateShutdownPrepareRequest = TypedClientRequest; export type ClientUpdateShutdownCommitRequest = TypedClientRequest; export type ClientUpdateShutdownAbortRequest = TypedClientRequest; +export type ClientReplacementImpactRequest = TypedClientRequest; export type ClientCapabilitiesChangedRequest = TypedClientRequest; export type ClientInitializeResponse = ResponseEnvelope; export type ClientCapabilitiesChangedResponse = ResponseEnvelope; @@ -1794,6 +1821,7 @@ export type ClientDetachResponse = ResponseEnvelope; export type ClientUpdateShutdownPrepareResponse = ResponseEnvelope; export type ClientUpdateShutdownCommitResponse = ResponseEnvelope; export type ClientUpdateShutdownAbortResponse = ResponseEnvelope; +export type ClientReplacementImpactResponse = ResponseEnvelope; export type StateSubscribeResponse = ResponseEnvelope; export type StateUnsubscribeResponse = ResponseEnvelope; export type DiagnosticsGetRuntimeResponse = ResponseEnvelope; @@ -1893,4 +1921,4 @@ export type TypedServerRequest = ServerRequestEnv method: M; }; -export const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = { major: 2, minor: 1 }; +export const APP_SERVER_PROTOCOL_VERSION: ProtocolVersion = { major: 2, minor: 2 }; From 0e2093019afef055a82c9bdde381b977c122da2a Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 07:51:29 +0200 Subject: [PATCH 04/21] feat(app-server): keep the App Server on an SSH Computer Add `openaide-app-server keep start|stop`, which an App Shell's SSH launcher runs on an SSH Computer (ADR-0069). `keep start` attaches to or starts the App Server for the default state root, detaches a keeper client into its own session, and prints one JSON report with the endpoint, token and versions. The keeper holds the App Server while Turns run, while a Task waits for the user for up to 24 hours, and for 30 minutes after the last App Shell leaves. One keeper runs per state root, guarded by a lease lock. An App Server of a different version is reported as a mismatch instead of being replaced; the launcher then chooses `--replace` or `--join` after asking the user. The handoff gains a reporting policy for incompatible servers, and the protocol gains the `keeper` shell kind. --- openaide-rs/app-server-protocol/src/client.rs | 4 + openaide-rs/app-server/Cargo.toml | 2 +- .../src/app_server_client/launch_handoff.rs | 35 +- .../app_server_client/launch_handoff_tests.rs | 37 ++ .../app-server/src/app_server_handoff.rs | 74 ++- openaide-rs/app-server/src/keeper_command.rs | 603 ++++++++++++++++++ .../app-server/src/keeper_command/lease.rs | 78 +++ .../src/keeper_command/lease_tests.rs | 47 ++ .../app-server/src/keeper_command/policy.rs | 127 ++++ .../src/keeper_command/policy_tests.rs | 132 ++++ .../app-server/src/keeper_command_tests.rs | 63 ++ openaide-rs/app-server/src/main.rs | 21 +- openaide-rs/app-server/src/remote_command.rs | 4 +- .../app-server/src/remote_command/launch.rs | 7 + .../src/remote_command/local_client.rs | 22 +- .../app-server/tests/keeper_command.rs | 151 +++++ .../src/generated/protocol.ts | 2 +- 17 files changed, 1397 insertions(+), 12 deletions(-) create mode 100644 openaide-rs/app-server/src/keeper_command.rs create mode 100644 openaide-rs/app-server/src/keeper_command/lease.rs create mode 100644 openaide-rs/app-server/src/keeper_command/lease_tests.rs create mode 100644 openaide-rs/app-server/src/keeper_command/policy.rs create mode 100644 openaide-rs/app-server/src/keeper_command/policy_tests.rs create mode 100644 openaide-rs/app-server/src/keeper_command_tests.rs create mode 100644 openaide-rs/app-server/tests/keeper_command.rs diff --git a/openaide-rs/app-server-protocol/src/client.rs b/openaide-rs/app-server-protocol/src/client.rs index 0c469aa7..00ff59d2 100644 --- a/openaide-rs/app-server-protocol/src/client.rs +++ b/openaide-rs/app-server-protocol/src/client.rs @@ -195,6 +195,10 @@ pub enum ShellKind { /// The App Server's own `remote` command: a same-machine client with no /// window that keeps the App Server running for Remote Devices. Terminal, + /// The App Server's own keeper on an SSH Computer: a same-machine client + /// with no window that holds the App Server while it has work or a recent + /// App Shell, then leaves (ADR-0069). + Keeper, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] diff --git a/openaide-rs/app-server/Cargo.toml b/openaide-rs/app-server/Cargo.toml index 5caf6ef8..f6c354cc 100644 --- a/openaide-rs/app-server/Cargo.toml +++ b/openaide-rs/app-server/Cargo.toml @@ -41,7 +41,7 @@ ctrlc = "3.4" qrcode = { version = "0.14", default-features = false } [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", features = ["signal", "hostname"] } +nix = { version = "0.31", features = ["signal", "hostname", "process"] } signal-hook = { version = "0.3", default-features = false } [target.'cfg(windows)'.dependencies] diff --git a/openaide-rs/app-server/src/app_server_client/launch_handoff.rs b/openaide-rs/app-server/src/app_server_client/launch_handoff.rs index 010122c0..d41d9431 100644 --- a/openaide-rs/app-server/src/app_server_client/launch_handoff.rs +++ b/openaide-rs/app-server/src/app_server_client/launch_handoff.rs @@ -54,6 +54,17 @@ impl LaunchWaiter for SleepLaunchWaiter { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LaunchHandoffPolicy { pub max_wait_attempts: usize, + pub incompatible: IncompatibleServerPolicy, +} + +/// What to do with a running App Server of another version for the same state root. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IncompatibleServerPolicy { + /// Ask it to stop and start this version. Same-machine App Shells are installed + /// with their App Server, so the newest one installed wins. + Replace, + /// Return it untouched, for a caller that must ask the user first (ADR-0069). + Report, } impl Default for LaunchHandoffPolicy { @@ -62,6 +73,7 @@ impl Default for LaunchHandoffPolicy { // Startup and authenticated replacement share one bounded 50-second // window, below the shell adapters' final 60-second safety ceiling. max_wait_attempts: 200, + incompatible: IncompatibleServerPolicy::Replace, } } } @@ -100,6 +112,11 @@ impl AttachOrLaunchHandoff { AttachOrLaunchRunResult::Fail { reason } => { return Ok(LaunchHandoffResult::Fail { reason }); } + AttachOrLaunchRunResult::ReplaceIncompatible { target, reason } + if self.policy.incompatible == IncompatibleServerPolicy::Report => + { + return Ok(LaunchHandoffResult::Incompatible { target, reason }); + } AttachOrLaunchRunResult::ReplaceIncompatible { target, reason } => { let replacement = request_local_http_replacement(&target)?; let max_wait_attempts = match replacement { @@ -132,9 +149,21 @@ impl AttachOrLaunchHandoff { #[derive(Debug)] pub enum LaunchHandoffResult { - AttachExisting { target: EndpointTarget }, - LaunchNew { lock: RuntimeLock }, - Fail { reason: AttachOrLaunchFailure }, + AttachExisting { + target: EndpointTarget, + }, + LaunchNew { + lock: RuntimeLock, + }, + Fail { + reason: AttachOrLaunchFailure, + }, + /// Only under [`IncompatibleServerPolicy::Report`]: a live App Server of another + /// version, left running. + Incompatible { + target: EndpointTarget, + reason: AttachOrLaunchFailure, + }, } #[derive(Debug, Error)] diff --git a/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs b/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs index 665e533c..a037d917 100644 --- a/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs +++ b/openaide-rs/app-server/src/app_server_client/launch_handoff_tests.rs @@ -64,6 +64,41 @@ fn incompatible_server_with_replacement_endpoint_stops_then_launches_replacement assert_eq!(waiter.calls, 1); } +#[test] +fn reporting_policy_returns_an_incompatible_server_without_asking_it_to_stop() { + let fixture = Fixture::with_policy(LaunchHandoffPolicy { + max_wait_attempts: 3, + incompatible: IncompatibleServerPolicy::Report, + }); + let dispatcher = ProtocolEdgeStdioDispatcher::new_for_test(fixture.state_root.clone()); + let _published = crate::app_server_process::publish_local_http_probe_endpoint( + dispatcher.shared_gateway(), + &fixture.state_root, + fixture.runtime_dir.path(), + ) + .unwrap(); + let mut waiter = CountingWaiter::default(); + + let result = fixture + .run_requiring_app_version("next-release", &mut waiter) + .unwrap(); + + assert!(matches!( + result, + LaunchHandoffResult::Incompatible { + ref target, + reason: AttachOrLaunchFailure::IncompatibleApp, + } if target.app_version == env!("CARGO_PKG_VERSION") + )); + assert_eq!(waiter.calls, 0); + let record = fixture + .endpoint_records + .read(fixture.state_root.fingerprint()) + .unwrap() + .expect("the incompatible App Server keeps its endpoint"); + assert_eq!(record.status, RuntimeEndpointRecordStatus::Running); +} + #[test] fn legacy_release_without_replacement_endpoint_waits_for_liveness_shutdown() { let fixture = Fixture::new(); @@ -148,6 +183,7 @@ fn unreachable_endpoint_is_preserved_until_launch_owner_publishes_replacement() fn busy_launch_lock_is_bounded_by_policy() { let fixture = Fixture::with_policy(LaunchHandoffPolicy { max_wait_attempts: 2, + ..Default::default() }); let _held_lock = acquire_launch_lock(&fixture); let mut waiter = CountingWaiter::default(); @@ -174,6 +210,7 @@ impl Fixture { fn new() -> Self { Self::with_policy(LaunchHandoffPolicy { max_wait_attempts: 3, + ..Default::default() }) } diff --git a/openaide-rs/app-server/src/app_server_handoff.rs b/openaide-rs/app-server/src/app_server_handoff.rs index 5e34da3d..ed589489 100644 --- a/openaide-rs/app-server/src/app_server_handoff.rs +++ b/openaide-rs/app-server/src/app_server_handoff.rs @@ -1,7 +1,8 @@ use std::path::{Path, PathBuf}; use openaide_app_server::app_server_client::launch_handoff::{ - AttachOrLaunchHandoff, LaunchHandoffResult, SleepLaunchWaiter, + AttachOrLaunchHandoff, IncompatibleServerPolicy, LaunchHandoffPolicy, LaunchHandoffResult, + SleepLaunchWaiter, }; use openaide_app_server::app_server_client::runner::{ AttachOrLaunchRequirements, AttachOrLaunchRunner, @@ -25,6 +26,9 @@ pub struct LocalHttpHandoffConnection { kind: &'static str, endpoint_url: String, auth_token: String, + /// Identifies the App Server process; not part of the printed handoff line. + #[serde(skip)] + server_id: String, } impl From for LocalHttpHandoffConnection { @@ -33,6 +37,7 @@ impl From for LocalHttpHandoffConnection { kind: "localHttp", endpoint_url: connection.endpoint_url, auth_token: connection.auth_token, + server_id: String::new(), } } } @@ -45,6 +50,23 @@ impl LocalHttpHandoffConnection { pub fn auth_token(&self) -> &str { &self.auth_token } + + pub fn server_id(&self) -> &str { + &self.server_id + } +} + +/// A live App Server of another version for this state root, left running. +pub struct IncompatibleServer { + pub connection: LocalHttpHandoffConnection, + pub app_version: String, + pub protocol_version: String, +} + +pub enum ReportingHandoff { + Attached(LocalHttpHandoffConnection), + LaunchHere(RuntimeLock), + Incompatible(IncompatibleServer), } pub fn attach_or_launch(state_root: &StateRoot, runtime_root: &Path) -> HandoffStart { @@ -71,6 +93,10 @@ pub fn attach_or_launch(state_root: &StateRoot, runtime_root: &Path) -> HandoffS })) } Ok(LaunchHandoffResult::LaunchNew { lock }) => HandoffStart::LaunchHere(lock), + Ok(LaunchHandoffResult::Incompatible { reason, .. }) => { + eprintln!("OpenAIDE App Server handoff left an incompatible server: {reason:?}"); + std::process::exit(1); + } Ok(LaunchHandoffResult::Fail { reason }) => { eprintln!("OpenAIDE App Server handoff failed: {reason:?}"); std::process::exit(1); @@ -82,6 +108,51 @@ pub fn attach_or_launch(state_root: &StateRoot, runtime_root: &Path) -> HandoffS } } +/// Like [`attach_or_launch`], but a running App Server of another version is +/// returned instead of replaced, and failures are returned as a stable class. +pub fn attach_or_launch_reporting( + state_root: &StateRoot, + runtime_root: &Path, +) -> Result { + let runner = AttachOrLaunchRunner::new( + EndpointRecordStore::new(runtime_root), + launch_lock_path(runtime_root, state_root.fingerprint()), + ); + let handoff = AttachOrLaunchHandoff::new( + runner, + LaunchHandoffPolicy { + incompatible: IncompatibleServerPolicy::Report, + ..Default::default() + }, + ); + let requirements = AttachOrLaunchRequirements { + required_protocol_version: APP_SERVER_PROTOCOL_VERSION.to_string(), + required_app_version: env!("CARGO_PKG_VERSION").to_string(), + }; + let mut waiter = SleepLaunchWaiter::default(); + match handoff.run( + state_root.fingerprint(), + &requirements, + StorageWriterState::Available, + &mut waiter, + ) { + Ok(LaunchHandoffResult::AttachExisting { target }) => connection_from_target(&target) + .map(ReportingHandoff::Attached) + .ok_or("no_local_http_endpoint"), + Ok(LaunchHandoffResult::LaunchNew { lock }) => Ok(ReportingHandoff::LaunchHere(lock)), + Ok(LaunchHandoffResult::Incompatible { target, .. }) => { + let connection = connection_from_target(&target).ok_or("no_local_http_endpoint")?; + Ok(ReportingHandoff::Incompatible(IncompatibleServer { + connection, + app_version: target.app_version, + protocol_version: target.protocol_version, + })) + } + Ok(LaunchHandoffResult::Fail { .. }) => Err("handoff_refused"), + Err(_) => Err("handoff_failed"), + } +} + pub fn print_connection_or_exit(connection: &LocalHttpHandoffConnection) { match serde_json::to_string(connection) { Ok(line) => println!("{line}"), @@ -101,6 +172,7 @@ fn connection_from_target(target: &EndpointTarget) -> Option Result { + let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); + let mode = |rest: &[&str]| match rest { + [] => Ok(Mode::Own), + ["--replace"] => Ok(Mode::Replace), + ["--join"] => Ok(Mode::Join), + _ => Err(format!("Unexpected \"{}\".", rest.join(" "))), + }; + match words.split_first() { + Some((&"start", rest)) => mode(rest).map(Action::Start), + Some((&"run", rest)) => mode(rest).map(Action::Run), + Some((&"stop", [])) => Ok(Action::Stop), + Some((&("help" | "--help" | "-h"), _)) | None => Ok(Action::Help), + Some((word, _)) => Err(format!("Unknown command \"{word}\".")), + } +} + +/// What `keep start` prints. Tokens and addresses are for the launcher only and +/// are never logged. +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Report { + pub(crate) outcome: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) keeper: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) endpoint_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) auth_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) app_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) protocol_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option<&'static str>, +} + +impl Report { + fn failed(error: &'static str) -> Self { + Self { + outcome: "failed", + error: Some(error), + ..Self::default() + } + } + + fn connected( + outcome: &'static str, + connection: &LocalHttpHandoffConnection, + app_version: String, + protocol_version: String, + ) -> Self { + Self { + outcome, + endpoint_url: Some(connection.endpoint_url().to_string()), + auth_token: Some(connection.auth_token().to_string()), + app_version: Some(app_version), + protocol_version: Some(protocol_version), + ..Self::default() + } + } +} + +pub(crate) fn run(arguments: &[String], state_root: &StateRoot, runtime_root: &Path) -> i32 { + let action = match parse(arguments) { + Ok(action) => action, + Err(problem) => { + eprintln!("{problem}\n\n{USAGE}"); + return 2; + } + }; + match action { + Action::Help => { + print!("{USAGE}"); + 0 + } + Action::Start(mode) => start(mode), + Action::Stop => stop(state_root, runtime_root), + Action::Run(mode) => keep(mode, state_root, runtime_root), + } +} + +/// Starts `keep run` outside this SSH session and relays its one line. +fn start(mode: Mode) -> i32 { + let started = Instant::now(); + logging::info("keeper_start_started", json!({ "mode": mode_class(mode) })); + let relayed = spawn_keeper(mode).and_then(|line| { + println!("{line}"); + std::io::stdout().flush().map_err(|_| "output_closed") + }); + let outcome = relayed.err().unwrap_or("relayed"); + logging::info( + "keeper_start_completed", + json!({ "mode": mode_class(mode), "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + if outcome == "relayed" { + 0 + } else { + println!( + "{}", + serde_json::to_string(&Report::failed(outcome)).unwrap_or_default() + ); + 1 + } +} + +fn spawn_keeper(mode: Mode) -> Result { + let executable = std::env::current_exe().map_err(|_| "spawn_failed")?; + let mut command = Command::new(executable); + command.arg("keep").arg("run"); + match mode { + Mode::Own => {} + Mode::Replace => { + command.arg("--replace"); + } + Mode::Join => { + command.arg("--join"); + } + } + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + new_session(&mut command); + let mut child = command.spawn().map_err(|_| "spawn_failed")?; + let stdout = child.stdout.take().ok_or("no_report")?; + let mut line = String::new(); + BufReader::new(stdout) + .read_line(&mut line) + .map_err(|_| "no_report")?; + let line = line.trim().to_string(); + if line.is_empty() { + return Err("no_report"); + } + // The keeper outlives this command; nothing waits for it. + drop(child); + Ok(line) +} + +/// The keeper must survive the SSH session that started it. +#[cfg(unix)] +fn new_session(command: &mut Command) { + use std::os::unix::process::CommandExt; + // SAFETY: `setsid` is async-signal-safe and touches no memory of the parent. + unsafe { + command.pre_exec(|| { + nix::unistd::setsid() + .map(|_| ()) + .map_err(std::io::Error::from) + }); + } +} + +#[cfg(not(unix))] +fn new_session(_command: &mut Command) {} + +/// The detached keeper: resolves the App Server, takes the lease, reports, keeps. +fn keep(mode: Mode, state_root: &StateRoot, runtime_root: &Path) -> i32 { + let started = Instant::now(); + let resolved = resolve(mode, state_root, runtime_root); + let (outcome, connection, app_version, protocol_version, _launcher) = match resolved { + Ok(Resolved::Keep { + outcome, + connection, + app_version, + protocol_version, + launcher, + }) => (outcome, connection, app_version, protocol_version, launcher), + Ok(Resolved::Mismatch(report)) => { + logging::info( + "keeper_not_started", + json!({ "mode": mode_class(mode), "outcome": report.outcome, "duration_ms": elapsed_ms(started) }), + ); + print_report(&report); + return 0; + } + Err(error) => { + let report = Report::failed(error); + logging::info( + "keeper_not_started", + json!({ "mode": mode_class(mode), "outcome": report.outcome, "error": report.error, "duration_ms": elapsed_ms(started) }), + ); + print_report(&report); + return 0; + } + }; + + let path = lease::lease_path(runtime_root, state_root.fingerprint().as_str()); + let holder = LeaseHolder { + pid: std::process::id(), + server_id: connection.server_id().to_string(), + }; + let lease = match take_lease(&path, &holder) { + Ok(Some(lease)) => lease, + Ok(None) => { + let mut report = Report::connected(outcome, &connection, app_version, protocol_version); + report.keeper = Some("existing"); + logging::info( + "keeper_already_running", + json!({ "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + print_report(&report); + return 0; + } + Err(error) => { + print_report(&Report::failed(error)); + return 1; + } + }; + + let mut client = match LocalClient::with_kind( + ShellKind::Keeper, + connection.endpoint_url(), + connection.auth_token(), + ) + .and_then(|mut client| client.initialize().map(|()| client)) + { + Ok(client) => client, + Err(error) => { + print_report(&Report::failed(error.class())); + return 1; + } + }; + let mut report = Report::connected(outcome, &connection, app_version, protocol_version); + report.keeper = Some("new"); + print_report(&report); + logging::info( + "keeper_started", + json!({ "mode": mode_class(mode), "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + let ended = hold(&mut client, KeeperPolicy::default()); + let _ = client.detach(); + drop(lease); + logging::info( + "keeper_ended", + json!({ "reason": ended, "held_ms": elapsed_ms(started) }), + ); + 0 +} + +enum Resolved { + Keep { + outcome: &'static str, + connection: LocalHttpHandoffConnection, + app_version: String, + protocol_version: String, + /// Holds the output pipe of an App Server this keeper started. + launcher: Option, + }, + Mismatch(Report), +} + +fn own_versions() -> (String, String) { + ( + env!("CARGO_PKG_VERSION").to_string(), + APP_SERVER_PROTOCOL_VERSION.to_string(), + ) +} + +fn resolve( + mode: Mode, + state_root: &StateRoot, + runtime_root: &Path, +) -> Result { + if mode == Mode::Replace { + // The ordinary App Shell path: attach to this version, or ask another one + // to stop and start this one. The user has confirmed. + let launched = + launch::attach_or_start(state_root, runtime_root).map_err(|error| error.class())?; + return keep_own(launched, state_root, runtime_root); + } + match app_server_handoff::attach_or_launch_reporting(state_root, runtime_root)? { + ReportingHandoff::Attached(connection) => { + let (app_version, protocol_version) = own_versions(); + Ok(Resolved::Keep { + outcome: "attached", + connection, + app_version, + protocol_version, + launcher: None, + }) + } + ReportingHandoff::LaunchHere(lock) if mode == Mode::Own => { + // The started process takes the launch itself. + drop(lock); + let launched = launch::start().map_err(|error| error.class())?; + keep_own(launched, state_root, runtime_root) + } + ReportingHandoff::LaunchHere(_lock) => Err("not_running"), + ReportingHandoff::Incompatible(server) if mode == Mode::Join => Ok(Resolved::Keep { + outcome: "joined", + connection: server.connection, + app_version: server.app_version, + protocol_version: server.protocol_version, + launcher: None, + }), + ReportingHandoff::Incompatible(server) => Ok(Resolved::Mismatch(Report::connected( + "mismatch", + &server.connection, + server.app_version, + server.protocol_version, + ))), + } +} + +/// An App Server of this version now runs; reading its endpoint record again +/// gives the server id the lease names. +fn keep_own( + launched: launch::Connection, + state_root: &StateRoot, + runtime_root: &Path, +) -> Result { + let outcome = if launched.started { + "started" + } else { + "attached" + }; + match app_server_handoff::attach_or_launch_reporting(state_root, runtime_root)? { + ReportingHandoff::Attached(connection) => { + let (app_version, protocol_version) = own_versions(); + Ok(Resolved::Keep { + outcome, + connection, + app_version, + protocol_version, + launcher: Some(launched), + }) + } + _ => Err("launch_unconfirmed"), + } +} + +/// `Ok(None)` when a keeper already keeps this App Server. +fn take_lease(path: &Path, holder: &LeaseHolder) -> Result, &'static str> { + let deadline = Instant::now() + LEASE_TAKEOVER_WAIT; + loop { + match lease::try_acquire(path, holder).map_err(|_| "lease_failed")? { + LeaseAttempt::Acquired(lease) => return Ok(Some(lease)), + LeaseAttempt::Held(Some(other)) if other.server_id == holder.server_id => { + return Ok(None) + } + // The keeper of a replaced App Server leaves once that server is gone. + LeaseAttempt::Held(other) => { + if let Some(other) = other { + terminate(other.pid); + } + if Instant::now() >= deadline { + return Err("keeper_busy"); + } + std::thread::sleep(Duration::from_millis(250)); + } + } + } +} + +fn hold(client: &mut LocalClient, policy: KeeperPolicy) -> &'static str { + let terminated = termination_flag(); + let mut clock = KeeperClock::new(policy, Instant::now()); + let mut last_heartbeat = Instant::now(); + let mut last_observation: Option = None; + let mut last_verdict: Option = None; + let mut missed = 0; + loop { + std::thread::sleep(TICK); + if terminated.load(Ordering::SeqCst) { + return "stop_requested"; + } + if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL { + last_heartbeat = Instant::now(); + match client.heartbeat() { + Ok(_) => missed = 0, + // The App Server forgot this client, as after the computer slept + // past the liveness window. + Err(ClientError::Refused { code, .. }) => { + logging::warn("keeper_rejoining", json!({ "error": code })); + if client.initialize().is_err() { + return "app_server_gone"; + } + } + Err(error) => { + missed += 1; + logging::warn( + "keeper_heartbeat_missed", + json!({ "attempt": missed, "error": error.class() }), + ); + if missed >= MAX_MISSED_HEARTBEATS { + return "app_server_gone"; + } + } + } + } + if last_observation.is_some_and(|at| at.elapsed() < OBSERVE_INTERVAL) { + continue; + } + last_observation = Some(Instant::now()); + let observation = match observe(client) { + Ok(observation) => observation, + // An App Server too old to say what it is doing cannot be judged, and + // holding it forever would be worse than leaving. + Err(ClientError::Refused { .. }) => return "impact_unsupported", + Err(error) => { + logging::warn("keeper_observe_failed", json!({ "error": error.class() })); + continue; + } + }; + let verdict = clock.observe(&observation, Instant::now()); + if last_verdict != Some(verdict) { + log_verdict(verdict, &observation); + last_verdict = Some(verdict); + } + if let Verdict::Leave(reason) = verdict { + return reason.class(); + } + } +} + +fn observe(client: &mut LocalClient) -> Result { + let answer = client.request(CLIENT_REPLACEMENT_IMPACT, json!({}))?; + let impact: ClientReplacementImpactResult = + serde_json::from_value(answer.result).map_err(|_| ClientError::Malformed)?; + Ok(observation_from(&impact)) +} + +pub(crate) fn observation_from(impact: &ClientReplacementImpactResult) -> Observation { + Observation { + active_turns: impact.active_turns, + pending_task_requests: impact.pending_task_requests, + app_shells: impact + .other_clients + .iter() + .filter(|client| !matches!(client.kind, ShellKind::Terminal | ShellKind::Keeper)) + .count(), + } +} + +fn log_verdict(verdict: Verdict, observation: &Observation) { + let (verdict, reason) = match verdict { + Verdict::Hold(reason) => ("hold", reason.class()), + Verdict::Leave(reason) => ("leave", reason.class()), + }; + logging::info( + "keeper_verdict_changed", + json!({ + "verdict": verdict, + "reason": reason, + "active_turns": observation.active_turns, + "pending_task_requests": observation.pending_task_requests, + "app_shells": observation.app_shells, + }), + ); +} + +fn stop(state_root: &StateRoot, runtime_root: &Path) -> i32 { + let started = Instant::now(); + let path = lease::lease_path(runtime_root, state_root.fingerprint().as_str()); + let outcome = match lease::current_holder(&path) { + Ok(None) => "not_running", + Ok(Some(holder)) => { + terminate(holder.pid); + let deadline = Instant::now() + STOP_WAIT; + loop { + match lease::current_holder(&path) { + Ok(None) => break "stopped", + _ if Instant::now() >= deadline => break "still_running", + _ => std::thread::sleep(Duration::from_millis(100)), + } + } + } + Err(_) => "lease_unreadable", + }; + logging::info( + "keeper_stop_completed", + json!({ "outcome": outcome, "duration_ms": elapsed_ms(started) }), + ); + println!("{}", json!({ "outcome": outcome })); + i32::from(outcome != "stopped" && outcome != "not_running") +} + +#[cfg(unix)] +fn terminate(pid: u32) { + use nix::sys::signal::{kill, Signal}; + use nix::unistd::Pid; + if let Ok(pid) = i32::try_from(pid) { + let _ = kill(Pid::from_raw(pid), Signal::SIGTERM); + } +} + +#[cfg(not(unix))] +fn terminate(_pid: u32) {} + +/// Only an explicit stop ends the keeper early. It has no terminal, so a hangup is +/// not a request to stop. +fn termination_flag() -> Arc { + let flag = Arc::new(AtomicBool::new(false)); + #[cfg(unix)] + for signal in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT] { + let _ = signal_hook::flag::register(signal, flag.clone()); + } + #[cfg(unix)] + let _ = signal_hook::flag::register( + signal_hook::consts::SIGHUP, + Arc::new(AtomicBool::new(false)), + ); + flag +} + +fn print_report(report: &Report) { + // The reader is `keep start`, which exits after one line; a later write + // failing must not end the keeper. + let line = serde_json::to_string(report).unwrap_or_default(); + let mut stdout = std::io::stdout(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); +} + +fn mode_class(mode: Mode) -> &'static str { + match mode { + Mode::Own => "own", + Mode::Replace => "replace", + Mode::Join => "join", + } +} + +fn elapsed_ms(started: Instant) -> u64 { + u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +#[path = "keeper_command_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/keeper_command/lease.rs b/openaide-rs/app-server/src/keeper_command/lease.rs new file mode 100644 index 00000000..fa5e8e17 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/lease.rs @@ -0,0 +1,78 @@ +//! One keeper per state root. The keeper holds an exclusive lock on its lease file +//! for as long as it runs and writes its process id and the App Server it keeps +//! into it. The operating system releases the lock when the process ends, so a +//! lease whose lock is free is stale whatever the file says. + +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LeaseHolder { + pub(crate) pid: u32, + pub(crate) server_id: String, +} + +pub(crate) enum LeaseAttempt { + Acquired(Lease), + /// Another keeper runs. Its record is absent while it is still writing it. + Held(Option), +} + +/// The lock, held until dropped. +pub(crate) struct Lease { + _file: File, +} + +pub(crate) fn lease_path(runtime_root: &Path, fingerprint: &str) -> PathBuf { + runtime_root.join(format!("{fingerprint}.keeper")) +} + +pub(crate) fn try_acquire(path: &Path, holder: &LeaseHolder) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + if file.try_lock_exclusive().is_err() { + return Ok(LeaseAttempt::Held(read_holder(&mut file))); + } + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(&serde_json::to_vec(holder).map_err(std::io::Error::other)?)?; + file.sync_data()?; + Ok(LeaseAttempt::Acquired(Lease { _file: file })) +} + +/// The running keeper, or `None` when no keeper holds the lease. +pub(crate) fn current_holder(path: &Path) -> std::io::Result> { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if file.try_lock_shared().is_ok() { + let _ = FileExt::unlock(&file); + return Ok(None); + } + Ok(read_holder(&mut file)) +} + +fn read_holder(file: &mut File) -> Option { + let mut text = String::new(); + file.seek(SeekFrom::Start(0)).ok()?; + file.read_to_string(&mut text).ok()?; + serde_json::from_str(&text).ok() +} + +#[cfg(test)] +#[path = "lease_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/keeper_command/lease_tests.rs b/openaide-rs/app-server/src/keeper_command/lease_tests.rs new file mode 100644 index 00000000..a5012939 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/lease_tests.rs @@ -0,0 +1,47 @@ +use super::*; + +fn holder(server_id: &str) -> LeaseHolder { + LeaseHolder { + pid: 4242, + server_id: server_id.to_string(), + } +} + +#[test] +fn a_second_keeper_sees_the_first_one_and_its_server() { + let dir = tempfile::tempdir().unwrap(); + let path = lease_path(dir.path(), "root-1"); + + let first = try_acquire(&path, &holder("server-a")).unwrap(); + let second = try_acquire(&path, &holder("server-b")).unwrap(); + + assert!(matches!(first, LeaseAttempt::Acquired(_))); + assert!(matches!( + second, + LeaseAttempt::Held(Some(ref found)) if *found == holder("server-a") + )); + assert_eq!(current_holder(&path).unwrap(), Some(holder("server-a"))); +} + +#[test] +fn a_released_lease_is_free_whatever_the_file_still_says() { + let dir = tempfile::tempdir().unwrap(); + let path = lease_path(dir.path(), "root-1"); + drop(try_acquire(&path, &holder("server-a")).unwrap()); + + assert_eq!(current_holder(&path).unwrap(), None); + let second = try_acquire(&path, &holder("server-b")).unwrap(); + + assert!(matches!(second, LeaseAttempt::Acquired(_))); + assert_eq!(current_holder(&path).unwrap(), Some(holder("server-b"))); +} + +#[test] +fn no_lease_file_means_no_keeper() { + let dir = tempfile::tempdir().unwrap(); + + assert_eq!( + current_holder(&lease_path(dir.path(), "root-1")).unwrap(), + None + ); +} diff --git a/openaide-rs/app-server/src/keeper_command/policy.rs b/openaide-rs/app-server/src/keeper_command/policy.rs new file mode 100644 index 00000000..e8eda2ac --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/policy.rs @@ -0,0 +1,127 @@ +//! When the keeper holds the App Server on an SSH Computer and when it leaves +//! (ADR-0069). The keeper is an ordinary client; leaving lets the App Server stop +//! by its own rule once no other client remains. + +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct KeeperPolicy { + /// How long after the last App Shell left the keeper still holds an idle App Server. + pub(crate) app_shell_grace: Duration, + /// How long Tasks waiting for the user keep holding it with nobody attached. + pub(crate) waiting_limit: Duration, +} + +impl Default for KeeperPolicy { + fn default() -> Self { + Self { + app_shell_grace: Duration::from_secs(30 * 60), + waiting_limit: Duration::from_secs(24 * 60 * 60), + } + } +} + +/// What the keeper read from `client/replacementImpact`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Observation { + pub(crate) active_turns: u32, + pub(crate) pending_task_requests: u32, + /// Attached clients that are App Shells, not other keepers or `remote` commands. + pub(crate) app_shells: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Verdict { + Hold(HoldReason), + Leave(LeaveReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HoldReason { + RunningWork, + AppShellAttached, + WaitingForUser, + RecentAppShell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LeaveReason { + Idle, + WaitedTooLong, +} + +impl HoldReason { + pub(crate) fn class(self) -> &'static str { + match self { + Self::RunningWork => "running_work", + Self::AppShellAttached => "app_shell_attached", + Self::WaitingForUser => "waiting_for_user", + Self::RecentAppShell => "recent_app_shell", + } + } +} + +impl LeaveReason { + pub(crate) fn class(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::WaitedTooLong => "waited_too_long", + } + } +} + +/// The keeper's memory between observations. Waiting time is measured by the keeper +/// from the first observation that saw a waiting Task, so it is never longer than +/// the real wait. +#[derive(Debug, Clone)] +pub(crate) struct KeeperClock { + policy: KeeperPolicy, + last_app_shell: Instant, + waiting_since: Option, +} + +impl KeeperClock { + /// The keeper starts because an App Shell is connecting, so that counts as one + /// just seen. + pub(crate) fn new(policy: KeeperPolicy, started: Instant) -> Self { + Self { + policy, + last_app_shell: started, + waiting_since: None, + } + } + + pub(crate) fn observe(&mut self, observation: &Observation, now: Instant) -> Verdict { + if observation.app_shells > 0 { + self.last_app_shell = now; + } + if observation.pending_task_requests == 0 { + self.waiting_since = None; + } else if self.waiting_since.is_none() { + self.waiting_since = Some(now); + } + + if observation.active_turns > 0 { + return Verdict::Hold(HoldReason::RunningWork); + } + if observation.app_shells > 0 { + return Verdict::Hold(HoldReason::AppShellAttached); + } + if let Some(since) = self.waiting_since { + if now.duration_since(since) < self.policy.waiting_limit { + return Verdict::Hold(HoldReason::WaitingForUser); + } + } + if now.duration_since(self.last_app_shell) < self.policy.app_shell_grace { + return Verdict::Hold(HoldReason::RecentAppShell); + } + Verdict::Leave(match self.waiting_since { + Some(_) => LeaveReason::WaitedTooLong, + None => LeaveReason::Idle, + }) + } +} + +#[cfg(test)] +#[path = "policy_tests.rs"] +mod tests; diff --git a/openaide-rs/app-server/src/keeper_command/policy_tests.rs b/openaide-rs/app-server/src/keeper_command/policy_tests.rs new file mode 100644 index 00000000..beae6d29 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command/policy_tests.rs @@ -0,0 +1,132 @@ +// timing-file: mocked — every observation passes an explicit instant; nothing waits. +use std::time::{Duration, Instant}; + +use super::*; + +const MINUTE: Duration = Duration::from_secs(60); +const HOUR: Duration = Duration::from_secs(60 * 60); + +fn idle() -> Observation { + Observation::default() +} + +fn shell_attached() -> Observation { + Observation { + app_shells: 1, + ..Observation::default() + } +} + +fn waiting() -> Observation { + Observation { + pending_task_requests: 1, + ..Observation::default() + } +} + +fn running() -> Observation { + Observation { + active_turns: 1, + ..Observation::default() + } +} + +#[test] +fn holds_an_idle_server_for_the_grace_after_the_app_shell_left() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&shell_attached(), start + MINUTE), + Verdict::Hold(HoldReason::AppShellAttached) + ); + assert_eq!( + clock.observe(&idle(), start + 30 * MINUTE), + Verdict::Hold(HoldReason::RecentAppShell) + ); + assert_eq!( + clock.observe(&idle(), start + 31 * MINUTE), + Verdict::Leave(LeaveReason::Idle) + ); +} + +#[test] +fn the_app_shell_that_started_the_keeper_counts_as_just_seen() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&idle(), start + 29 * MINUTE), + Verdict::Hold(HoldReason::RecentAppShell) + ); + assert_eq!( + clock.observe(&idle(), start + 30 * MINUTE), + Verdict::Leave(LeaveReason::Idle) + ); +} + +#[test] +fn running_work_holds_without_limit() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&running(), start + 72 * HOUR), + Verdict::Hold(HoldReason::RunningWork) + ); +} + +#[test] +fn a_waiting_task_holds_for_a_day_then_the_keeper_leaves() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + assert_eq!( + clock.observe(&waiting(), start + HOUR), + Verdict::Hold(HoldReason::WaitingForUser) + ); + assert_eq!( + clock.observe(&waiting(), start + 24 * HOUR + 59 * MINUTE), + Verdict::Hold(HoldReason::WaitingForUser) + ); + assert_eq!( + clock.observe(&waiting(), start + 25 * HOUR), + Verdict::Leave(LeaveReason::WaitedTooLong) + ); +} + +#[test] +fn an_answered_request_restarts_the_waiting_limit() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + + clock.observe(&waiting(), start + HOUR); + clock.observe(&running(), start + 20 * HOUR); + + assert_eq!( + clock.observe(&waiting(), start + 30 * HOUR), + Verdict::Hold(HoldReason::WaitingForUser) + ); + assert_eq!( + clock.observe(&waiting(), start + 54 * HOUR), + Verdict::Leave(LeaveReason::WaitedTooLong) + ); +} + +#[test] +fn an_attached_app_shell_holds_past_the_waiting_limit() { + let start = Instant::now(); + let mut clock = KeeperClock::new(KeeperPolicy::default(), start); + let attached_and_waiting = Observation { + pending_task_requests: 1, + app_shells: 1, + ..Observation::default() + }; + + clock.observe(&attached_and_waiting, start); + + assert_eq!( + clock.observe(&attached_and_waiting, start + 48 * HOUR), + Verdict::Hold(HoldReason::AppShellAttached) + ); +} diff --git a/openaide-rs/app-server/src/keeper_command_tests.rs b/openaide-rs/app-server/src/keeper_command_tests.rs new file mode 100644 index 00000000..8cefe9c6 --- /dev/null +++ b/openaide-rs/app-server/src/keeper_command_tests.rs @@ -0,0 +1,63 @@ +use openaide_app_server_protocol::client::{ + AttachedClientSummary, ClientReplacementImpactResult, ShellKind, +}; + +use super::*; + +fn words(text: &str) -> Vec { + text.split_whitespace().map(str::to_string).collect() +} + +fn client(kind: ShellKind) -> AttachedClientSummary { + AttachedClientSummary { + kind, + name: None, + app_version: None, + protocol_version: None, + reconnecting: false, + } +} + +#[test] +fn reads_start_modes_and_refuses_anything_else() { + assert_eq!(parse(&words("start")), Ok(Action::Start(Mode::Own))); + assert_eq!( + parse(&words("start --replace")), + Ok(Action::Start(Mode::Replace)) + ); + assert_eq!(parse(&words("run --join")), Ok(Action::Run(Mode::Join))); + assert_eq!(parse(&words("stop")), Ok(Action::Stop)); + assert!(parse(&words("start --replace --join")).is_err()); + assert!(parse(&words("stop now")).is_err()); + assert!(parse(&words("restart")).is_err()); +} + +#[test] +fn only_app_shells_count_as_attached() { + let impact = ClientReplacementImpactResult { + other_clients: vec![ + client(ShellKind::Desktop), + client(ShellKind::Mobile), + client(ShellKind::Terminal), + client(ShellKind::Keeper), + ], + active_turns: 1, + pending_task_requests: 2, + }; + + assert_eq!( + observation_from(&impact), + Observation { + active_turns: 1, + pending_task_requests: 2, + app_shells: 2, + } + ); +} + +#[test] +fn a_failure_report_names_only_its_class() { + let line = serde_json::to_string(&Report::failed("not_running")).unwrap(); + + assert_eq!(line, r#"{"outcome":"failed","error":"not_running"}"#); +} diff --git a/openaide-rs/app-server/src/main.rs b/openaide-rs/app-server/src/main.rs index 65473b99..5f3523a1 100644 --- a/openaide-rs/app-server/src/main.rs +++ b/openaide-rs/app-server/src/main.rs @@ -13,6 +13,7 @@ use openaide_app_server::transport::shell_control::ShellControlDispatcher; use openaide_app_server::Runtime; mod app_server_handoff; +mod keeper_command; mod protocol_edge_process; mod remote_command; @@ -43,6 +44,9 @@ fn main() { if arguments.first().map(String::as_str) == Some("remote") { std::process::exit(run_remote_command(&arguments[1..], &storage_root)); } + if arguments.first().map(String::as_str) == Some("keep") { + std::process::exit(run_keeper_command(&arguments[1..], &storage_root)); + } match protocol_mode().as_deref() { Ok(APP_SERVER_PROTOCOL_MODE) => { @@ -140,11 +144,26 @@ fn run_shell_control_stdio(storage_root: PathBuf) { } fn run_remote_command(arguments: &[String], storage_root: &std::path::Path) -> i32 { + with_roots(storage_root, |state_root, runtime_root| { + remote_command::run(arguments, state_root, runtime_root) + }) +} + +fn run_keeper_command(arguments: &[String], storage_root: &std::path::Path) -> i32 { + with_roots(storage_root, |state_root, runtime_root| { + keeper_command::run(arguments, state_root, runtime_root) + }) +} + +fn with_roots( + storage_root: &std::path::Path, + command: impl FnOnce(&StateRoot, &std::path::Path) -> i32, +) -> i32 { let runtime_root = env::var_os(RUNTIME_ROOT_ENV) .map(PathBuf::from) .unwrap_or_else(default_runtime_root); match StateRoot::resolve(storage_root) { - Ok(state_root) => remote_command::run(arguments, &state_root, &runtime_root), + Ok(state_root) => command(&state_root, &runtime_root), Err(error) => { openaide_app_server::logging::error( "protocol_edge_state_root_failed", diff --git a/openaide-rs/app-server/src/remote_command.rs b/openaide-rs/app-server/src/remote_command.rs index dc4f3873..1d2d3b77 100644 --- a/openaide-rs/app-server/src/remote_command.rs +++ b/openaide-rs/app-server/src/remote_command.rs @@ -19,8 +19,8 @@ use openaide_app_server_protocol::devices::{ use openaide_app_server_protocol::methods::{DEVICES_CREATE_INVITE, DEVICES_REMOVE}; use serde_json::json; -mod launch; -mod local_client; +pub(crate) mod launch; +pub(crate) mod local_client; mod text; use launch::Connection; diff --git a/openaide-rs/app-server/src/remote_command/launch.rs b/openaide-rs/app-server/src/remote_command/launch.rs index 56b87553..3644e66e 100644 --- a/openaide-rs/app-server/src/remote_command/launch.rs +++ b/openaide-rs/app-server/src/remote_command/launch.rs @@ -57,6 +57,13 @@ pub(crate) fn attach_or_start( if let Some(connection) = attach(state_root, runtime_root) { return Ok(connection); } + start() +} + +/// Starts an App Server for the default state root in a separate process and reads +/// the address it prints. If another one won the launch meanwhile, the started +/// process prints that one's address instead and exits. +pub(crate) fn start() -> Result { let executable = std::env::current_exe().map_err(|_| LaunchError::Spawn)?; let mut command = Command::new(executable); command diff --git a/openaide-rs/app-server/src/remote_command/local_client.rs b/openaide-rs/app-server/src/remote_command/local_client.rs index c6bbe96b..75d477e6 100644 --- a/openaide-rs/app-server/src/remote_command/local_client.rs +++ b/openaide-rs/app-server/src/remote_command/local_client.rs @@ -44,6 +44,7 @@ pub(crate) struct Answer { } pub(crate) struct LocalClient { + kind: ShellKind, address: SocketAddr, path: String, auth_token: String, @@ -55,6 +56,16 @@ pub(crate) struct LocalClient { impl LocalClient { /// Reads the loopback address the App Server published. Any other host is refused. pub(crate) fn new(endpoint_url: &str, auth_token: &str) -> Result { + Self::with_kind(ShellKind::Terminal, endpoint_url, auth_token) + } + + /// A client that introduces itself as `kind`: `terminal` for the `remote` + /// command, `keeper` for the keeper on an SSH Computer. + pub(crate) fn with_kind( + kind: ShellKind, + endpoint_url: &str, + auth_token: &str, + ) -> Result { let rest = endpoint_url .strip_prefix("http://") .ok_or(ClientError::Malformed)?; @@ -67,12 +78,17 @@ impl LocalClient { return Err(ClientError::Malformed); } let instance = uuid::Uuid::new_v4().simple().to_string(); + let prefix = match kind { + ShellKind::Keeper => "keeper", + _ => "terminal", + }; Ok(Self { + kind, address, path: path.to_string(), auth_token: auth_token.to_string(), - connection_id: format!("terminal-{instance}"), - client_instance_id: format!("terminal-{instance}"), + connection_id: format!("{prefix}-{instance}"), + client_instance_id: format!("{prefix}-{instance}"), next_request: 0, }) } @@ -83,7 +99,7 @@ impl LocalClient { json!({ "clientInstanceId": self.client_instance_id, "shell": { - "kind": ShellKind::Terminal, + "kind": self.kind, "name": "OpenAIDE", "version": env!("CARGO_PKG_VERSION"), }, diff --git a/openaide-rs/app-server/tests/keeper_command.rs b/openaide-rs/app-server/tests/keeper_command.rs new file mode 100644 index 00000000..26d21bbe --- /dev/null +++ b/openaide-rs/app-server/tests/keeper_command.rs @@ -0,0 +1,151 @@ +//! Drives the built App Server program the way an App Shell's SSH launcher does +//! on an SSH Computer (ADR-0069). + +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::Path; +use std::process::{Command, Stdio}; + +use serde_json::{json, Value}; + +const PROGRAM: &str = env!("CARGO_BIN_EXE_openaide-app-server"); + +fn keep(root: &Path, arguments: &[&str]) -> (bool, Value) { + let output = Command::new(PROGRAM) + .arg("keep") + .args(arguments) + .env("OPENAIDE_STORAGE_ROOT", root.join("state")) + .env("OPENAIDE_RUNTIME_ROOT", root.join("run")) + .env_remove("OPENAIDE_APP_SERVER_PROTOCOL") + .env_remove("OPENAIDE_RUNTIME_PROTOCOL") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .unwrap(); + let text = String::from_utf8_lossy(&output.stdout); + let report = serde_json::from_str(text.trim()) + .unwrap_or_else(|_| panic!("keep {arguments:?} printed {text:?}")); + (output.status.success(), report) +} + +/// Stops a keeper a failed assertion left behind. +struct StopKeeper<'a>(&'a Path); + +impl Drop for StopKeeper<'_> { + fn drop(&mut self) { + let _ = keep(self.0, &["stop"]); + } +} + +/// A minimal App Shell: one HTTP request per call, as the launcher's client is. +struct Shell { + address: SocketAddr, + path: String, + token: String, + next: u32, +} + +impl Shell { + fn attach(report: &Value) -> Self { + let url = report["endpointUrl"].as_str().unwrap(); + let rest = url.strip_prefix("http://").unwrap(); + let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); + let mut shell = Self { + address: authority.parse().unwrap(), + path: if path.is_empty() { "/" } else { path }.to_string(), + token: report["authToken"].as_str().unwrap().to_string(), + next: 0, + }; + shell.call( + "client/initialize", + json!({ + "clientInstanceId": "desktop-1", + "shell": { "kind": "desktop", "name": "Desktop", "version": "test" }, + "requestedSurface": { "kind": "home" }, + }), + ); + shell + } + + fn call(&mut self, method: &str, params: Value) -> Value { + self.next += 1; + let id = format!("shell-{}", self.next); + let body = + json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + let mut stream = TcpStream::connect(self.address).unwrap(); + write!( + stream, + "POST {} HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {}\r\nX-OpenAIDE-Connection-Id: desktop-1\r\nX-OpenAIDE-Client-Instance-Id: desktop-1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + self.path, self.address, self.token, body.len(), body + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + let (_, body) = response.split_once("\r\n\r\n").unwrap(); + let messages = match serde_json::from_str::(body).unwrap() { + Value::Array(messages) => messages, + message => vec![message], + }; + let reply = messages + .into_iter() + .find(|message| message["id"] == id) + .unwrap(); + assert!(reply.get("error").is_none(), "{method} failed: {reply}"); + reply["result"]["result"].clone() + } +} + +#[test] +fn start_brings_up_one_kept_app_server_that_stop_lets_go() { + let root = tempfile::tempdir().unwrap(); + let _cleanup = StopKeeper(root.path()); + + let (ok, first) = keep(root.path(), &["start"]); + assert!(ok, "{first}"); + assert_eq!(first["outcome"], "started", "{first}"); + assert_eq!(first["keeper"], "new"); + assert_eq!(first["appVersion"], env!("CARGO_PKG_VERSION")); + + let (ok, second) = keep(root.path(), &["start"]); + assert!(ok, "{second}"); + assert_eq!(second["outcome"], "attached", "{second}"); + assert_eq!(second["keeper"], "existing"); + assert_eq!(second["endpointUrl"], first["endpointUrl"]); + + let mut shell = Shell::attach(&second); + let impact = shell.call("client/replacementImpact", json!({})); + let kinds: Vec<&str> = impact["otherClients"] + .as_array() + .unwrap() + .iter() + .map(|client| client["kind"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, ["keeper"], "{impact}"); + + let (ok, stopped) = keep(root.path(), &["stop"]); + assert!(ok, "{stopped}"); + assert_eq!(stopped["outcome"], "stopped"); + let impact = shell.call("client/replacementImpact", json!({})); + assert!(impact.get("otherClients").is_none(), "{impact}"); + + let (ok, again) = keep(root.path(), &["stop"]); + assert!(ok, "{again}"); + assert_eq!(again["outcome"], "not_running"); + shell.call("client/detach", json!({})); +} + +#[test] +fn join_without_a_running_app_server_starts_nothing() { + let root = tempfile::tempdir().unwrap(); + let _cleanup = StopKeeper(root.path()); + + let (ok, report) = keep(root.path(), &["start", "--join"]); + + assert!(ok, "{report}"); + assert_eq!( + report, + json!({ "outcome": "failed", "error": "not_running" }) + ); +} diff --git a/packages/app-server-client/src/generated/protocol.ts b/packages/app-server-client/src/generated/protocol.ts index cdd18fb9..ad0b1d32 100644 --- a/packages/app-server-client/src/generated/protocol.ts +++ b/packages/app-server-client/src/generated/protocol.ts @@ -305,7 +305,7 @@ export type ClientWorkspaceRoot = { path: string, }; export type ShellDescriptor = { kind: ShellKind, name?: string | null, version?: string | null, }; -export type ShellKind = "web" | "desktop" | "mobile" | "vscodeExtension" | "terminal"; +export type ShellKind = "web" | "desktop" | "mobile" | "vscodeExtension" | "terminal" | "keeper"; export type RequestedSurface = { "kind": "home" } | { "kind": "project", projectId: ProjectId, } | { "kind": "newTask", projectId?: ProjectId | null, } | { "kind": "task", taskId: TaskId, } | { "kind": "settings", section?: SettingsSection | null, }; From 25dfb8c6bcb26e6749101310a3a60756992c3f58 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 07:58:56 +0200 Subject: [PATCH 05/21] feat(remote-client): start the App Server on an SSH Computer Add the launcher App Shells share for SSH Computers (ADR-0069). Through a transport each App Shell provides, it checks that the computer is Linux x64, uploads this release's App Server only when the installed one's SHA-256 differs, has the installed binary's keeper attach to or start the App Server, and forwards the loopback endpoint. An older App Server that accepts this release's protocol is joined and offered an update; an older one it cannot use needs a confirmed update; a newer or unordered one is never replaced. The launcher also reports when logind would end the App Server at logout without lingering. Commands are single `sh -c` lines that every common login shell passes through unchanged, and each stage logs its outcome and duration without the token or paths. --- Cargo.lock | 1 + openaide-rs/app-server/tests/ssh_launch.rs | 126 +++++ openaide-rs/remote-client/Cargo.toml | 3 + openaide-rs/remote-client/src/lib.rs | 5 + openaide-rs/remote-client/src/ssh.rs | 459 ++++++++++++++++++ openaide-rs/remote-client/src/ssh/remote.rs | 197 ++++++++ .../remote-client/src/ssh/remote_tests.rs | 126 +++++ openaide-rs/remote-client/src/ssh/version.rs | 75 +++ .../remote-client/src/ssh/version_tests.rs | 45 ++ openaide-rs/remote-client/src/ssh_tests.rs | 303 ++++++++++++ 10 files changed, 1340 insertions(+) create mode 100644 openaide-rs/app-server/tests/ssh_launch.rs create mode 100644 openaide-rs/remote-client/src/ssh.rs create mode 100644 openaide-rs/remote-client/src/ssh/remote.rs create mode 100644 openaide-rs/remote-client/src/ssh/remote_tests.rs create mode 100644 openaide-rs/remote-client/src/ssh/version.rs create mode 100644 openaide-rs/remote-client/src/ssh/version_tests.rs create mode 100644 openaide-rs/remote-client/src/ssh_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 30221e17..002fe4f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2712,6 +2712,7 @@ dependencies = [ "openaide-app-server-protocol", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "tokio", ] diff --git a/openaide-rs/app-server/tests/ssh_launch.rs b/openaide-rs/app-server/tests/ssh_launch.rs new file mode 100644 index 00000000..da57b953 --- /dev/null +++ b/openaide-rs/app-server/tests/ssh_launch.rs @@ -0,0 +1,126 @@ +//! Drives the shared SSH launcher against this machine: every command runs +//! through `sh` the way an SSH server runs it, and the forward is the identity, +//! so the release binary built here is installed, kept and stopped for real. + +#![cfg(target_os = "linux")] + +use std::io::Read; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +use openaide_remote_client::{ + LaunchError, Launched, RemoteOutput, ServerRelease, SshLauncher, SshTransport, StartMode, + StopOutcome, TransportError, +}; + +const PROGRAM: &str = env!("CARGO_BIN_EXE_openaide-app-server"); +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Runs commands in a temporary home with its own state and runtime roots. +struct LocalShell { + home: PathBuf, +} + +impl SshTransport for LocalShell { + type Forward = (); + + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let mut child = Command::new("sh") + .arg("-c") + .arg(command) + .env("HOME", &self.home) + .env("OPENAIDE_STORAGE_ROOT", self.home.join("state")) + .env("OPENAIDE_RUNTIME_ROOT", self.home.join("run")) + .env_remove("OPENAIDE_APP_SERVER_PROTOCOL") + .env_remove("OPENAIDE_RUNTIME_PROTOCOL") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| TransportError { class: "spawn" })?; + let mut stdin = child.stdin.take().unwrap(); + if let Some(input) = input { + std::io::copy(input, &mut stdin).map_err(|_| TransportError { class: "upload" })?; + } + drop(stdin); + let output = child + .wait_with_output() + .map_err(|_| TransportError { class: "wait" })?; + Ok(RemoteOutput { + success: output.status.success(), + stdout: output.stdout, + }) + } + + fn forward(&mut self, remote_port: u16) -> Result<((), SocketAddr), TransportError> { + Ok(((), SocketAddr::from(([127, 0, 0, 1], remote_port)))) + } +} + +/// Lets a kept App Server go if an assertion fails first. +struct StopKeeper<'a>(&'a SshLauncher, &'a Path); + +impl Drop for StopKeeper<'_> { + fn drop(&mut self) { + let mut shell = LocalShell { + home: self.1.to_path_buf(), + }; + let _ = self.0.stop(&mut shell, "cleanup"); + } +} + +fn sha256(path: &str) -> String { + let output = Command::new("sha256sum").arg(path).output().unwrap(); + String::from_utf8(output.stdout).unwrap()[..64].to_string() +} + +fn launch(launcher: &SshLauncher, home: &Path) -> Result, LaunchError> { + let mut shell = LocalShell { + home: home.to_path_buf(), + }; + let mut fetch = || -> Result, &'static str> { + Ok(Box::new(std::fs::File::open(PROGRAM).unwrap())) + }; + launcher.launch(&mut shell, StartMode::Keep, &mut fetch, "test") +} + +#[test] +fn installs_keeps_and_stops_the_release_through_a_shell() { + let home = tempfile::tempdir().unwrap(); + let launcher = SshLauncher::new( + ServerRelease::new(VERSION, &sha256(PROGRAM)).unwrap(), + Arc::new(|_, _| {}), + Box::new(|_| {}), + ); + let _cleanup = StopKeeper(&launcher, home.path()); + + let Ok(Launched::Ready(first)) = launch(&launcher, home.path()) else { + panic!("the first launch did not reach a ready App Server"); + }; + assert!(!first.keeper_existed); + assert_eq!(first.app_version, VERSION); + assert!(home + .path() + .join(format!( + ".local/share/openaide/app-server/{VERSION}/openaide-app-server" + )) + .is_file()); + + let Ok(Launched::Ready(second)) = launch(&launcher, home.path()) else { + panic!("the second launch did not reach a ready App Server"); + }; + assert!(second.keeper_existed); + assert_eq!(second.endpoint_url, first.endpoint_url); + assert_eq!(second.auth_token, first.auth_token); + + let mut shell = LocalShell { + home: home.path().to_path_buf(), + }; + assert_eq!(launcher.stop(&mut shell, "test"), Ok(StopOutcome::Stopped)); +} diff --git a/openaide-rs/remote-client/Cargo.toml b/openaide-rs/remote-client/Cargo.toml index ab6ce5bb..abbb9a87 100644 --- a/openaide-rs/remote-client/Cargo.toml +++ b/openaide-rs/remote-client/Cargo.toml @@ -13,3 +13,6 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/openaide-rs/remote-client/src/lib.rs b/openaide-rs/remote-client/src/lib.rs index 75a75245..21bffe18 100644 --- a/openaide-rs/remote-client/src/lib.rs +++ b/openaide-rs/remote-client/src/lib.rs @@ -9,6 +9,7 @@ mod client; mod gateway; mod pairing_code; +mod ssh; pub use client::{ application_alpn, bind, device_key_from_text, device_key_text, new_device_key, redeem, Failure, @@ -17,3 +18,7 @@ pub use client::{ pub use gateway::Gateway; pub use iroh::{Endpoint, PublicKey, SecretKey}; pub use pairing_code::{clamp_label, InviteCode, PairingCodeError}; +pub use ssh::{ + FetchBinary, LaunchError, Launched, Ready, RemoteOutput, ServerRelease, SshLauncher, + SshTransport, Stage, StartMode, StopOutcome, TransportError, +}; diff --git a/openaide-rs/remote-client/src/ssh.rs b/openaide-rs/remote-client/src/ssh.rs new file mode 100644 index 00000000..06389d46 --- /dev/null +++ b/openaide-rs/remote-client/src/ssh.rs @@ -0,0 +1,459 @@ +//! Starting the App Server on an SSH Computer (ADR-0069), shared by the App +//! Shells that offer one. +//! +//! The App Shell installs its own release of the App Server on the computer, +//! asks the installed binary's keeper to attach to or start the App Server for +//! the default state root, and forwards the App Server's loopback endpoint to +//! its own loopback. The App Shell then holds the token like a same-machine App +//! Shell; nothing is paired. Each App Shell brings its own [`SshTransport`]: the +//! Desktop drives the system `ssh`, Android an embedded client. +//! +//! Every call blocks; App Shells run it off their UI thread. + +mod remote; +mod version; + +use std::io::Read; +use std::net::SocketAddr; +use std::time::Instant; + +use openaide_app_server_protocol::client::APP_SERVER_PROTOCOL_VERSION; +use serde_json::json; + +use crate::Logger; +use remote::{KeeperReport, Probe, RemoteEndpoint}; +use version::Running; + +/// One SSH connection to the computer, as the App Shell implements it. +pub trait SshTransport { + /// Kept by the App Shell while it uses the App Server; dropping it ends the forward. + type Forward; + + /// Runs one command line on the computer, streaming `input` to its stdin. + /// A command that runs and fails is an `Ok` output; `Err` means SSH itself failed. + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result; + + /// Forwards a port on this device's loopback to `remote_port` on the + /// computer's loopback and returns the local address. + fn forward(&mut self, remote_port: u16) -> Result<(Self::Forward, SocketAddr), TransportError>; +} + +pub struct RemoteOutput { + pub success: bool, + pub stdout: Vec, +} + +/// Why SSH failed, as a stable class the transport chooses, such as +/// `unreachable`, `auth_failed`, or `host_key_rejected`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("ssh failed: {class}")] +pub struct TransportError { + pub class: &'static str, +} + +/// The App Server release this App Shell ships: its version and the SHA-256 of +/// its Linux x64 binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerRelease { + version: String, + sha256: String, +} + +impl ServerRelease { + /// `None` unless the version is a plain release version and the checksum is + /// lowercase hex, because both become words of a remote command. + pub fn new(version: &str, sha256: &str) -> Option { + (remote::is_version(version) && remote::is_sha256(sha256)).then(|| Self { + version: version.to_string(), + sha256: sha256.to_string(), + }) + } + + pub fn version(&self) -> &str { + &self.version + } +} + +/// Supplies the release binary only when the computer lacks it, so a first +/// download happens once and only when needed. Errors are stable classes. +pub type FetchBinary<'a> = &'a mut dyn FnMut() -> Result, &'static str>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StartMode { + /// Attach to the App Server already there, or start one; never replace one. + Keep, + /// Replace an older App Server. Only after the user confirmed the update. + Replace, +} + +/// A step the startup screen names while it runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Stage { + Probe, + Fetch, + Upload, + Start, + Forward, +} + +impl Stage { + pub fn as_str(self) -> &'static str { + match self { + Self::Probe => "probe", + Self::Fetch => "fetch", + Self::Upload => "upload", + Self::Start => "start", + Self::Forward => "forward", + } + } +} + +pub enum Launched { + Ready(Ready), + /// An older App Server this App Shell cannot use holds the state root. + /// Launching again with [`StartMode::Replace`] updates it once the user + /// agrees to interrupt whatever it is doing. + UpdateRequired { + running_version: String, + }, +} + +/// An App Server this App Shell can use through the forward. Not `Debug`: it +/// holds the token. +pub struct Ready { + pub forward: F, + pub endpoint_url: String, + pub auth_token: String, + pub app_version: String, + /// The App Server is older than this App Shell but speaks its protocol, so + /// the App Shell attached and may offer an update. + pub update_available: bool, + /// Another App Shell's keeper already held the App Server. + pub keeper_existed: bool, + /// Logind will end the App Server when the user's last session closes; + /// `loginctl enable-linger` keeps it. + pub suggest_linger: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LaunchError { + #[error("ssh failed during {}: {}", .stage.as_str(), .error.class)] + Transport { stage: Stage, error: TransportError }, + #[error("the computer is not Linux x64")] + UnsupportedPlatform, + #[error("the release binary is unavailable: {0}")] + Fetch(&'static str), + #[error("the release binary could not be installed: {0}")] + Install(&'static str), + #[error("the computer runs a newer App Server ({running_version})")] + ServerNewer { running_version: String }, + #[error("the computer runs an App Server of an unknown version")] + ServerVersionUnknown, + #[error("the keeper failed: {0}")] + Keeper(String), + #[error("the computer gave an unreadable answer during {}", .0.as_str())] + UnreadableAnswer(Stage), +} + +impl LaunchError { + pub fn stage(&self) -> Stage { + match self { + Self::Transport { stage, .. } | Self::UnreadableAnswer(stage) => *stage, + Self::UnsupportedPlatform => Stage::Probe, + Self::Fetch(_) => Stage::Fetch, + Self::Install(_) => Stage::Upload, + Self::ServerNewer { .. } | Self::ServerVersionUnknown | Self::Keeper(_) => Stage::Start, + } + } + + pub fn class(&self) -> &str { + match self { + Self::Transport { error, .. } => error.class, + Self::UnsupportedPlatform => "unsupported_platform", + Self::Fetch(class) | Self::Install(class) => class, + Self::ServerNewer { .. } => "server_newer", + Self::ServerVersionUnknown => "server_version_unknown", + Self::Keeper(class) => class, + Self::UnreadableAnswer(_) => "unreadable_answer", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StopOutcome { + Stopped, + NotRunning, + /// The keeper did not end in time; the App Server keeps running for now. + StillRunning, +} + +/// Starts App Servers on SSH Computers for one App Shell release. +pub struct SshLauncher { + release: ServerRelease, + logger: Logger, + progress: Box, +} + +impl SshLauncher { + pub fn new( + release: ServerRelease, + logger: Logger, + progress: Box, + ) -> Self { + Self { + release, + logger, + progress, + } + } + + /// Installs this release if the computer lacks it, has its keeper hold an + /// App Server, and forwards it. `operation_id` correlates the log events. + pub fn launch( + &self, + transport: &mut T, + mode: StartMode, + fetch: FetchBinary<'_>, + operation_id: &str, + ) -> Result, LaunchError> { + let started = Instant::now(); + (self.logger)( + "ssh_launch_started", + json!({ "operationId": operation_id, "mode": mode_name(mode) }), + ); + let result = self.launch_stages(transport, mode, fetch, operation_id); + let mut fields = json!({ + "operationId": operation_id, + "durationMs": started.elapsed().as_millis() as u64, + }); + match &result { + Ok(Launched::Ready(ready)) => { + fields["outcome"] = json!("ready"); + fields["keeper"] = json!(if ready.keeper_existed { + "existing" + } else { + "new" + }); + fields["updateAvailable"] = json!(ready.update_available); + fields["suggestLinger"] = json!(ready.suggest_linger); + } + Ok(Launched::UpdateRequired { .. }) => fields["outcome"] = json!("update_required"), + Err(error) => { + fields["outcome"] = json!("failure"); + fields["stage"] = json!(error.stage().as_str()); + fields["errorClass"] = json!(error.class()); + } + } + (self.logger)("ssh_launch_completed", fields); + result + } + + fn launch_stages( + &self, + transport: &mut T, + mode: StartMode, + fetch: FetchBinary<'_>, + operation_id: &str, + ) -> Result, LaunchError> { + let version = self.release.version.as_str(); + let probe = self.stage(Stage::Probe, operation_id, || { + let output = run(transport, Stage::Probe, &remote::probe(version), None)?; + let probe = Probe::parse(&output.stdout); + if !probe.is_supported() { + return Err(LaunchError::UnsupportedPlatform); + } + Ok(probe) + })?; + if probe.installed_sha256.as_deref() != Some(self.release.sha256.as_str()) { + let mut binary = self.stage(Stage::Fetch, operation_id, || { + fetch().map_err(LaunchError::Fetch) + })?; + self.stage(Stage::Upload, operation_id, || { + let command = remote::install(version, &self.release.sha256); + let output = run(transport, Stage::Upload, &command, Some(&mut *binary))?; + remote::install_verdict(&output.stdout).map_err(LaunchError::Install) + })?; + } + + let started = self.stage(Stage::Start, operation_id, || { + let flags: &[&str] = match mode { + StartMode::Keep => &["start"], + StartMode::Replace => &["start", "--replace"], + }; + let report = keep(transport, version, flags)?; + if report.outcome != "mismatch" { + return accept(report, false); + } + let running_version = report.app_version.clone().unwrap_or_default(); + match version::classify( + version, + APP_SERVER_PROTOCOL_VERSION, + &running_version, + report.protocol_version.as_deref(), + ) { + Running::Newer => Err(LaunchError::ServerNewer { running_version }), + Running::Unknown => Err(LaunchError::ServerVersionUnknown), + Running::OlderIncompatible => Ok(Start::UpdateRequired { running_version }), + Running::OlderCompatible => { + accept(keep(transport, version, &["start", "--join"])?, true) + } + } + })?; + let held = match started { + Start::Held(held) => held, + Start::UpdateRequired { running_version } => { + return Ok(Launched::UpdateRequired { running_version }) + } + }; + + let (forward, local) = self.stage(Stage::Forward, operation_id, || { + transport + .forward(held.endpoint.port) + .map_err(|error| LaunchError::Transport { + stage: Stage::Forward, + error, + }) + })?; + Ok(Launched::Ready(Ready { + forward, + endpoint_url: format!("http://{local}{}", held.endpoint.path), + auth_token: held.auth_token, + app_version: held.app_version, + update_available: held.joined_older, + keeper_existed: held.keeper_existed, + suggest_linger: probe.suggests_linger(), + })) + } + + /// Lets the App Server go: its keeper ends, and the App Server stops once + /// its last App Shell leaves too. + pub fn stop( + &self, + transport: &mut T, + operation_id: &str, + ) -> Result { + let started = Instant::now(); + let result = keep(transport, &self.release.version, &["stop"]).and_then(|report| { + match report.outcome.as_str() { + "stopped" => Ok(StopOutcome::Stopped), + "not_running" => Ok(StopOutcome::NotRunning), + "still_running" => Ok(StopOutcome::StillRunning), + _ => Err(LaunchError::Keeper(report.error_class())), + } + }); + (self.logger)( + "ssh_stop_completed", + json!({ + "operationId": operation_id, + "outcome": match &result { + Ok(StopOutcome::Stopped) => "stopped", + Ok(StopOutcome::NotRunning) => "not_running", + Ok(StopOutcome::StillRunning) => "still_running", + Err(_) => "failure", + }, + "errorClass": result.as_ref().err().map(LaunchError::class), + "durationMs": started.elapsed().as_millis() as u64, + }), + ); + result + } + + /// Announces a stage to the startup screen and logs how it ended. + fn stage( + &self, + stage: Stage, + operation_id: &str, + body: impl FnOnce() -> Result, + ) -> Result { + (self.progress)(stage); + let started = Instant::now(); + let result = body(); + (self.logger)( + "ssh_launch_stage_completed", + json!({ + "operationId": operation_id, + "stage": stage.as_str(), + "outcome": if result.is_ok() { "success" } else { "failure" }, + "errorClass": result.as_ref().err().map(LaunchError::class), + "durationMs": started.elapsed().as_millis() as u64, + }), + ); + result + } +} + +enum Start { + Held(Held), + UpdateRequired { running_version: String }, +} + +/// The App Server a keeper holds, before it is forwarded. +struct Held { + endpoint: RemoteEndpoint, + auth_token: String, + app_version: String, + keeper_existed: bool, + joined_older: bool, +} + +fn accept(report: KeeperReport, joined_older: bool) -> Result { + if !matches!(report.outcome.as_str(), "started" | "attached" | "joined") { + return Err(LaunchError::Keeper(report.error_class())); + } + let unreadable = || LaunchError::UnreadableAnswer(Stage::Start); + let endpoint = report + .endpoint_url + .as_deref() + .and_then(RemoteEndpoint::parse) + .ok_or_else(unreadable)?; + Ok(Start::Held(Held { + endpoint, + auth_token: report + .auth_token + .filter(|token| !token.is_empty()) + .ok_or_else(unreadable)?, + app_version: report.app_version.unwrap_or_default(), + keeper_existed: report.keeper.as_deref() == Some("existing"), + joined_older, + })) +} + +fn keep( + transport: &mut T, + version: &str, + arguments: &[&str], +) -> Result { + let output = run( + transport, + Stage::Start, + &remote::keep(version, arguments), + None, + )?; + KeeperReport::parse(&output.stdout).ok_or(LaunchError::UnreadableAnswer(Stage::Start)) +} + +fn run( + transport: &mut T, + stage: Stage, + command: &str, + input: Option<&mut dyn Read>, +) -> Result { + transport + .run(command, input) + .map_err(|error| LaunchError::Transport { stage, error }) +} + +fn mode_name(mode: StartMode) -> &'static str { + match mode { + StartMode::Keep => "keep", + StartMode::Replace => "replace", + } +} + +#[cfg(test)] +#[path = "ssh_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/remote.rs b/openaide-rs/remote-client/src/ssh/remote.rs new file mode 100644 index 00000000..c1c44b3a --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/remote.rs @@ -0,0 +1,197 @@ +//! The commands an App Shell runs on an SSH Computer and the answers it reads. +//! +//! The SSH server hands each command line to the user's login shell, which may +//! be bash, zsh, fish or tcsh. Every command is therefore one `sh -c '…'` line: +//! the scripts hold no single quote, no `!` and no newline, which those shells +//! all pass to `sh` unchanged, and every argument is a single-quoted word. + +use serde::Deserialize; + +/// The versioned install directory, shared with the Desktop's WSL runtime. +const INSTALL_DIRECTORY: &str = r#"d="$HOME/.local/share/openaide/app-server/$1""#; + +/// Prints `key=value` facts: the platform, the installed binary's checksum, and +/// whether logind ends the user's processes at logout. +const PROBE: &str = r#"b="$d/openaide-app-server"; echo "platform=$(uname -sm)"; if [ -x "$b" ] && command -v sha256sum >/dev/null 2>&1; then echo "installed=$(sha256sum "$b" | cut -d " " -f 1)"; fi; if command -v loginctl >/dev/null 2>&1; then echo "kill_user_processes=$(loginctl show-session -p KillUserProcesses --value 2>/dev/null)"; echo "linger=$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)"; fi"#; + +/// Receives the binary on stdin and moves it into place only if its checksum +/// matches, so an interrupted or corrupted upload never becomes the install. +const INSTALL: &str = r#"command -v sha256sum >/dev/null 2>&1 || { echo checksum_tool_missing; exit 0; }; mkdir -p "$d" || { echo install_failed; exit 0; }; t="$d/openaide-app-server.upload.$$"; cat > "$t" || { rm -f "$t"; echo install_failed; exit 0; }; [ "$(sha256sum "$t" | cut -d " " -f 1)" = "$2" ] || { rm -f "$t"; echo checksum_mismatch; exit 0; }; chmod 700 "$t" && mv -f "$t" "$d/openaide-app-server" && echo installed || { rm -f "$t"; echo install_failed; }"#; + +/// Runs the installed binary's `keep` command with the remaining arguments. +const KEEP: &str = r#"b="$d/openaide-app-server"; shift; exec "$b" keep "$@""#; + +pub(crate) fn probe(version: &str) -> String { + command(PROBE, &[version]) +} + +pub(crate) fn install(version: &str, sha256: &str) -> String { + command(INSTALL, &[version, sha256]) +} + +pub(crate) fn keep(version: &str, arguments: &[&str]) -> String { + let mut all = vec![version]; + all.extend_from_slice(arguments); + command(KEEP, &all) +} + +fn command(script: &str, arguments: &[&str]) -> String { + let mut line = format!("sh -c '{INSTALL_DIRECTORY}; {script}' openaide"); + for argument in arguments { + line.push(' '); + line.push_str("e(argument)); + } + line +} + +fn quote(argument: &str) -> String { + format!("'{}'", argument.replace('\'', r"'\''")) +} + +/// What the probe found on the SSH Computer. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Probe { + pub(crate) platform: String, + pub(crate) installed_sha256: Option, + pub(crate) kill_user_processes: Option, + pub(crate) linger: Option, +} + +impl Probe { + pub(crate) fn parse(stdout: &[u8]) -> Self { + let mut probe = Self::default(); + for line in String::from_utf8_lossy(stdout).lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key { + "platform" => probe.platform = value.to_string(), + "installed" if !value.is_empty() => { + probe.installed_sha256 = Some(value.to_ascii_lowercase()) + } + "kill_user_processes" => probe.kill_user_processes = yes_no(value), + "linger" => probe.linger = yes_no(value), + _ => {} + } + } + probe + } + + pub(crate) fn is_supported(&self) -> bool { + self.platform == "Linux x86_64" + } + + /// Logind ends everything the user started once their last session closes, + /// the keeper included, unless the user lingers. + pub(crate) fn suggests_linger(&self) -> bool { + self.kill_user_processes == Some(true) && self.linger != Some(true) + } +} + +fn yes_no(value: &str) -> Option { + match value { + "yes" => Some(true), + "no" => Some(false), + _ => None, + } +} + +/// The installer's one-word verdict. +pub(crate) fn install_verdict(stdout: &[u8]) -> Result<(), &'static str> { + match last_line(stdout) { + Some("installed") => Ok(()), + Some("checksum_mismatch") => Err("checksum_mismatch"), + Some("checksum_tool_missing") => Err("checksum_tool_missing"), + _ => Err("install_failed"), + } +} + +/// The JSON line `keep start` and `keep stop` print. +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct KeeperReport { + pub(crate) outcome: String, + pub(crate) keeper: Option, + pub(crate) endpoint_url: Option, + pub(crate) auth_token: Option, + pub(crate) app_version: Option, + pub(crate) protocol_version: Option, + pub(crate) error: Option, +} + +impl KeeperReport { + pub(crate) fn parse(stdout: &[u8]) -> Option { + serde_json::from_str(last_line(stdout)?).ok() + } + + /// The keeper's error class, or a fixed word when another build printed + /// something that is not a class and so must not reach the logs. + pub(crate) fn error_class(&self) -> String { + match self.error.as_deref() { + Some(class) if is_class(class) => class.to_string(), + _ => "unrecognized".to_string(), + } + } +} + +fn is_class(text: &str) -> bool { + !text.is_empty() + && text.len() <= 48 + && text + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte == b'_') +} + +fn last_line(stdout: &[u8]) -> Option<&str> { + std::str::from_utf8(stdout) + .ok()? + .lines() + .map(str::trim) + .rfind(|line| !line.is_empty()) +} + +/// The remote endpoint split so that only its port crosses SSH. The App Server +/// listens on loopback; any other host would make this App Shell forward to a +/// machine the user did not choose, so it is refused. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RemoteEndpoint { + pub(crate) port: u16, + pub(crate) path: String, +} + +impl RemoteEndpoint { + pub(crate) fn parse(url: &str) -> Option { + let rest = url.strip_prefix("http://")?; + let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); + let (host, port) = authority.rsplit_once(':')?; + if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") { + return None; + } + Some(Self { + port: port.parse().ok().filter(|port| *port != 0)?, + path: if path.is_empty() { "/" } else { path }.to_string(), + }) + } +} + +/// Release versions and checksums are interpolated as shell words; refusing +/// anything else keeps them inert even if quoting were wrong. +pub(crate) fn is_version(text: &str) -> bool { + !text.is_empty() + && text.len() <= 64 + && text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'+')) +} + +pub(crate) fn is_sha256(text: &str) -> bool { + text.len() == 64 + && text + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(test)] +#[path = "remote_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/remote_tests.rs b/openaide-rs/remote-client/src/ssh/remote_tests.rs new file mode 100644 index 00000000..f3459d77 --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/remote_tests.rs @@ -0,0 +1,126 @@ +use std::process::Command; + +use super::*; + +const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +#[test] +fn scripts_survive_every_login_shell_unchanged() { + for script in [INSTALL_DIRECTORY, PROBE, INSTALL, KEEP] { + assert!(!script.contains(['\'', '!', '\n']), "{script}"); + } + assert_eq!(quote("it's"), r"'it'\''s'"); +} + +/// Runs a generated command line the way an SSH server does, through a shell, +/// against a temporary home. +fn run(line: &str, home: &std::path::Path, input: &[u8]) -> Vec { + use std::io::Write; + let mut child = Command::new("sh") + .arg("-c") + .arg(line) + .env("HOME", home) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap().stdout +} + +#[cfg(unix)] +#[test] +fn installs_only_a_binary_whose_checksum_matches() { + let home = tempfile::tempdir().unwrap(); + let binary = b"#!/bin/sh\necho \"$*\"\n"; + let sha = sha256sum(binary); + + let rejected = run(&install("1.2.3", SHA), home.path(), binary); + assert_eq!(install_verdict(&rejected), Err("checksum_mismatch")); + let installed = home + .path() + .join(".local/share/openaide/app-server/1.2.3/openaide-app-server"); + assert!(!installed.exists()); + + let accepted = run(&install("1.2.3", &sha), home.path(), binary); + assert_eq!(install_verdict(&accepted), Ok(())); + assert_eq!(std::fs::read(&installed).unwrap(), binary); + + let probe = Probe::parse(&run(&probe("1.2.3"), home.path(), b"")); + assert_eq!(probe.installed_sha256.as_deref(), Some(sha.as_str())); + assert!(!probe.platform.is_empty()); + + let kept = run(&keep("1.2.3", &["start", "--join"]), home.path(), b""); + assert_eq!(String::from_utf8_lossy(&kept), "keep start --join\n"); +} + +#[cfg(unix)] +fn sha256sum(bytes: &[u8]) -> String { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload"); + std::fs::write(&path, bytes).unwrap(); + let output = Command::new("sha256sum").arg(&path).output().unwrap(); + String::from_utf8(output.stdout).unwrap()[..64].to_string() +} + +#[test] +fn probe_reads_logind_facts() { + let probe = + Probe::parse(b"platform=Linux x86_64\ninstalled=ABC\nkill_user_processes=yes\nlinger=no\n"); + + assert_eq!( + probe, + Probe { + platform: "Linux x86_64".into(), + installed_sha256: Some("abc".into()), + kill_user_processes: Some(true), + linger: Some(false), + } + ); + assert!(probe.is_supported()); + assert!(probe.suggests_linger()); + assert!(!Probe::parse(b"platform=Linux aarch64\nkill_user_processes=\n").is_supported()); + assert!(!Probe::parse(b"platform=Linux x86_64\nkill_user_processes=\n").suggests_linger()); +} + +#[test] +fn keeper_report_is_the_last_line_and_its_error_is_a_class() { + let report = + KeeperReport::parse(b"motd noise\n{\"outcome\":\"failed\",\"error\":\"keeper_busy\"}\n\n") + .unwrap(); + assert_eq!(report.outcome, "failed"); + assert_eq!(report.error_class(), "keeper_busy"); + + let odd = + KeeperReport::parse(br#"{"outcome":"failed","error":"/home/someone: denied"}"#).unwrap(); + assert_eq!(odd.error_class(), "unrecognized"); + assert!(KeeperReport::parse(b"sh: not found\n").is_none()); +} + +#[test] +fn only_a_loopback_endpoint_is_forwarded() { + assert_eq!( + RemoteEndpoint::parse("http://127.0.0.1:43123/rpc"), + Some(RemoteEndpoint { + port: 43123, + path: "/rpc".into() + }) + ); + assert_eq!( + RemoteEndpoint::parse("http://[::1]:8080").map(|endpoint| endpoint.path), + Some("/".into()) + ); + assert_eq!(RemoteEndpoint::parse("http://10.0.0.5:43123/rpc"), None); + assert_eq!(RemoteEndpoint::parse("https://127.0.0.1:43123/"), None); + assert_eq!(RemoteEndpoint::parse("http://127.0.0.1:0/"), None); +} + +#[test] +fn only_inert_versions_and_checksums_are_accepted() { + assert!(is_version("0.11.0-rc.1+build.5")); + assert!(!is_version("0.11.0; rm -rf ~")); + assert!(!is_version("")); + assert!(is_sha256(SHA)); + assert!(!is_sha256(&SHA.to_uppercase())); + assert!(!is_sha256(&SHA[1..])); +} diff --git a/openaide-rs/remote-client/src/ssh/version.rs b/openaide-rs/remote-client/src/ssh/version.rs new file mode 100644 index 00000000..c4f9c07b --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/version.rs @@ -0,0 +1,75 @@ +//! What an App Shell may do with an App Server of another version that already +//! holds the SSH Computer's state root (ADR-0069): attach to an older one that +//! still speaks its protocol, replace an older one only with the user's +//! confirmation, and never replace a newer one. + +use std::cmp::Ordering; + +use openaide_app_server_protocol::snapshot::ProtocolVersion; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Running { + /// Older, and it accepts this App Shell's protocol: attach and offer an update. + OlderCompatible, + /// Older, and this App Shell cannot use it: only a confirmed update helps. + OlderIncompatible, + /// Newer: replacing it would downgrade the computer. + Newer, + /// The versions cannot be ordered, so neither attaching nor replacing is safe. + Unknown, +} + +pub(crate) fn classify( + shell_version: &str, + shell_protocol: ProtocolVersion, + running_version: &str, + running_protocol: Option<&str>, +) -> Running { + match compare(running_version, shell_version) { + Some(Ordering::Greater) => Running::Newer, + Some(Ordering::Less) => { + let accepts = running_protocol + .and_then(|text| text.parse::().ok()) + .is_some_and(|running| running.accepts_client(shell_protocol)); + if accepts { + Running::OlderCompatible + } else { + Running::OlderIncompatible + } + } + // An equal version cannot be the mismatch the keeper reported. + Some(Ordering::Equal) | None => Running::Unknown, + } +} + +/// Orders release versions `major.minor.patch[-pre][+build]`. A pre-release is +/// older than its release; two different pre-releases of one release are not +/// ordered, because nothing here needs to choose between them. +pub(crate) fn compare(left: &str, right: &str) -> Option { + let (left_core, left_pre) = parse(left)?; + let (right_core, right_pre) = parse(right)?; + match left_core.cmp(&right_core) { + Ordering::Equal => match (left_pre, right_pre) { + (None, None) => Some(Ordering::Equal), + (None, Some(_)) => Some(Ordering::Greater), + (Some(_), None) => Some(Ordering::Less), + (Some(left), Some(right)) => (left == right).then_some(Ordering::Equal), + }, + ordering => Some(ordering), + } +} + +fn parse(version: &str) -> Option<([u64; 3], Option<&str>)> { + let version = version.split_once('+').map_or(version, |(core, _)| core); + let (core, pre) = match version.split_once('-') { + Some((core, pre)) => (core, Some(pre)), + None => (version, None), + }; + let mut parts = core.split('.').map(|part| part.parse::().ok()); + let parsed = [parts.next()??, parts.next()??, parts.next()??]; + parts.next().is_none().then_some((parsed, pre)) +} + +#[cfg(test)] +#[path = "version_tests.rs"] +mod tests; diff --git a/openaide-rs/remote-client/src/ssh/version_tests.rs b/openaide-rs/remote-client/src/ssh/version_tests.rs new file mode 100644 index 00000000..3a5cf12d --- /dev/null +++ b/openaide-rs/remote-client/src/ssh/version_tests.rs @@ -0,0 +1,45 @@ +use super::*; + +const SHELL: ProtocolVersion = ProtocolVersion { major: 2, minor: 2 }; + +#[test] +fn orders_releases_and_their_pre_releases() { + assert_eq!(compare("0.10.0", "0.9.12"), Some(Ordering::Greater)); + assert_eq!(compare("0.10.0", "0.10.0+build.7"), Some(Ordering::Equal)); + assert_eq!(compare("0.11.0-rc.1", "0.11.0"), Some(Ordering::Less)); + assert_eq!(compare("0.11.0-rc.1", "0.11.0-rc.2"), None); + assert_eq!(compare("0.11", "0.11.0"), None); + assert_eq!(compare("dev", "0.11.0"), None); +} + +#[test] +fn an_older_server_that_accepts_the_shell_protocol_can_be_joined() { + assert_eq!( + classify("0.11.0", SHELL, "0.10.1", Some("2.3")), + Running::OlderCompatible + ); + assert_eq!( + classify("0.11.0", SHELL, "0.10.1", Some("2.1")), + Running::OlderIncompatible + ); + assert_eq!( + classify("0.11.0", SHELL, "0.10.1", None), + Running::OlderIncompatible + ); +} + +#[test] +fn a_newer_or_unordered_server_is_never_replaced() { + assert_eq!( + classify("0.11.0", SHELL, "0.12.0", Some("2.2")), + Running::Newer + ); + assert_eq!( + classify("0.11.0", SHELL, "custom", Some("2.2")), + Running::Unknown + ); + assert_eq!( + classify("0.11.0", SHELL, "0.11.0", Some("2.2")), + Running::Unknown + ); +} diff --git a/openaide-rs/remote-client/src/ssh_tests.rs b/openaide-rs/remote-client/src/ssh_tests.rs new file mode 100644 index 00000000..a54d0012 --- /dev/null +++ b/openaide-rs/remote-client/src/ssh_tests.rs @@ -0,0 +1,303 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +use super::*; + +const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const VERSION: &str = "0.11.0"; +const LOCAL: &str = "127.0.0.1:50001"; + +/// Answers each command with the next scripted reply after checking that the +/// command is the expected one. +struct FakeSsh { + replies: VecDeque<(&'static str, Result)>, + uploaded: Option>, + forwarded: Option, +} + +impl FakeSsh { + fn new(replies: Vec<(&'static str, Result)>) -> Self { + Self { + replies: replies.into(), + uploaded: None, + forwarded: None, + } + } +} + +impl SshTransport for FakeSsh { + type Forward = u16; + + fn run( + &mut self, + command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let (expected, reply) = self.replies.pop_front().expect("an unexpected command"); + assert!( + command.contains(expected), + "expected {expected:?} in {command}" + ); + if let Some(input) = input { + let mut bytes = Vec::new(); + input.read_to_end(&mut bytes).unwrap(); + self.uploaded = Some(bytes); + } + reply.map(|stdout| RemoteOutput { + success: true, + stdout: stdout.into_bytes(), + }) + } + + fn forward(&mut self, remote_port: u16) -> Result<(u16, SocketAddr), TransportError> { + self.forwarded = Some(remote_port); + Ok((remote_port, LOCAL.parse().unwrap())) + } +} + +type Events = Arc>>; + +fn launcher() -> (SshLauncher, Events, Arc>>) { + let events: Events = Arc::default(); + let stages: Arc>> = Arc::default(); + let sink = events.clone(); + let seen = stages.clone(); + let launcher = SshLauncher::new( + ServerRelease::new(VERSION, SHA).unwrap(), + Arc::new(move |name, fields| sink.lock().unwrap().push((name, fields))), + Box::new(move |stage| seen.lock().unwrap().push(stage)), + ); + (launcher, events, stages) +} + +fn probe(installed: &str) -> (&'static str, Result) { + ( + "uname -sm", + Ok(format!( + "platform=Linux x86_64\ninstalled={installed}\nkill_user_processes=yes\nlinger=no\n" + )), + ) +} + +fn report(outcome: &str, app_version: &str, protocol: &str) -> Result { + Ok(json!({ + "outcome": outcome, + "keeper": "new", + "endpointUrl": "http://127.0.0.1:43123/rpc", + "authToken": "secret-token", + "appVersion": app_version, + "protocolVersion": protocol, + }) + .to_string()) +} + +fn no_fetch() -> Result, &'static str> { + panic!("the installed binary should have been used") +} + +fn ready(launched: Result, LaunchError>) -> Ready { + match launched { + Ok(Launched::Ready(ready)) => ready, + Ok(Launched::UpdateRequired { running_version }) => { + panic!("update required from {running_version}") + } + Err(error) => panic!("{error}"), + } +} + +#[test] +fn installs_a_missing_release_then_forwards_the_kept_app_server() { + let (launcher, events, stages) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(""), + ("openaide-app-server.upload", Ok("installed\n".into())), + ( + "keep \"$@\"' openaide '0.11.0' 'start'", + report("started", VERSION, "2.2"), + ), + ]); + let mut fetched = 0; + let mut fetch = || -> Result, &'static str> { + fetched += 1; + Ok(Box::new(&b"binary"[..])) + }; + + let ready = ready(launcher.launch(&mut ssh, StartMode::Keep, &mut fetch, "op-1")); + + assert_eq!(fetched, 1); + assert_eq!(ssh.uploaded.as_deref(), Some(&b"binary"[..])); + assert_eq!(ssh.forwarded, Some(43123)); + assert_eq!(ready.endpoint_url, format!("http://{LOCAL}/rpc")); + assert_eq!(ready.auth_token, "secret-token"); + assert!(!ready.update_available && !ready.keeper_existed); + assert!(ready.suggest_linger); + assert_eq!( + *stages.lock().unwrap(), + [ + Stage::Probe, + Stage::Fetch, + Stage::Upload, + Stage::Start, + Stage::Forward + ] + ); + let events = events.lock().unwrap(); + let (name, completed) = events.last().unwrap(); + assert_eq!(*name, "ssh_launch_completed"); + assert_eq!(completed["outcome"], "ready"); + assert_eq!(completed["operationId"], "op-1"); + assert!(!format!("{events:?}").contains("secret-token")); +} + +#[test] +fn an_installed_release_is_not_fetched_again() { + let (launcher, _, stages) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("keep", report("attached", VERSION, "2.2")), + ]); + + ready(launcher.launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1")); + + assert_eq!( + *stages.lock().unwrap(), + [Stage::Probe, Stage::Start, Stage::Forward] + ); +} + +#[test] +fn an_older_app_server_that_speaks_the_protocol_is_joined_and_offered_an_update() { + let (launcher, _, _) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start'", report("mismatch", "0.10.1", "2.2")), + ("'start' '--join'", report("joined", "0.10.1", "2.2")), + ]); + + let ready = ready(launcher.launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1")); + + assert!(ready.update_available); + assert_eq!(ready.app_version, "0.10.1"); +} + +#[test] +fn an_older_app_server_this_shell_cannot_use_needs_a_confirmed_update() { + let (launcher, _, _) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start'", report("mismatch", "0.10.0", "2.1")), + ]); + + let launched = launcher.launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1"); + + assert!(matches!( + launched, + Ok(Launched::UpdateRequired { ref running_version }) if running_version == "0.10.0" + )); + assert_eq!(ssh.forwarded, None); + + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start' '--replace'", report("started", VERSION, "2.2")), + ]); + ready(launcher.launch(&mut ssh, StartMode::Replace, &mut no_fetch, "op-2")); +} + +#[test] +fn a_newer_app_server_is_never_replaced() { + let (launcher, events, _) = launcher(); + let mut ssh = FakeSsh::new(vec![ + probe(SHA), + ("'start'", report("mismatch", "0.12.0", "2.3")), + ]); + + let error = launcher + .launch(&mut ssh, StartMode::Keep, &mut no_fetch, "op-1") + .err() + .unwrap(); + + assert_eq!( + error, + LaunchError::ServerNewer { + running_version: "0.12.0".into() + } + ); + let events = events.lock().unwrap(); + let (_, completed) = events.last().unwrap(); + assert_eq!(completed["stage"], "start"); + assert_eq!(completed["errorClass"], "server_newer"); +} + +#[test] +fn failures_are_classified_by_stage() { + let (launcher, _, _) = launcher(); + let unsupported = FakeSsh::new(vec![("uname", Ok("platform=Darwin arm64\n".into()))]); + let unreachable = FakeSsh::new(vec![( + "uname", + Err(TransportError { + class: "unreachable", + }), + )]); + let corrupted = FakeSsh::new(vec![ + probe(""), + ("upload", Ok("checksum_mismatch\n".into())), + ]); + let busy = FakeSsh::new(vec![ + probe(SHA), + ( + "keep", + Ok(r#"{"outcome":"failed","error":"keeper_busy"}"#.into()), + ), + ]); + let elsewhere = FakeSsh::new(vec![ + probe(SHA), + ( + "keep", + Ok( + r#"{"outcome":"started","endpointUrl":"http://10.0.0.5:1/","authToken":"t"}"# + .into(), + ), + ), + ]); + let mut fetch = || -> Result, &'static str> { Ok(Box::new(&b""[..])) }; + + let mut classes = Vec::new(); + for mut ssh in [unsupported, unreachable, corrupted, busy, elsewhere] { + let error = launcher + .launch(&mut ssh, StartMode::Keep, &mut fetch, "op") + .err() + .unwrap(); + classes.push((error.stage(), error.class().to_string())); + } + + assert_eq!( + classes, + [ + (Stage::Probe, "unsupported_platform".to_string()), + (Stage::Probe, "unreachable".to_string()), + (Stage::Upload, "checksum_mismatch".to_string()), + (Stage::Start, "keeper_busy".to_string()), + (Stage::Start, "unreadable_answer".to_string()), + ] + ); +} + +#[test] +fn stop_reports_what_the_keeper_did() { + let (launcher, events, _) = launcher(); + let mut ssh = FakeSsh::new(vec![("'stop'", Ok(r#"{"outcome":"stopped"}"#.into()))]); + + assert_eq!(launcher.stop(&mut ssh, "op-3"), Ok(StopOutcome::Stopped)); + let events = events.lock().unwrap(); + assert_eq!(events[0].0, "ssh_stop_completed"); + assert_eq!(events[0].1["outcome"], "stopped"); +} + +#[test] +fn a_release_must_be_safe_to_name_in_a_command() { + assert!(ServerRelease::new("0.11.0", SHA).is_some()); + assert!(ServerRelease::new("0.11.0 && true", SHA).is_none()); + assert!(ServerRelease::new("0.11.0", "abc").is_none()); +} From c2b36783e16f8ce5e765295deeedd9b500904980 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 09:11:31 +0200 Subject: [PATCH 06/21] feat(desktop): start the App Server on an SSH Computer The Desktop runs the shared launcher over the system `ssh`. It relays ssh's questions to the WebView through an askpass helper, multiplexes connections on macOS and Linux, and keeps a supervised loopback forward to the App Server. The Linux App Server for the matching release is downloaded once, checked against a SHA-256 pinned at build time, and cached; Windows reuses its bundled WSL copy. New commands list SSH hosts from the SSH configuration, add an SSH Computer, report its status and last failure, confirm an update, stop its keeper, and answer prompts. The launcher now also logs the start of each stage. --- .github/workflows/build-vsix.yml | 2 +- .github/workflows/package-desktop-target.yml | 8 +- apps/desktop/src-tauri/Cargo.lock | 3 + apps/desktop/src-tauri/Cargo.toml | 6 + apps/desktop/src-tauri/build.rs | 1 + apps/desktop/src-tauri/src/desktop_remote.rs | 14 +- .../src-tauri/src/desktop_remote_tests.rs | 1 + apps/desktop/src-tauri/src/desktop_runtime.rs | 4 + apps/desktop/src-tauri/src/desktop_ssh.rs | 474 ++++++++++++++++++ .../src-tauri/src/desktop_ssh_askpass.rs | 279 +++++++++++ .../src/desktop_ssh_askpass_tests.rs | 31 ++ .../src-tauri/src/desktop_ssh_config.rs | 114 +++++ .../src-tauri/src/desktop_ssh_config_tests.rs | 32 ++ .../src-tauri/src/desktop_ssh_release.rs | 208 ++++++++ .../src/desktop_ssh_release_tests.rs | 80 +++ .../src-tauri/src/desktop_ssh_tests.rs | 90 ++++ .../src-tauri/src/desktop_ssh_transport.rs | 411 +++++++++++++++ .../src/desktop_ssh_transport_tests.rs | 46 ++ apps/desktop/src-tauri/src/main.rs | 43 +- openaide-rs/remote-client/src/ssh.rs | 4 + openaide-rs/remote-client/src/ssh_tests.rs | 9 + scripts/resolve-release-artifact-graph.mjs | 3 +- scripts/test-workspace-gate.test.mjs | 12 + 23 files changed, 1865 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src-tauri/src/desktop_ssh.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_askpass.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_config.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_release.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_tests.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_transport.rs create mode 100644 apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs diff --git a/.github/workflows/build-vsix.yml b/.github/workflows/build-vsix.yml index bc110dba..55bda760 100644 --- a/.github/workflows/build-vsix.yml +++ b/.github/workflows/build-vsix.yml @@ -184,7 +184,7 @@ jobs: desktop-darwin-arm64: if: needs.prepare.outputs.desktop_darwin_arm64 == 'true' - needs: [prepare, server-darwin-arm64] + needs: [prepare, server-darwin-arm64, server-linux-x64-musl] uses: ./.github/workflows/package-desktop-target.yml with: target: darwin-arm64 diff --git a/.github/workflows/package-desktop-target.yml b/.github/workflows/package-desktop-target.yml index c3e93c1d..a2738ff5 100644 --- a/.github/workflows/package-desktop-target.yml +++ b/.github/workflows/package-desktop-target.yml @@ -80,11 +80,17 @@ jobs: with: name: app-server-${{ inputs.target }} path: app-server + # Windows bundles it for WSL; every Desktop pins its checksum for SSH Computers. - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 - if: inputs.target == 'win32-x64' with: name: app-server-linux-x64-musl path: wsl-app-server + - name: Pin the Linux App Server for SSH Computers + shell: bash + run: | + sha="$(openssl dgst -sha256 -r wsl-app-server/openaide-app-server | cut -d ' ' -f 1)" + test "${#sha}" -eq 64 + echo "OPENAIDE_SSH_APP_SERVER_SHA256=$sha" >> "$GITHUB_ENV" - name: Restore App Server executable permission if: runner.os != 'Windows' run: chmod +x "app-server/${{ steps.target.outputs.binary }}" diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d8f9976d..0ab16394 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3722,6 +3722,7 @@ dependencies = [ name = "openaide-desktop" version = "0.0.0" dependencies = [ + "flate2", "fs2", "futures-util", "keyring", @@ -3734,12 +3735,14 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "tar", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-updater", + "tempfile", "tokio", "url", "uuid", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ddc78830..2b77afcc 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,6 +23,9 @@ semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +# Unpacks the published Linux App Server for SSH Computers (ADR-0069). +flate2 = "1" +tar = "0.4" tauri = { version = "2.12.1", features = [] } tauri-plugin-dialog = "2.6.0" tauri-plugin-notification = "2.7.0" @@ -32,6 +35,9 @@ tokio = { version = "1", features = ["sync"] } url = "2" uuid = { version = "1", features = ["v4"] } +[dev-dependencies] +tempfile = "3" + [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] keyring = { version = "3.6.3", features = ["apple-native", "windows-native"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 0c172218..b4291a96 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -4,6 +4,7 @@ fn main() { "OPENAIDE_DESKTOP_UPDATE_RELEASE_PUBLIC_KEY", "OPENAIDE_DESKTOP_UPDATE_RECOVERY_PUBLIC_KEY", "OPENAIDE_DESKTOP_UPDATE_ENABLED_BUILD", + "OPENAIDE_SSH_APP_SERVER_SHA256", ] { println!("cargo:rerun-if-env-changed={variable}"); } diff --git a/apps/desktop/src-tauri/src/desktop_remote.rs b/apps/desktop/src-tauri/src/desktop_remote.rs index 171c667c..97488a4f 100644 --- a/apps/desktop/src-tauri/src/desktop_remote.rs +++ b/apps/desktop/src-tauri/src/desktop_remote.rs @@ -18,12 +18,16 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use crate::desktop_runtime::{DesktopBootstrapPreferences, write_bootstrap_preferences}; use crate::{DesktopState, LocalHttpConnection}; -/// A computer whose App Server trusts this Desktop: its public key and the name -/// it gave itself. Neither is a credential; the device key is stored apart. +/// A computer whose App Server this Desktop may use instead of its own. A +/// paired computer is known by its public key and the name it gave itself; an +/// SSH Computer (ADR-0069) by its `ssh` destination. None is a credential; the +/// device key is stored apart. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct RemoteComputer { pub(crate) id: String, pub(crate) name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) ssh: Option, } impl DesktopBootstrapPreferences { @@ -60,6 +64,8 @@ pub(crate) struct RemoteComputerSummary { id: String, name: String, current: bool, + #[serde(skip_serializing_if = "Option::is_none")] + ssh: Option, } /// This Desktop's endpoint, bound on first use and kept for the process: a @@ -144,6 +150,7 @@ pub(crate) fn desktop_remote_snapshot( id: computer.id.clone(), name: computer.name.clone(), current: preferences.remote_computer.as_ref() == Some(&computer.id), + ssh: computer.ssh.clone(), }) .collect()) } @@ -193,6 +200,7 @@ pub(crate) async fn desktop_remote_pair( } else { paired.name }, + ssh: None, }); write_bootstrap_preferences(&state.preferences_path, &preferences)?; drop(preferences); @@ -321,7 +329,7 @@ fn message(failure: remote::Failure, name: &str) -> String { } /// Prints the client's metadata-only events in this shell's log format. -fn logger() -> remote::Logger { +pub(crate) fn logger() -> remote::Logger { Arc::new(|event, fields| { let fields = fields .as_object() diff --git a/apps/desktop/src-tauri/src/desktop_remote_tests.rs b/apps/desktop/src-tauri/src/desktop_remote_tests.rs index 55433874..fc21b710 100644 --- a/apps/desktop/src-tauri/src/desktop_remote_tests.rs +++ b/apps/desktop/src-tauri/src/desktop_remote_tests.rs @@ -4,6 +4,7 @@ fn computer(id: &str, name: &str) -> RemoteComputer { RemoteComputer { id: id.to_string(), name: name.to_string(), + ssh: None, } } diff --git a/apps/desktop/src-tauri/src/desktop_runtime.rs b/apps/desktop/src-tauri/src/desktop_runtime.rs index 0c38a6fc..48fb3026 100644 --- a/apps/desktop/src-tauri/src/desktop_runtime.rs +++ b/apps/desktop/src-tauri/src/desktop_runtime.rs @@ -102,6 +102,10 @@ pub(crate) struct DesktopBootstrapPreferences { /// The paired computer in use instead of this one, by id. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) remote_computer: Option, + /// The SSH Computer whose older App Server the user agreed to replace on + /// the next start (ADR-0069). Cleared by that start. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) ssh_replace: Option, } pub(crate) fn read_bootstrap_preferences(path: &Path) -> DesktopBootstrapPreferences { diff --git a/apps/desktop/src-tauri/src/desktop_ssh.rs b/apps/desktop/src-tauri/src/desktop_ssh.rs new file mode 100644 index 00000000..285758ac --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh.rs @@ -0,0 +1,474 @@ +//! SSH Computers in the Desktop (ADR-0069). +//! +//! An SSH Computer is one more choice of where agents work, kept beside paired +//! computers and switched through the same restart. Starting with one installs +//! this Desktop's App Server there through the system `ssh`, has its keeper hold +//! it, and forwards its endpoint to loopback; the WebView then talks to it like +//! to this computer's own App Server. This module owns the forward for the +//! process and what the WebView may ask about it: the last outcome, prompts, +//! updates, and stopping. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use openaide_remote_client::{LaunchError, Launched, SshLauncher, Stage, StartMode, StopOutcome}; +use serde::Serialize; +use tauri::{Emitter, Manager}; + +use crate::desktop_remote::RemoteComputer; +use crate::desktop_runtime::write_bootstrap_preferences; +use crate::desktop_ssh_askpass::{AskpassBroker, SshPrompt}; +use crate::desktop_ssh_release::ReleaseSource; +use crate::desktop_ssh_transport::{SshForward, SystemSsh, is_destination}; +use crate::{DesktopState, LocalHttpConnection}; + +pub(crate) struct DesktopSshState { + askpass: Mutex>>, + forward: Mutex>, + status: Mutex>, + failure: Mutex>, + cache_root: PathBuf, +} + +/// What the App Shell knows about the SSH Computer it uses. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SshStatus { + computer_id: String, + running_version: String, + /// The App Server there is older but compatible; an update may be offered. + update_available: bool, + /// Logind would end the App Server when the user's last session closes. + suggest_linger: bool, +} + +/// Why the last start did not reach the SSH Computer's App Server. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SshFailure { + computer_id: String, + /// `updateRequired` when only a confirmed update can help. + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + running_version: Option, + stage: &'static str, + error_class: String, +} + +impl DesktopSshState { + pub(crate) fn new(cache_root: PathBuf) -> Self { + Self { + askpass: Mutex::new(None), + forward: Mutex::new(None), + status: Mutex::new(None), + failure: Mutex::new(None), + cache_root, + } + } + + fn askpass(&self, app: &tauri::AppHandle) -> Result, String> { + let mut broker = self + .askpass + .lock() + .map_err(|_| "SSH state is unavailable.".to_string())?; + if let Some(broker) = broker.as_ref() { + return Ok(broker.clone()); + } + let started = AskpassBroker::start(app.clone()) + .map_err(|_| "OpenAIDE could not prepare its SSH sign-in prompts.".to_string())?; + *broker = Some(started.clone()); + Ok(started) + } +} + +/// Starts or attaches to the App Server on `computer` and forwards it. The +/// startup screen shows each stage when `show_progress` is set. +pub(crate) async fn connect( + app: &tauri::AppHandle, + computer: &RemoteComputer, + destination: &str, + show_progress: bool, +) -> Result { + let ssh_state = app.state::(); + let mode = take_confirmed_update(app, &computer.id)?; + let broker = ssh_state.askpass(app)?; + let source = release_source(app, &ssh_state)?; + let operation_id = format!("desktop-ssh-connect-{}", uuid::Uuid::new_v4()); + let name = computer.name.clone(); + let progress_app = app.clone(); + let launcher = SshLauncher::new( + source.release.clone(), + crate::desktop_remote::logger(), + Box::new(move |stage| { + if show_progress { + crate::emit_bootstrap_progress(&progress_app, "ssh", stage_message(stage, &name)); + } + }), + ); + let environment = broker.environment(&computer.name); + let destination = destination.to_string(); + let started = Instant::now(); + desktop_log!("desktop_ssh_connect_started operation_id={operation_id}"); + let launched = tauri::async_runtime::spawn_blocking(move || { + let mut transport = SystemSsh::new(&destination, environment); + let mut fetch = || source.open(); + launcher.launch(&mut transport, mode, &mut fetch, &operation_id) + }) + .await + .map_err(|_| "The SSH connection task failed.".to_string())?; + desktop_log!( + "desktop_ssh_connect_completed outcome={} duration_ms={}", + match &launched { + Ok(Launched::Ready(_)) => "ready", + Ok(Launched::UpdateRequired { .. }) => "update_required", + Err(_) => "failure", + }, + started.elapsed().as_millis() + ); + let failure = |kind, running_version, stage: Stage, error_class: String| SshFailure { + computer_id: computer.id.clone(), + kind, + running_version, + stage: stage.as_str(), + error_class, + }; + match launched { + Ok(Launched::Ready(ready)) => { + set(&ssh_state.failure, None); + set( + &ssh_state.status, + Some(SshStatus { + computer_id: computer.id.clone(), + running_version: ready.app_version.clone(), + update_available: ready.update_available, + suggest_linger: ready.suggest_linger, + }), + ); + // Replacing the forward ends the previous `ssh`. + set(&ssh_state.forward, Some(ready.forward)); + Ok(LocalHttpConnection { + kind: "localHttp".to_string(), + endpoint_url: ready.endpoint_url, + auth_token: ready.auth_token, + }) + } + Ok(Launched::UpdateRequired { running_version }) => { + set( + &ssh_state.failure, + Some(failure( + "updateRequired", + Some(running_version.clone()), + Stage::Start, + "update_required".to_string(), + )), + ); + Err(format!( + "{} runs an older OpenAIDE ({running_version}) that this version cannot use. Update it to continue.", + computer.name + )) + } + Err(error) => { + let running_version = match &error { + LaunchError::ServerNewer { running_version } => Some(running_version.clone()), + _ => None, + }; + set( + &ssh_state.failure, + Some(failure( + "failed", + running_version, + error.stage(), + error.class().to_string(), + )), + ); + Err(message(&error, &computer.name)) + } + } +} + +/// Brings the SSH Computer's App Server back after sleep or after its keeper +/// let it go. `None` when no SSH Computer is in use. +pub(crate) async fn ensure( + app: &tauri::AppHandle, +) -> Option, String>> { + let state = app.state::(); + let computer = state.preferences.lock().ok()?.remote().cloned()?; + let destination = computer.ssh.clone()?; + Some( + async { + let cached = state + .connection + .lock() + .map_err(|_| "desktop connection state unavailable")? + .clone(); + if let Some(connection) = &cached + && crate::app_server_endpoint_reachable(connection).await + { + return Ok(None); + } + desktop_log!("desktop_ssh_ensure_started reason_code=endpoint_unreachable"); + let connection = connect(app, &computer, &destination, false).await?; + *state + .connection + .lock() + .map_err(|_| "desktop connection state unavailable")? = Some(connection.clone()); + let _ = app.emit("desktop-app-server-replaced", &connection); + Ok(Some(connection)) + } + .await, + ) +} + +/// An update the user confirmed applies to the next start with that computer only. +fn take_confirmed_update(app: &tauri::AppHandle, id: &str) -> Result { + let state = app.state::(); + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + if preferences.ssh_replace.as_deref() != Some(id) { + return Ok(StartMode::Keep); + } + preferences.ssh_replace = None; + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + desktop_log!("desktop_ssh_update_confirmed_applied"); + Ok(StartMode::Replace) +} + +fn release_source( + app: &tauri::AppHandle, + ssh_state: &DesktopSshState, +) -> Result { + let version = app.package_info().version.to_string(); + let wsl_binary = app.state::().wsl_resource_binary.clone(); + crate::desktop_ssh_release::resolve(&version, &wsl_binary, &ssh_state.cache_root).map_err( + |class| { + desktop_log!("desktop_ssh_release_unavailable error_class={class}"); + "This build of OpenAIDE cannot install its App Server on SSH computers.".to_string() + }, + ) +} + +#[tauri::command] +pub(crate) fn desktop_ssh_hosts(app: tauri::AppHandle) -> Vec { + app.path() + .home_dir() + .map(|home| crate::desktop_ssh_config::host_aliases(&home.join(".ssh"))) + .unwrap_or_default() +} + +/// Remembers an SSH Computer by its `ssh` destination and restarts into it. +#[tauri::command] +pub(crate) fn desktop_ssh_add( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, + destination: String, + name: Option, +) -> Result<(), String> { + let destination = destination.trim().to_string(); + if !is_destination(&destination) { + return Err("Enter an SSH host alias or user@host.".to_string()); + } + let name = name + .map(|name| openaide_remote_client::clamp_label(&name)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| openaide_remote_client::clamp_label(&destination)); + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + let existing = preferences + .remote_computers + .iter() + .find(|computer| computer.ssh.as_deref() == Some(destination.as_str())) + .map(|computer| computer.id.clone()); + let id = existing.unwrap_or_else(|| format!("ssh-{}", uuid::Uuid::new_v4().simple())); + preferences.remote_computers.retain(|known| known.id != id); + preferences.remote_computers.push(RemoteComputer { + id: id.clone(), + name, + ssh: Some(destination), + }); + preferences.remote_computer = Some(id); + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + drop(preferences); + desktop_log!("desktop_remote_computer_selected reason=ssh_added"); + crate::restart_desktop(&app); + Ok(()) +} + +#[tauri::command] +pub(crate) fn desktop_ssh_status(state: tauri::State<'_, DesktopSshState>) -> Option { + state.status.lock().ok()?.clone() +} + +#[tauri::command] +pub(crate) fn desktop_ssh_failure(state: tauri::State<'_, DesktopSshState>) -> Option { + state.failure.lock().ok()?.clone() +} + +/// Replaces the App Server on the SSH Computer in use on the next start. The +/// WebView asks only after showing what the replacement interrupts. +#[tauri::command] +pub(crate) fn desktop_ssh_update( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result<(), String> { + let mut preferences = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")?; + let id = preferences + .remote() + .filter(|computer| computer.ssh.is_some()) + .map(|computer| computer.id.clone()) + .ok_or_else(|| "No SSH computer is in use.".to_string())?; + preferences.ssh_replace = Some(id); + write_bootstrap_preferences(&state.preferences_path, &preferences)?; + drop(preferences); + desktop_log!("desktop_ssh_update_confirmed"); + crate::restart_desktop(&app); + Ok(()) +} + +/// Ends the keeper on an SSH Computer. Its App Server then stops once no App +/// Shell uses it. +#[tauri::command] +pub(crate) async fn desktop_ssh_stop( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopState>, + ssh_state: tauri::State<'_, DesktopSshState>, + id: String, +) -> Result<&'static str, String> { + let computer = state + .preferences + .lock() + .map_err(|_| "desktop bootstrap preferences unavailable")? + .remote_computers + .iter() + .find(|computer| computer.id == id) + .cloned() + .ok_or_else(|| "This computer is no longer saved.".to_string())?; + let destination = computer + .ssh + .clone() + .ok_or_else(|| "This computer is not an SSH computer.".to_string())?; + let environment = ssh_state.askpass(&app)?.environment(&computer.name); + let source = release_source(&app, &ssh_state)?; + let launcher = SshLauncher::new( + source.release, + crate::desktop_remote::logger(), + Box::new(|_| {}), + ); + let operation_id = format!("desktop-ssh-stop-{}", uuid::Uuid::new_v4()); + let stopped = tauri::async_runtime::spawn_blocking(move || { + launcher.stop( + &mut SystemSsh::new(&destination, environment), + &operation_id, + ) + }) + .await + .map_err(|_| "The SSH connection task failed.".to_string())?; + match stopped { + Ok(StopOutcome::Stopped) => Ok("stopped"), + Ok(StopOutcome::NotRunning) => Ok("notRunning"), + Ok(StopOutcome::StillRunning) => Ok("stillRunning"), + Err(error) => Err(message(&error, &computer.name)), + } +} + +#[tauri::command] +pub(crate) fn desktop_ssh_prompts( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopSshState>, +) -> Vec { + state + .askpass(&app) + .map(|broker| broker.pending()) + .unwrap_or_default() +} + +#[tauri::command] +pub(crate) fn desktop_ssh_prompt_answer( + app: tauri::AppHandle, + state: tauri::State<'_, DesktopSshState>, + id: u64, + answer: Option, +) -> bool { + state + .askpass(&app) + .map(|broker| broker.answer(id, answer)) + .unwrap_or(false) +} + +fn set(slot: &Mutex>, value: Option) { + if let Ok(mut slot) = slot.lock() { + *slot = value; + } +} + +fn stage_message(stage: Stage, name: &str) -> String { + match stage { + Stage::Probe => format!("Connecting to {name}"), + Stage::Fetch => "Downloading the App Server".to_string(), + Stage::Upload => format!("Installing the App Server on {name}"), + Stage::Start => format!("Starting the App Server on {name}"), + Stage::Forward => format!("Opening the connection to {name}"), + } +} + +fn message(error: &LaunchError, name: &str) -> String { + match error { + LaunchError::UnsupportedPlatform => { + format!( + "{name} is not a Linux x64 computer. OpenAIDE runs on SSH computers with Linux x64 only." + ) + } + LaunchError::Fetch(_) => { + "OpenAIDE could not download its App Server. Check this computer's internet connection." + .to_string() + } + LaunchError::Install("checksum_tool_missing") => { + format!("{name} has no sha256sum, which OpenAIDE needs to verify its install.") + } + LaunchError::Install(_) => format!( + "OpenAIDE could not install its App Server on {name}. Check the free space in your home directory there." + ), + LaunchError::ServerNewer { running_version } => format!( + "{name} runs a newer OpenAIDE ({running_version}). Update OpenAIDE on this computer first." + ), + LaunchError::ServerVersionUnknown => format!( + "{name} runs an OpenAIDE whose version is unknown. Stop it there, then try again." + ), + LaunchError::UnreadableAnswer(_) => format!( + "{name} gave an unexpected answer. Its login shell may print text in non-interactive sessions." + ), + LaunchError::Keeper(class) => { + format!("OpenAIDE could not start on {name} ({class}).") + } + LaunchError::Transport { error, .. } => match error.class { + "ssh_missing" => { + "OpenAIDE needs the ssh command. Install OpenSSH, then try again.".to_string() + } + "auth_failed" => { + format!("{name} did not accept the sign-in. Check your SSH key or password.") + } + "host_key_rejected" => format!( + "{name}'s host key does not match the one this computer knows. Check known_hosts before connecting." + ), + "host_unknown" => format!("OpenAIDE could not find {name}. Check the host name."), + "ssh_config_invalid" => { + "Your SSH configuration has an error. Run ssh in a terminal to see it.".to_string() + } + "forward_failed" | "forward_timeout" | "local_port_unavailable" => format!( + "OpenAIDE could not open its connection to {name}. A LocalForward in your SSH configuration may be in the way." + ), + _ => format!("{name} is unreachable over SSH."), + }, + } +} + +#[cfg(test)] +#[path = "desktop_ssh_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_askpass.rs b/apps/desktop/src-tauri/src/desktop_ssh_askpass.rs new file mode 100644 index 00000000..46d83e09 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_askpass.rs @@ -0,0 +1,279 @@ +//! Shows the system `ssh`'s questions in the Desktop (ADR-0069): passwords, +//! passphrases, one-time codes, and host key confirmations. +//! +//! Each `ssh` this shell starts names the Desktop's own executable as its +//! `SSH_ASKPASS` program. Started that way, the executable does not open a +//! window: it carries the one prompt over loopback to the running Desktop, +//! which asks the user and returns the answer. The listener accepts only +//! callers holding the token it handed to its `ssh` processes. Answers pass +//! through memory once and are never stored or logged. + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +const ADDRESS_ENV: &str = "OPENAIDE_SSH_ASKPASS_ADDRESS"; +const TOKEN_ENV: &str = "OPENAIDE_SSH_ASKPASS_TOKEN"; +const LABEL_ENV: &str = "OPENAIDE_SSH_ASKPASS_LABEL"; +pub(crate) const PROMPT_EVENT: &str = "desktop-ssh-prompt"; +/// `ssh` gives up on its own connection timeout well before a person answers, +/// so this only bounds a prompt nobody is looking at. +const ANSWER_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_MESSAGE_BYTES: u64 = 16 * 1024; + +/// What the helper sends: its token, `ssh`'s prompt, and `SSH_ASKPASS_PROMPT`. +#[derive(Deserialize, Serialize)] +struct Request { + token: String, + prompt: String, + hint: Option, + label: Option, +} + +#[derive(Deserialize, Serialize)] +struct Reply { + answer: Option, +} + +/// One question as the WebView shows it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SshPrompt { + id: u64, + /// The SSH Computer's name in this Desktop. + computer: String, + /// `ssh`'s own wording, such as a password request or a host key fingerprint. + text: String, + kind: PromptKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum PromptKind { + /// Typed and hidden: a password, passphrase, PIN, or code. + Secret, + /// Answered `yes` or `no`, such as trusting a new host key. + YesNo, + /// Only shown, such as a request to touch a security key. + Notice, +} + +impl PromptKind { + fn of(text: &str, hint: Option<&str>) -> Self { + match hint { + Some("none") => Self::Notice, + Some("confirm") => Self::YesNo, + _ if text.contains("(yes/no") => Self::YesNo, + _ => Self::Secret, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Secret => "secret", + Self::YesNo => "yes_no", + Self::Notice => "notice", + } + } +} + +/// A prompt shown in the App Shell and where its answer goes. +type PendingPrompt = (SshPrompt, mpsc::Sender>); + +/// The running Desktop's half: one loopback listener for the whole process. +pub(crate) struct AskpassBroker { + address: SocketAddr, + token: String, + next_id: AtomicU64, + pending: Mutex>, +} + +impl AskpassBroker { + pub(crate) fn start(app: tauri::AppHandle) -> std::io::Result> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let broker = Arc::new(Self { + address: listener.local_addr()?, + token: format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ), + next_id: AtomicU64::new(1), + pending: Mutex::new(HashMap::new()), + }); + let serving = broker.clone(); + std::thread::Builder::new() + .name("ssh-askpass".to_string()) + .spawn(move || { + for stream in listener.incoming().flatten() { + let broker = serving.clone(); + let app = app.clone(); + let _ = std::thread::Builder::new() + .name("ssh-askpass-prompt".to_string()) + .spawn(move || broker.serve(&app, stream)); + } + })?; + desktop_log!("desktop_ssh_askpass_started"); + Ok(broker) + } + + /// The environment that makes an `ssh` ask this Desktop. + pub(crate) fn environment(&self, computer: &str) -> Vec<(&'static str, String)> { + let program = std::env::current_exe() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut environment = vec![ + ("SSH_ASKPASS", program), + // OpenSSH 8.4 and later use askpass even with a terminal attached. + ("SSH_ASKPASS_REQUIRE", "force".to_string()), + (ADDRESS_ENV, self.address.to_string()), + (TOKEN_ENV, self.token.clone()), + (LABEL_ENV, computer.to_string()), + ]; + // Older OpenSSH, including Windows 10's, uses askpass only with a display. + if std::env::var_os("DISPLAY").is_none() { + environment.push(("DISPLAY", "openaide:0".to_string())); + } + environment + } + + pub(crate) fn pending(&self) -> Vec { + let mut prompts: Vec = self + .pending + .lock() + .map(|pending| pending.values().map(|(prompt, _)| prompt.clone()).collect()) + .unwrap_or_default(); + prompts.sort_by_key(|prompt| prompt.id); + prompts + } + + /// Delivers the user's answer; `None` declines. + pub(crate) fn answer(&self, id: u64, answer: Option) -> bool { + let entry = self + .pending + .lock() + .ok() + .and_then(|mut pending| pending.remove(&id)); + match entry { + Some((_, reply)) => reply.send(answer).is_ok(), + None => false, + } + } + + fn serve(&self, app: &tauri::AppHandle, stream: TcpStream) { + let started = Instant::now(); + let Some(request) = read_message::(&stream) else { + desktop_log!("desktop_ssh_prompt_refused reason=unreadable"); + return; + }; + if !same_token(&request.token, &self.token) { + desktop_log!("desktop_ssh_prompt_refused reason=token"); + return; + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let kind = PromptKind::of(&request.prompt, request.hint.as_deref()); + let prompt = SshPrompt { + id, + computer: request.label.unwrap_or_default(), + text: request.prompt, + kind, + }; + desktop_log!( + "desktop_ssh_prompt_started prompt_id={id} kind={}", + kind.as_str() + ); + let (sender, receiver) = mpsc::channel(); + if kind != PromptKind::Notice + && let Ok(mut pending) = self.pending.lock() + { + pending.insert(id, (prompt.clone(), sender)); + } + let _ = app.emit(PROMPT_EVENT, &prompt); + // A notice needs no answer: `ssh` ends the helper when it is done. + let (answer, outcome) = if kind == PromptKind::Notice { + (None, "shown") + } else { + match receiver.recv_timeout(ANSWER_TIMEOUT) { + Ok(Some(answer)) => (Some(answer), "answered"), + Ok(None) => (None, "declined"), + Err(_) => { + if let Ok(mut pending) = self.pending.lock() { + pending.remove(&id); + } + (None, "timed_out") + } + } + }; + let _ = write_message(&stream, &Reply { answer }); + desktop_log!( + "desktop_ssh_prompt_completed prompt_id={id} outcome={outcome} duration_ms={}", + started.elapsed().as_millis() + ); + } +} + +/// Runs the helper when `ssh` started this executable as its askpass program. +/// Returns the exit code, or `None` for a normal Desktop start. +pub(crate) fn run_helper_if_requested() -> Option { + let address = std::env::var(ADDRESS_ENV).ok()?; + let token = std::env::var(TOKEN_ENV).unwrap_or_default(); + let prompt = std::env::args().nth(1).unwrap_or_default(); + let request = Request { + token, + prompt, + hint: std::env::var("SSH_ASKPASS_PROMPT").ok(), + label: std::env::var(LABEL_ENV).ok(), + }; + let answer = address + .parse::() + .ok() + .and_then(|address| TcpStream::connect_timeout(&address, Duration::from_secs(5)).ok()) + .and_then(|stream| { + write_message(&stream, &request).ok()?; + read_message::(&stream) + }) + .and_then(|reply| reply.answer); + match answer { + Some(answer) => { + let mut stdout = std::io::stdout(); + let _ = writeln!(stdout, "{answer}"); + let _ = stdout.flush(); + Some(0) + } + None => Some(1), + } +} + +fn read_message Deserialize<'de>>(stream: &TcpStream) -> Option { + let mut line = String::new(); + BufReader::new(stream.take(MAX_MESSAGE_BYTES)) + .read_line(&mut line) + .ok()?; + serde_json::from_str(&line).ok() +} + +fn write_message(mut stream: &TcpStream, message: &T) -> std::io::Result<()> { + let mut bytes = serde_json::to_vec(message).map_err(std::io::Error::other)?; + bytes.push(b'\n'); + stream.write_all(&bytes) +} + +fn same_token(given: &str, expected: &str) -> bool { + given.len() == expected.len() + && given + .bytes() + .zip(expected.bytes()) + .fold(0u8, |difference, (left, right)| difference | (left ^ right)) + == 0 +} + +#[cfg(test)] +#[path = "desktop_ssh_askpass_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs new file mode 100644 index 00000000..c00009cb --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_askpass_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn prompts_are_classified_by_hint_and_wording() { + assert_eq!( + PromptKind::of("someone@host's password: ", None), + PromptKind::Secret + ); + assert_eq!( + PromptKind::of( + "Are you sure you want to continue connecting (yes/no/[fingerprint])? ", + None + ), + PromptKind::YesNo + ); + assert_eq!( + PromptKind::of("Allow use of key?", Some("confirm")), + PromptKind::YesNo + ); + assert_eq!( + PromptKind::of("Confirm user presence for key", Some("none")), + PromptKind::Notice + ); +} + +#[test] +fn only_the_handed_out_token_is_accepted() { + assert!(same_token("abc123", "abc123")); + assert!(!same_token("abc124", "abc123")); + assert!(!same_token("abc12", "abc123")); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_config.rs b/apps/desktop/src-tauri/src/desktop_ssh_config.rs new file mode 100644 index 00000000..3d24f81f --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_config.rs @@ -0,0 +1,114 @@ +//! Host aliases from the user's `~/.ssh/config`, offered when adding an SSH +//! Computer. Only names are read; `ssh` itself applies the configuration. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// Bounds `Include` chains, which may refer to each other. +const MAX_INCLUDE_DEPTH: usize = 8; + +pub(crate) fn host_aliases(ssh_directory: &Path) -> Vec { + let mut aliases = BTreeSet::new(); + collect( + &ssh_directory.join("config"), + ssh_directory, + 0, + &mut aliases, + ); + aliases.into_iter().collect() +} + +fn collect(path: &Path, ssh_directory: &Path, depth: usize, aliases: &mut BTreeSet) { + if depth > MAX_INCLUDE_DEPTH { + return; + } + let Ok(text) = std::fs::read_to_string(path) else { + return; + }; + for line in text.lines() { + let Some((keyword, values)) = split_directive(line) else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + aliases.extend( + values + .split_whitespace() + .map(|alias| alias.trim_matches('"')) + .filter(|alias| is_concrete(alias)) + .map(str::to_string), + ); + } else if keyword.eq_ignore_ascii_case("include") { + for pattern in values.split_whitespace() { + for included in expand_include(pattern.trim_matches('"'), ssh_directory) { + collect(&included, ssh_directory, depth + 1, aliases); + } + } + } + } +} + +/// `Keyword value…` or `Keyword=value…`, without comments. +fn split_directive(line: &str) -> Option<(&str, &str)> { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let split = line.find(|character: char| character.is_whitespace() || character == '=')?; + let (keyword, rest) = line.split_at(split); + let rest = + rest.trim_start_matches(|character: char| character.is_whitespace() || character == '='); + Some((keyword, rest.split('#').next().unwrap_or_default().trim())) +} + +/// Patterns and negations match many hosts and name none. +fn is_concrete(alias: &str) -> bool { + !alias.is_empty() && !alias.contains(['*', '?', '!', '%']) +} + +/// A relative `Include` is under `~/.ssh`. Only the file name may hold `*`, +/// which covers the common `config.d/*` layouts. +fn expand_include(pattern: &str, ssh_directory: &Path) -> Vec { + let path = if let Some(rest) = pattern.strip_prefix("~/") { + match ssh_directory.parent() { + Some(home) => home.join(rest), + None => return Vec::new(), + } + } else if Path::new(pattern).is_absolute() { + PathBuf::from(pattern) + } else { + ssh_directory.join(pattern) + }; + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + if !name.contains('*') { + return vec![path]; + } + let (prefix, suffix) = name.split_once('*').unwrap_or((&name, "")); + let Some(directory) = path.parent() else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut matches: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|candidate| { + candidate.is_file() + && candidate.file_name().is_some_and(|file| { + let file = file.to_string_lossy(); + file.len() >= prefix.len() + suffix.len() + && file.starts_with(prefix) + && file.ends_with(suffix) + }) + }) + .collect(); + matches.sort(); + matches +} + +#[cfg(test)] +#[path = "desktop_ssh_config_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs new file mode 100644 index 00000000..b95f9fa2 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_config_tests.rs @@ -0,0 +1,32 @@ +use super::*; + +#[test] +fn lists_concrete_aliases_including_included_files() { + let home = tempfile::tempdir().unwrap(); + let ssh = home.path().join(".ssh"); + std::fs::create_dir_all(ssh.join("config.d")).unwrap(); + std::fs::write( + ssh.join("config"), + "# personal\nInclude config.d/*.conf\nHost devbox build-1 *.internal !bad\n HostName 10.0.0.2\nHost=\"quoted\" # note\nMatch host x\n", + ) + .unwrap(); + std::fs::write(ssh.join("config.d/vm.conf"), "Host vm\n").unwrap(); + std::fs::write(ssh.join("config.d/ignored.txt"), "Host ignored\n").unwrap(); + + assert_eq!(host_aliases(&ssh), ["build-1", "devbox", "quoted", "vm"]); +} + +#[test] +fn includes_that_loop_or_are_missing_end_quietly() { + let home = tempfile::tempdir().unwrap(); + let ssh = home.path().join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + "Include config\nInclude missing\nHost a\n", + ) + .unwrap(); + + assert_eq!(host_aliases(&ssh), ["a"]); + assert!(host_aliases(&home.path().join("absent")).is_empty()); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_release.rs b/apps/desktop/src-tauri/src/desktop_ssh_release.rs new file mode 100644 index 00000000..67400d2a --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_release.rs @@ -0,0 +1,208 @@ +//! The Linux x64 App Server this Desktop installs on SSH Computers (ADR-0069). +//! +//! It must be exactly this Desktop's release. The Windows Desktop already +//! bundles that binary for WSL. The macOS Desktop downloads the published Linux +//! archive once, keeps the binary in its cache, and trusts it only when its +//! SHA-256 matches the one the release build compiled in. + +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use openaide_remote_client::ServerRelease; +use sha2::{Digest, Sha256}; + +/// Set by the release build from the Linux binary it publishes. +const BUILT_IN_SHA256: Option<&str> = option_env!("OPENAIDE_SSH_APP_SERVER_SHA256"); +const RELEASES: &str = "https://github.com/OldKrab/OpenAIDE/releases/download"; +const ARCHIVE_ENTRY: &str = "openaide-app-server/openaide-app-server"; +const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_BINARY_BYTES: u64 = 512 * 1024 * 1024; +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600); + +pub(crate) struct ReleaseSource { + pub(crate) release: ServerRelease, + origin: Origin, +} + +enum Origin { + /// A binary already on this computer whose checksum was just computed. + File(PathBuf), + Download { + url: String, + cache: PathBuf, + sha256: String, + }, +} + +/// Finds this Desktop's release binary. Errors are stable classes. +pub(crate) fn resolve( + app_version: &str, + wsl_resource_binary: &Path, + cache_root: &Path, +) -> Result { + if let Some(source) = development_override(app_version)? { + return Ok(source); + } + if cfg!(target_os = "windows") && wsl_resource_binary.is_file() { + return local(app_version, wsl_resource_binary.to_path_buf()); + } + let sha256 = BUILT_IN_SHA256.ok_or("unavailable_in_build")?; + let release = ServerRelease::new(app_version, sha256).ok_or("unavailable_in_build")?; + Ok(ReleaseSource { + release, + origin: Origin::Download { + url: format!( + "{RELEASES}/v{app_version}/openaide-app-server-linux-x64-{app_version}.tar.gz" + ), + cache: cache_root + .join("ssh-app-server") + .join(app_version) + .join("openaide-app-server"), + sha256: sha256.to_string(), + }, + }) +} + +/// A development Desktop has no published release; it may name a Linux build +/// of the App Server and the version it reports. +fn development_override(app_version: &str) -> Result, &'static str> { + if !cfg!(debug_assertions) { + return Ok(None); + } + let Some(path) = std::env::var_os("OPENAIDE_DESKTOP_SSH_APP_SERVER") else { + return Ok(None); + }; + let version = std::env::var("OPENAIDE_DESKTOP_SSH_APP_SERVER_VERSION") + .unwrap_or_else(|_| app_version.to_string()); + local(&version, PathBuf::from(path)).map(Some) +} + +fn local(version: &str, path: PathBuf) -> Result { + let sha256 = sha256_file(&path).map_err(|_| "release_unreadable")?; + Ok(ReleaseSource { + release: ServerRelease::new(version, &sha256).ok_or("unavailable_in_build")?, + origin: Origin::File(path), + }) +} + +impl ReleaseSource { + /// Opens the binary, downloading it first if the cache lacks it. Blocks. + pub(crate) fn open(&self) -> Result, &'static str> { + match &self.origin { + Origin::File(path) => File::open(path) + .map(|file| Box::new(file) as Box) + .map_err(|_| "release_unreadable"), + Origin::Download { url, cache, sha256 } => { + if sha256_file(cache).ok().as_deref() != Some(sha256.as_str()) { + download(url, cache, sha256)?; + } + File::open(cache) + .map(|file| Box::new(file) as Box) + .map_err(|_| "release_unreadable") + } + } + } +} + +fn download(url: &str, cache: &Path, sha256: &str) -> Result<(), &'static str> { + let started = Instant::now(); + desktop_log!("desktop_ssh_release_download_started attempt_count=1"); + let result = tauri::async_runtime::block_on(fetch_archive(url)) + .and_then(|archive| extract_verified(&archive, cache, sha256)); + desktop_log!( + "desktop_ssh_release_download_completed outcome={} error_class={} duration_ms={} attempt_count=1", + if result.is_ok() { "success" } else { "failure" }, + result.err().unwrap_or("none"), + started.elapsed().as_millis() + ); + result +} + +async fn fetch_archive(url: &str) -> Result, &'static str> { + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(DOWNLOAD_TIMEOUT) + .build() + .map_err(|_| "download_failed")?; + let mut response = client + .get(url) + .send() + .await + .map_err(|_| "download_failed")?; + if !response.status().is_success() { + return Err(if response.status() == reqwest::StatusCode::NOT_FOUND { + "release_not_published" + } else { + "download_failed" + }); + } + let mut archive = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| "download_failed")? { + if archive.len() as u64 + chunk.len() as u64 > MAX_ARCHIVE_BYTES { + return Err("download_too_large"); + } + archive.extend_from_slice(&chunk); + } + Ok(archive) +} + +/// Unpacks the one binary into the cache and keeps it only if it is the release. +fn extract_verified(archive: &[u8], cache: &Path, sha256: &str) -> Result<(), &'static str> { + let directory = cache.parent().ok_or("cache_unwritable")?; + std::fs::create_dir_all(directory).map_err(|_| "cache_unwritable")?; + let temporary = directory.join(format!(".download-{}", uuid::Uuid::new_v4().simple())); + let result = (|| { + let mut entries = tar::Archive::new(flate2::read::GzDecoder::new(archive)); + for entry in entries.entries().map_err(|_| "archive_invalid")? { + let entry = entry.map_err(|_| "archive_invalid")?; + let is_binary = entry + .path() + .map(|path| path == Path::new(ARCHIVE_ENTRY)) + .unwrap_or(false); + if !is_binary { + continue; + } + let mut file = File::create(&temporary).map_err(|_| "cache_unwritable")?; + let digest = copy_hashing(entry.take(MAX_BINARY_BYTES), &mut file) + .map_err(|_| "archive_invalid")?; + file.sync_all().map_err(|_| "cache_unwritable")?; + if digest != sha256 { + return Err("checksum_mismatch"); + } + return std::fs::rename(&temporary, cache).map_err(|_| "cache_unwritable"); + } + Err("archive_invalid") + })(); + if result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + result +} + +fn sha256_file(path: &Path) -> std::io::Result { + copy_hashing(File::open(path)?, &mut std::io::sink()) +} + +fn copy_hashing(mut source: impl Read, target: &mut impl Write) -> std::io::Result { + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 64 * 1024]; + loop { + let read = source.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + target.write_all(&buffer[..read])?; + } + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[cfg(test)] +#[path = "desktop_ssh_release_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs new file mode 100644 index 00000000..bed924ab --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_release_tests.rs @@ -0,0 +1,80 @@ +use super::*; + +fn archive(entries: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, bytes) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, path, *bytes).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + +fn sha256(bytes: &[u8]) -> String { + copy_hashing(bytes, &mut std::io::sink()).unwrap() +} + +#[test] +fn keeps_only_the_release_binary_from_the_archive() { + let directory = tempfile::tempdir().unwrap(); + let cache = directory.path().join("0.11.0/openaide-app-server"); + let packed = archive(&[ + ("openaide-app-server/README.md", b"guide"), + (ARCHIVE_ENTRY, b"binary"), + ]); + + assert_eq!( + extract_verified(&packed, &cache, &sha256(b"other")), + Err("checksum_mismatch") + ); + assert!(!cache.exists()); + assert_eq!( + std::fs::read_dir(cache.parent().unwrap()).unwrap().count(), + 0 + ); + + extract_verified(&packed, &cache, &sha256(b"binary")).unwrap(); + assert_eq!(std::fs::read(&cache).unwrap(), b"binary"); +} + +#[test] +fn an_archive_without_the_binary_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let packed = archive(&[("elsewhere/openaide-app-server", b"binary")]); + + assert_eq!( + extract_verified( + &packed, + &directory.path().join("x/openaide-app-server"), + &sha256(b"binary") + ), + Err("archive_invalid") + ); + assert_eq!( + extract_verified( + b"not gzip", + &directory.path().join("y/openaide-app-server"), + &sha256(b"") + ), + Err("archive_invalid") + ); +} + +#[test] +fn a_bundled_binary_is_its_own_release() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("openaide-app-server"); + std::fs::write(&path, b"binary").unwrap(); + + let source = local("0.11.0", path).unwrap(); + + assert_eq!(source.release.version(), "0.11.0"); + let mut opened = Vec::new(); + source.open().unwrap().read_to_end(&mut opened).unwrap(); + assert_eq!(opened, b"binary"); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_tests.rs new file mode 100644 index 00000000..5f7437a1 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_tests.rs @@ -0,0 +1,90 @@ +use openaide_remote_client::TransportError; + +use super::*; + +#[test] +fn failures_name_the_computer_and_never_the_raw_error() { + let failure = LaunchError::Transport { + stage: Stage::Probe, + error: TransportError { + class: "auth_failed", + }, + }; + + assert_eq!( + message(&failure, "devbox"), + "devbox did not accept the sign-in. Check your SSH key or password." + ); + assert!( + message( + &LaunchError::ServerNewer { + running_version: "0.12.0".into() + }, + "devbox" + ) + .contains("0.12.0") + ); + assert!( + message(&LaunchError::Install("checksum_tool_missing"), "devbox").contains("sha256sum") + ); +} + +/// Drives the system `ssh` and the shared launcher against a real Linux x64 +/// host: install, start, forward, attach again, and stop. Run it by hand with +/// `OPENAIDE_SSH_E2E_DESTINATION` (a host `ssh` reaches without prompts), +/// `OPENAIDE_SSH_E2E_BINARY` (a Linux x64 `openaide-app-server`), and +/// `OPENAIDE_SSH_E2E_VERSION` (that binary's version), then +/// `cargo test against_a_real_ssh_host -- --ignored`. +#[test] +#[ignore = "needs an SSH host; see the doc comment"] +fn against_a_real_ssh_host() { + use std::io::{Read, Write}; + + use openaide_remote_client::ServerRelease; + use sha2::{Digest, Sha256}; + + let variable = |name| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set")); + let destination = variable("OPENAIDE_SSH_E2E_DESTINATION"); + let binary = std::fs::read(variable("OPENAIDE_SSH_E2E_BINARY")).unwrap(); + let sha256 = format!("{:x}", Sha256::digest(&binary)); + let release = ServerRelease::new(&variable("OPENAIDE_SSH_E2E_VERSION"), &sha256).unwrap(); + let launcher = SshLauncher::new(release, crate::desktop_remote::logger(), Box::new(|_| {})); + let mut transport = SystemSsh::new(&destination, Vec::new()); + let launch = |transport: &mut SystemSsh, operation_id: &str| { + let mut fetch = || Ok(Box::new(std::io::Cursor::new(binary.clone())) as Box); + match launcher.launch(transport, StartMode::Keep, &mut fetch, operation_id) { + Ok(Launched::Ready(ready)) => ready, + Ok(Launched::UpdateRequired { running_version }) => { + panic!("the host runs an incompatible {running_version}") + } + Err(error) => panic!("{} failed: {}", error.stage().as_str(), error.class()), + } + }; + + let first = launch(&mut transport, "e2e-1"); + let url = url::Url::parse(&first.endpoint_url).unwrap(); + let mut stream = std::net::TcpStream::connect(( + url.host_str().unwrap(), + url.port_or_known_default().unwrap(), + )) + .unwrap(); + write!( + stream, + "GET {} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + url.path() + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!(response.starts_with("HTTP/1.1 "), "{response}"); + + let second = launch(&mut transport, "e2e-2"); + assert!(second.keeper_existed); + assert_eq!(second.auth_token, first.auth_token); + drop((first, second)); + + assert_eq!( + launcher.stop(&mut transport, "e2e-3").unwrap(), + StopOutcome::Stopped + ); +} diff --git a/apps/desktop/src-tauri/src/desktop_ssh_transport.rs b/apps/desktop/src-tauri/src/desktop_ssh_transport.rs new file mode 100644 index 00000000..9b06acd5 --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_transport.rs @@ -0,0 +1,411 @@ +//! The system `ssh` as the shared SSH launcher's transport (ADR-0069), so the +//! user's SSH configuration, jump hosts, agent, and hardware keys keep working. +//! +//! Each launch command is one `ssh` run. On macOS and Linux those runs share one +//! connection through `ControlMaster`, so a password is asked once per launch; +//! Windows' OpenSSH has no multiplexing and asks per run. The forward is its own +//! long-lived `ssh` that a supervisor restarts after a network drop, keeping the +//! same local port so the WebView's endpoint stays valid. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use openaide_remote_client::{RemoteOutput, SshTransport, TransportError}; + +/// Options every run shares. `ForwardX11=no` because askpass may set a +/// placeholder `DISPLAY`. +const COMMON_OPTIONS: &[&str] = &[ + "-o", + "ConnectTimeout=20", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + "-o", + "ForwardX11=no", +]; +/// Covers a person typing a password or touching a key after `ssh` starts. +const FORWARD_READY_LIMIT: Duration = Duration::from_secs(330); +const FORWARD_POLL: Duration = Duration::from_millis(100); +const RESTART_BACKOFF_LIMIT: Duration = Duration::from_secs(30); +const MAX_STDERR_BYTES: u64 = 64 * 1024; +#[cfg(target_os = "windows")] +const DETACHED_PROCESS: u32 = 0x0000_0008; + +pub(crate) struct SystemSsh { + destination: String, + environment: Vec<(&'static str, String)>, + control_path: Option, +} + +impl SystemSsh { + /// `environment` makes `ssh` ask its questions in this Desktop. + pub(crate) fn new(destination: &str, environment: Vec<(&'static str, String)>) -> Self { + Self { + destination: destination.to_string(), + environment, + control_path: control_path(), + } + } + + fn command(&self) -> Command { + let mut command = Command::new("ssh"); + command.args(COMMON_OPTIONS); + command.envs(self.environment.iter().map(|(name, value)| (name, value))); + #[cfg(target_os = "windows")] + { + // Without a console, Windows' OpenSSH reads prompts through askpass + // instead of a hidden console nobody can type into. + // TODO: verify on Windows that askpass starts the helper when the + // Desktop's install path holds spaces; only Linux has run it so far. + use std::os::windows::process::CommandExt; + command.creation_flags(DETACHED_PROCESS); + } + command + } +} + +impl SshTransport for SystemSsh { + type Forward = SshForward; + + fn run( + &mut self, + remote_command: &str, + input: Option<&mut dyn Read>, + ) -> Result { + let mut command = self.command(); + command.args(["-T", "-o", "ClearAllForwardings=yes"]); + if let Some(path) = &self.control_path { + command.args([ + "-o", + "ControlMaster=auto", + "-o", + &format!("ControlPath={path}"), + "-o", + "ControlPersist=60", + ]); + } + command + .args(["--", &self.destination, remote_command]) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(spawn_failure)?; + let stderr = collect_stderr(&mut child); + let mut stdout = child.stdout.take().expect("piped stdout"); + let reader = std::thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stdout.read_to_end(&mut bytes); + bytes + }); + if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) { + // A broken pipe means `ssh` ended early; its status says why. + let _ = std::io::copy(input, &mut stdin).and_then(|_| stdin.flush()); + } + let status = child.wait().map_err(|_| TransportError { + class: "ssh_failed", + })?; + let stdout = reader.join().unwrap_or_default(); + let stderr = stderr.join().unwrap_or_default(); + // 255 is `ssh`'s own failure; any other status is the remote command's. + if status.code() == Some(255) { + return Err(TransportError { + class: classify(&stderr), + }); + } + Ok(RemoteOutput { + success: status.success(), + stdout, + }) + } + + fn forward(&mut self, remote_port: u16) -> Result<(SshForward, SocketAddr), TransportError> { + let local_port = free_local_port()?; + let mut command = self.command(); + // TODO: a `LocalForward` in the user's configuration for this host + // also binds here, and a taken port fails the forward under + // `ExitOnForwardFailure`. `-o ClearAllForwardings=yes` would drop + // ours too, so a fix needs `-F` with a filtered configuration. + command.args([ + "-N", + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ControlPath=none", + "-L", + &format!("127.0.0.1:{local_port}:127.0.0.1:{remote_port}"), + "--", + &self.destination, + ]); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let local = SocketAddr::from(([127, 0, 0, 1], local_port)); + let child = start_forward(&mut command, local)?; + Ok((SshForward::supervise(command, child, local), local)) + } +} + +/// Keeps the forward up while it is held; dropping it ends the `ssh`. +pub(crate) struct SshForward { + stopping: Arc, + child: Arc>>, +} + +impl SshForward { + fn supervise(mut command: Command, child: Child, local: SocketAddr) -> Self { + let stopping = Arc::new(AtomicBool::new(false)); + let current = Arc::new(Mutex::new(Some(child))); + let forward = Self { + stopping: stopping.clone(), + child: current.clone(), + }; + let _ = std::thread::Builder::new() + .name("ssh-forward".to_string()) + .spawn(move || { + let mut attempt = 0u32; + loop { + wait_for_exit(¤t, &stopping); + if stopping.load(Ordering::Acquire) { + return; + } + attempt += 1; + let backoff = Duration::from_secs(1 << attempt.min(5)).min(RESTART_BACKOFF_LIMIT); + desktop_log!( + "desktop_ssh_forward_lost attempt_count={attempt} retry_in_ms={}", + backoff.as_millis() + ); + if sleep_unless_stopping(backoff, &stopping) { + return; + } + let started = Instant::now(); + match start_forward(&mut command, local) { + Ok(child) => { + desktop_log!( + "desktop_ssh_forward_restored attempt_count={attempt} duration_ms={}", + started.elapsed().as_millis() + ); + attempt = 0; + if let Ok(mut slot) = current.lock() { + *slot = Some(child); + } + } + Err(error) => desktop_log!( + "desktop_ssh_forward_restart_failed attempt_count={attempt} error_class={} duration_ms={}", + error.class, + started.elapsed().as_millis() + ), + } + } + }); + forward + } +} + +impl Drop for SshForward { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Release); + if let Ok(mut slot) = self.child.lock() + && let Some(mut child) = slot.take() + { + let _ = child.kill(); + let _ = child.wait(); + } + desktop_log!("desktop_ssh_forward_closed"); + } +} + +/// Starts the forward `ssh` and returns once the local port answers, which is +/// after any prompt and the remote's acceptance of the forward. +fn start_forward(command: &mut Command, local: SocketAddr) -> Result { + let started = Instant::now(); + let mut child = command.spawn().map_err(spawn_failure)?; + let stderr = collect_stderr(&mut child); + loop { + if let Ok(Some(_)) = child.try_wait() { + return Err(TransportError { + class: match classify(&stderr.join().unwrap_or_default()) { + "ssh_failed" => "forward_failed", + class => class, + }, + }); + } + if TcpStream::connect_timeout(&local, FORWARD_POLL).is_ok() + && matches!(child.try_wait(), Ok(None)) + { + desktop_log!( + "desktop_ssh_forward_ready wait_ms={}", + started.elapsed().as_millis() + ); + return Ok(child); + } + if started.elapsed() > FORWARD_READY_LIMIT { + let _ = child.kill(); + let _ = child.wait(); + return Err(TransportError { + class: "forward_timeout", + }); + } + std::thread::sleep(FORWARD_POLL); + } +} + +fn wait_for_exit(child: &Mutex>, stopping: &AtomicBool) { + loop { + if stopping.load(Ordering::Acquire) { + return; + } + let exited = match child.lock() { + Ok(mut slot) => match slot.as_mut() { + Some(child) => !matches!(child.try_wait(), Ok(None)), + None => true, + }, + Err(_) => true, + }; + if exited { + return; + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// True when stopped during the wait. +fn sleep_unless_stopping(duration: Duration, stopping: &AtomicBool) -> bool { + let until = Instant::now() + duration; + while Instant::now() < until { + if stopping.load(Ordering::Acquire) { + return true; + } + std::thread::sleep(Duration::from_millis(200)); + } + stopping.load(Ordering::Acquire) +} + +/// `ssh -L` cannot choose a port and report it, so the shell picks a free one. +/// Another process taking it in between makes `ssh` fail the forward. +fn free_local_port() -> Result { + TcpListener::bind(("127.0.0.1", 0)) + .and_then(|listener| listener.local_addr()) + .map(|address| address.port()) + .map_err(|_| TransportError { + class: "local_port_unavailable", + }) +} + +fn collect_stderr(child: &mut Child) -> std::thread::JoinHandle { + let stderr = child.stderr.take(); + std::thread::spawn(move || { + let mut text = String::new(); + if let Some(stderr) = stderr { + let _ = stderr.take(MAX_STDERR_BYTES).read_to_string(&mut text); + } + text + }) +} + +fn spawn_failure(error: std::io::Error) -> TransportError { + TransportError { + class: if error.kind() == std::io::ErrorKind::NotFound { + "ssh_missing" + } else { + "ssh_failed" + }, + } +} + +/// Classifies `ssh`'s own diagnostics. The text itself names hosts and users +/// and never leaves this function. +pub(crate) fn classify(stderr: &str) -> &'static str { + let has = |needles: &[&str]| needles.iter().any(|needle| stderr.contains(needle)); + if has(&[ + "Host key verification failed", + "REMOTE HOST IDENTIFICATION HAS CHANGED", + ]) { + "host_key_rejected" + } else if has(&["Permission denied", "Too many authentication failures"]) { + "auth_failed" + } else if has(&[ + "Could not resolve hostname", + "Name or service not known", + "nodename nor servname", + ]) { + "host_unknown" + } else if has(&["Connection refused"]) { + "connection_refused" + } else if has(&[ + "timed out", + "No route to host", + "Network is unreachable", + "Connection closed", + "Connection reset", + ]) { + "unreachable" + } else if has(&[ + "Bad configuration option", + "Bad owner or permissions", + "garbage at end of line", + "Unsupported option", + ]) { + "ssh_config_invalid" + } else if has(&[ + "forwarding failed", + "cannot listen to port", + "Address already in use", + ]) { + "forward_failed" + } else { + "ssh_failed" + } +} + +/// A place for the control socket whose directory only this user can enter, +/// short enough for a socket path, or none to run without multiplexing. +fn control_path() -> Option { + #[cfg(target_os = "macos")] + let base = Some(std::env::temp_dir()); + #[cfg(target_os = "linux")] + let base = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from); + // TODO: multiplex on Windows too. Its OpenSSH has no ControlMaster, so a + // password user answers once per `ssh` run; a long-lived session or an + // embedded client, as planned for Android, would ask once per start. + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + let base: Option = None; + let directory = base?.join("oa-ssh"); + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + let _ = std::fs::DirBuilder::new().mode(0o700).create(&directory); + let metadata = std::fs::metadata(&directory).ok()?; + if !metadata.is_dir() || metadata.permissions().mode() & 0o077 != 0 { + return None; + } + } + let path = directory.join("%C").to_string_lossy().into_owned(); + // `%C` expands to 40 characters; Unix socket paths stop near 104 bytes. + (path.len() + 40 < 100 && !path.contains(' ')).then_some(path) +} + +/// A destination is passed to `ssh` as one argument after `--`; anything that +/// could read as an option or split into several words is refused. +pub(crate) fn is_destination(text: &str) -> bool { + !text.is_empty() + && text.len() <= 255 + && !text.starts_with('-') + && text + .chars() + .all(|character| !character.is_whitespace() && !character.is_control()) +} + +#[cfg(test)] +#[path = "desktop_ssh_transport_tests.rs"] +mod tests; diff --git a/apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs b/apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs new file mode 100644 index 00000000..942e0afa --- /dev/null +++ b/apps/desktop/src-tauri/src/desktop_ssh_transport_tests.rs @@ -0,0 +1,46 @@ +use super::*; + +#[test] +fn ssh_diagnostics_become_classes() { + let cases = [ + ("Host key verification failed.", "host_key_rejected"), + ( + "someone@host: Permission denied (publickey,password).", + "auth_failed", + ), + ( + "ssh: Could not resolve hostname nowhere: Name or service not known", + "host_unknown", + ), + ( + "ssh: connect to host h port 22: Connection refused", + "connection_refused", + ), + ( + "ssh: connect to host h port 22: Operation timed out", + "unreachable", + ), + ( + "/home/someone/.ssh/config: line 3: Bad configuration option: foo", + "ssh_config_invalid", + ), + ( + "Error: remote port forwarding failed for listen port 1", + "forward_failed", + ), + ("something new", "ssh_failed"), + ]; + for (stderr, class) in cases { + assert_eq!(classify(stderr), class, "{stderr}"); + } +} + +#[test] +fn a_destination_cannot_become_an_option_or_several_words() { + assert!(is_destination("devbox")); + assert!(is_destination("ssh://someone@build.example:2222")); + assert!(!is_destination("-oProxyCommand=touch")); + assert!(!is_destination("devbox other")); + assert!(!is_destination("devbox\n")); + assert!(!is_destination("")); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index e1e6cbdb..6c09d021 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -28,6 +28,11 @@ mod desktop_secrets; #[cfg(test)] #[path = "desktop_server_recovery_tests.rs"] mod desktop_server_recovery_tests; +mod desktop_ssh; +mod desktop_ssh_askpass; +mod desktop_ssh_config; +mod desktop_ssh_release; +mod desktop_ssh_transport; mod desktop_support_export; mod desktop_update; mod desktop_update_receipt; @@ -76,6 +81,10 @@ struct DesktopBootstrap { /// Names the paired computer whose App Server this Desktop uses instead of its own. #[serde(skip_serializing_if = "Option::is_none")] remote_computer: Option, + /// The computer in use is an SSH Computer, whose App Server this shell + /// restores after sleep like its own. + #[serde(skip_serializing_if = "std::ops::Not::not")] + ssh_computer: bool, } #[derive(Clone, Debug, Serialize)] @@ -147,6 +156,10 @@ impl DesktopState { } fn main() { + // `ssh` starts this executable to ask the user something; it opens no window. + if let Some(code) = desktop_ssh_askpass::run_helper_if_requested() { + std::process::exit(code); + } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_notification::init()) @@ -175,6 +188,9 @@ fn main() { resource_dir.join("wsl").join("openaide-app-server"), )); app.manage(desktop_remote::DesktopRemoteState::new(&app_local_data)); + app.manage(desktop_ssh::DesktopSshState::new( + app.path().app_cache_dir()?, + )); app.manage(DesktopUpdateState::for_build( app_local_data.join("desktop-update-receipt.json"), )); @@ -244,6 +260,14 @@ fn main() { desktop_remote::desktop_remote_pair, desktop_remote::desktop_remote_use, desktop_remote::desktop_remote_forget, + desktop_ssh::desktop_ssh_hosts, + desktop_ssh::desktop_ssh_add, + desktop_ssh::desktop_ssh_status, + desktop_ssh::desktop_ssh_failure, + desktop_ssh::desktop_ssh_update, + desktop_ssh::desktop_ssh_stop, + desktop_ssh::desktop_ssh_prompts, + desktop_ssh::desktop_ssh_prompt_answer, desktop_update::desktop_update_snapshot, desktop_update::desktop_check_for_update, desktop_update::desktop_auto_check_for_update, @@ -424,6 +448,9 @@ async fn desktop_bootstrap( ) }; let remote_computer = remote.as_ref().map(|computer| computer.name.clone()); + let ssh_computer = remote + .as_ref() + .is_some_and(|computer| computer.ssh.is_some()); let runtime_options = DesktopRuntimeOptions { wsl_distros: discover_wsl_distros(), }; @@ -440,15 +467,19 @@ async fn desktop_bootstrap( runtime_environment: environment, runtime_options, remote_computer, + ssh_computer, }); } let operation_id = format!("desktop-bootstrap-{}", uuid::Uuid::new_v4()); let connection = match &remote { - Some(computer) => { - emit_bootstrap_progress(&app, "remote", format!("Connecting to {}", computer.name)); - desktop_remote::connect(&app, computer).await? - } + Some(computer) => match &computer.ssh { + Some(destination) => desktop_ssh::connect(&app, computer, destination, true).await?, + None => { + emit_bootstrap_progress(&app, "remote", format!("Connecting to {}", computer.name)); + desktop_remote::connect(&app, computer).await? + } + }, None => { launch_app_server_for_desktop( state.runtime_paths.clone(), @@ -479,6 +510,7 @@ async fn desktop_bootstrap( runtime_environment: environment, runtime_options, remote_computer, + ssh_computer, }) } @@ -569,6 +601,9 @@ async fn desktop_ensure_app_server( app: tauri::AppHandle, state: tauri::State<'_, DesktopState>, ) -> Result, String> { + if let Some(ensured) = desktop_ssh::ensure(&app).await { + return ensured; + } // The gateway to a paired computer lives as long as this process, and the // session's own retries reopen its connection. if !state.owns_app_server() { diff --git a/openaide-rs/remote-client/src/ssh.rs b/openaide-rs/remote-client/src/ssh.rs index 06389d46..bafc4af1 100644 --- a/openaide-rs/remote-client/src/ssh.rs +++ b/openaide-rs/remote-client/src/ssh.rs @@ -370,6 +370,10 @@ impl SshLauncher { body: impl FnOnce() -> Result, ) -> Result { (self.progress)(stage); + (self.logger)( + "ssh_launch_stage_started", + json!({ "operationId": operation_id, "stage": stage.as_str() }), + ); let started = Instant::now(); let result = body(); (self.logger)( diff --git a/openaide-rs/remote-client/src/ssh_tests.rs b/openaide-rs/remote-client/src/ssh_tests.rs index a54d0012..d70c4b6d 100644 --- a/openaide-rs/remote-client/src/ssh_tests.rs +++ b/openaide-rs/remote-client/src/ssh_tests.rs @@ -148,6 +148,15 @@ fn installs_a_missing_release_then_forwards_the_kept_app_server() { assert_eq!(*name, "ssh_launch_completed"); assert_eq!(completed["outcome"], "ready"); assert_eq!(completed["operationId"], "op-1"); + let started = events + .iter() + .filter(|(name, _)| *name == "ssh_launch_stage_started") + .count(); + let finished = events + .iter() + .filter(|(name, _)| *name == "ssh_launch_stage_completed") + .count(); + assert_eq!((started, finished), (5, 5)); assert!(!format!("{events:?}").contains("secret-token")); } diff --git a/scripts/resolve-release-artifact-graph.mjs b/scripts/resolve-release-artifact-graph.mjs index 0a957b0c..198daae4 100644 --- a/scripts/resolve-release-artifact-graph.mjs +++ b/scripts/resolve-release-artifact-graph.mjs @@ -8,8 +8,9 @@ const dependencies = Object.freeze({ "vsix-linux-x64": ["server-linux-x64-musl"], "vsix-win32-x64": ["server-win32-x64"], "vsix-darwin-arm64": ["server-darwin-arm64"], + // Every Desktop pins the Linux App Server it installs on SSH Computers (ADR-0069). "desktop-win32-x64": ["server-win32-x64", "server-linux-x64-musl"], - "desktop-darwin-arm64": ["server-darwin-arm64"], + "desktop-darwin-arm64": ["server-darwin-arm64", "server-linux-x64-musl"], }); const nodeOrder = [ diff --git a/scripts/test-workspace-gate.test.mjs b/scripts/test-workspace-gate.test.mjs index 30d14651..a5c52bd3 100644 --- a/scripts/test-workspace-gate.test.mjs +++ b/scripts/test-workspace-gate.test.mjs @@ -282,6 +282,18 @@ test("artifact builds select only the requested package targets and required ser ]); assert.deepEqual(fullRelease.dependencies["vsix-linux-x64"], ["server-linux-x64-musl"]); + const macos = resolveReleaseArtifactGraph({ + artifacts: "desktop", + vsixTarget: "all", + desktopTarget: "darwin-arm64", + }); + // The macOS Desktop compiles in the checksum of the Linux App Server it installs over SSH. + assert.deepEqual(macos.enabledNodes, [ + "server-linux-x64-musl", + "server-darwin-arm64", + "desktop-darwin-arm64", + ]); + assert.throws( () => resolveReleaseArtifactGraph({ artifacts: "desktop", From 625d9e9962764b5cc382bfadeedc506b881ee1b5 Mon Sep 17 00:00:00 2001 From: Daniil Shushakov <4shushakov@gmail.com> Date: Fri, 23 Oct 2026 09:11:40 +0200 Subject: [PATCH 07/21] feat(frontend): add and manage SSH Computers in Settings Settings lists SSH Computers beside paired ones, adds one by host with suggestions from the SSH configuration, stops OpenAIDE there after confirmation, and offers an update when its App Server is older. The update warning lists what the replacement interrupts, as the App Server reports it. It also suggests `loginctl enable-linger` when logind would end the App Server at logout. The Desktop shows ssh's questions in a dialog above the startup screen and the app, restores an SSH Computer's App Server after sleep like its own, and offers the confirmed update when a start fails because the App Server there is too old. --- apps/desktop/frontend/desktopBootstrap.ts | 2 + apps/desktop/frontend/desktopShell.ts | 14 +- .../frontend/desktopSshPrompts.test.ts | 69 +++++++++ apps/desktop/frontend/desktopSshPrompts.ts | 140 ++++++++++++++++++ .../frontend/desktopSshStartup.test.ts | 33 +++++ apps/desktop/frontend/desktopSshStartup.ts | 55 +++++++ .../frontend/desktopStartupWindow.test.ts | 12 +- apps/desktop/frontend/main.tsx | 8 + apps/desktop/index.html | 62 ++++++++ .../src/components/AppSurfaces.test.tsx | 1 + .../src/components/settings/RemoteSection.tsx | 103 +++++++++++-- .../settings/SettingsSections.dom.test.tsx | 78 ++++++++++ .../settings/SettingsView.dom.test.tsx | 1 + .../src/components/settings/sshComputers.tsx | 136 +++++++++++++++++ .../src/intents/remoteDeviceIntents.ts | 5 + .../frontend/src/services/frontendShell.ts | 38 ++++- .../src/styles/app/settings-pages.css | 6 + 17 files changed, 740 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/frontend/desktopSshPrompts.test.ts create mode 100644 apps/desktop/frontend/desktopSshPrompts.ts create mode 100644 apps/desktop/frontend/desktopSshStartup.test.ts create mode 100644 apps/desktop/frontend/desktopSshStartup.ts create mode 100644 packages/frontend/src/components/settings/sshComputers.tsx diff --git a/apps/desktop/frontend/desktopBootstrap.ts b/apps/desktop/frontend/desktopBootstrap.ts index 62c90205..5affd34b 100644 --- a/apps/desktop/frontend/desktopBootstrap.ts +++ b/apps/desktop/frontend/desktopBootstrap.ts @@ -39,6 +39,8 @@ export type DesktopBootstrap = { runtimeOptions: DesktopRuntimeOptions; /** Names the paired computer whose App Server is in use instead of this one's. */ remoteComputer?: string; + /** The computer in use is an SSH Computer, whose App Server this shell restores (ADR-0069). */ + sshComputer?: boolean; }; export type DesktopBootstrapProgress = { diff --git a/apps/desktop/frontend/desktopShell.ts b/apps/desktop/frontend/desktopShell.ts index 188dcb5e..5fa85aa7 100644 --- a/apps/desktop/frontend/desktopShell.ts +++ b/apps/desktop/frontend/desktopShell.ts @@ -13,7 +13,7 @@ import { import { openUrl } from "@tauri-apps/plugin-opener"; import { CLIENT_DETACH, type AppServerSession } from "@openaide/app-server-client"; import type { HostToWebviewMessage, SettingsIntent, SettingsTabId } from "@openaide/app-shell-contracts"; -import type { FrontendShell, RemoteComputer } from "../../../packages/frontend/src/services/frontendShell"; +import type { FrontendShell, RemoteComputer, SshComputerStatus } from "../../../packages/frontend/src/services/frontendShell"; import { createShellAppearance } from "../../../packages/frontend/src/services/shellAppearance"; import type { PostHostMessage } from "../../../packages/frontend/src/state/postHostMessage"; import type { WebviewBootstrap } from "../../../packages/frontend/src/state/surfaceTypes"; @@ -49,9 +49,10 @@ export function createDesktopShell( // machine-resume signal on macOS, and re-broadcasting it as `openaide:resume` // also wakes the shared transport and restarts suspended animations. // With a paired computer in use there is no process here to restore; the - // session's own retries reopen the connection. + // session's own retries reopen the connection. An SSH Computer's forward and + // App Server are this shell's to restore, as its own are. const remote = Boolean(host.remoteComputer); - if (!remote) createDesktopServerRecovery({ invoke }); + if (!remote || host.sshComputer) createDesktopServerRecovery({ invoke }); void nativeWindow.onFocusChanged(({ payload: focused }) => { if (focused) window.dispatchEvent(new Event("openaide:resume")); }); @@ -261,6 +262,13 @@ export function createDesktopShell( pair: (code) => invoke("desktop_remote_pair", { code }), use: (id) => invoke("desktop_remote_use", { id: id ?? null }), forget: (id) => invoke("desktop_remote_forget", { id }), + ssh: { + hosts: () => invoke("desktop_ssh_hosts"), + add: (destination, name) => invoke("desktop_ssh_add", { destination, name: name ?? null }), + status: async () => (await invoke("desktop_ssh_status")) ?? undefined, + update: () => invoke("desktop_ssh_update"), + stop: (id) => invoke<"stopped" | "notRunning" | "stillRunning">("desktop_ssh_stop", { id }), + }, }, desktopUpdates, desktopCommands: { diff --git a/apps/desktop/frontend/desktopSshPrompts.test.ts b/apps/desktop/frontend/desktopSshPrompts.test.ts new file mode 100644 index 00000000..ee419855 --- /dev/null +++ b/apps/desktop/frontend/desktopSshPrompts.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { initializeSshPrompts, type DesktopSshPrompt } from "./desktopSshPrompts"; + +afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +const password: DesktopSshPrompt = { id: 1, computer: "devbox", text: "me@devbox's password:", kind: "secret" }; +const hostKey: DesktopSshPrompt = { id: 2, computer: "devbox", text: "Are you sure you want to continue connecting (yes/no)?", kind: "yesNo" }; + +async function setUp(pending: DesktopSshPrompt[] = []) { + let emit: (prompt: DesktopSshPrompt) => void = () => undefined; + const invoke = vi.fn(async (command: string) => (command === "desktop_ssh_prompts" ? pending : true)); + const listen = vi.fn(async (_event: string, listener: (event: { payload: DesktopSshPrompt }) => void) => { + emit = (payload) => listener({ payload }); + return () => undefined; + }); + vi.spyOn(console, "info").mockImplementation(() => undefined); + initializeSshPrompts({ invoke, listen }); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompts")); + return { invoke, emit: (prompt: DesktopSshPrompt) => emit(prompt) }; +} + +const prompt = () => document.querySelector(".desktop-ssh-prompt"); +const press = (label: string) => [...prompt()!.querySelectorAll("button")].find((button) => button.textContent === label)!.click(); + +it("asks for a password that was pending before the WebView listened, and logs no answer", async () => { + const { invoke } = await setUp([password]); + + expect(prompt()?.textContent).toContain("me@devbox's password:"); + const input = prompt()!.querySelector("input")!; + expect(input.type).toBe("password"); + input.value = "hunter2"; + press("Continue"); + + expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompt_answer", { id: 1, answer: "hunter2" }); + expect(prompt()).toBeNull(); + await vi.waitFor(() => expect(console.info).toHaveBeenCalledWith(expect.stringContaining("desktop_ssh_prompt_closed prompt_id=1"))); + expect(JSON.stringify(vi.mocked(console.info).mock.calls)).not.toContain("hunter2"); +}); + +it("shows one question at a time, ignores a repeat, and answers a host key question", async () => { + const { invoke, emit } = await setUp(); + emit(hostKey); + emit(password); + emit(hostKey); + + expect(document.querySelectorAll(".desktop-ssh-prompt")).toHaveLength(1); + press("No"); + expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompt_answer", { id: 2, answer: "no" }); + expect(prompt()?.querySelector("input")).not.toBeNull(); + press("Cancel"); + + expect(invoke).toHaveBeenCalledWith("desktop_ssh_prompt_answer", { id: 1, answer: null }); + expect(prompt()).toBeNull(); +}); + +it("dismisses a notice without answering ssh", async () => { + const { invoke, emit } = await setUp(); + emit({ id: 3, computer: "devbox", text: "Host key changed.", kind: "notice" }); + + expect(prompt()?.textContent).toContain("Message from devbox"); + press("OK"); + + expect(prompt()).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith("desktop_ssh_prompt_answer", expect.anything()); +}); diff --git a/apps/desktop/frontend/desktopSshPrompts.ts b/apps/desktop/frontend/desktopSshPrompts.ts new file mode 100644 index 00000000..0dd761b4 --- /dev/null +++ b/apps/desktop/frontend/desktopSshPrompts.ts @@ -0,0 +1,140 @@ +/** One question from `ssh`, relayed by the Desktop's askpass broker (ADR-0069). */ +export type DesktopSshPrompt = { + id: number; + /** The SSH Computer's name in this Desktop. */ + computer: string; + /** `ssh`'s own wording, such as a password request or a host key fingerprint. */ + text: string; + kind: "secret" | "yesNo" | "notice"; +}; + +type Invoke = (command: string, args?: Record) => Promise; +type Listen = ( + event: string, + listener: (event: { payload: DesktopSshPrompt }) => void, +) => Promise<() => void>; + +/** + * Shows `ssh` questions one at a time, above the startup screen and the app + * alike, since `ssh` may ask while the shell starts or while it reconnects a + * forward. The dialog lives on `document.body`, outside the React root that + * replaces the startup nodes. Answers go straight back to the broker and are + * never kept here; logs carry only the prompt id and kind. + */ +export function initializeSshPrompts({ + invoke, + listen, + body = document.body, +}: { + invoke: Invoke; + listen: Listen; + body?: HTMLElement; +}) { + const queue: DesktopSshPrompt[] = []; + const seen = new Set(); + let shown: { prompt: DesktopSshPrompt; element: HTMLElement; startedAt: number } | undefined; + + const enqueue = (prompt: DesktopSshPrompt) => { + if (seen.has(prompt.id)) return; + seen.add(prompt.id); + queue.push(prompt); + if (!shown) showNext(); + }; + + const finish = (answer: string | null, outcome: "answered" | "declined" | "dismissed") => { + if (!shown) return; + const { prompt, element, startedAt } = shown; + shown = undefined; + element.remove(); + const durationMs = Math.round(performance.now() - startedAt); + if (prompt.kind === "notice") { + console.info(`desktop_ssh_prompt_closed prompt_id=${prompt.id} kind=notice outcome=${outcome} duration_ms=${durationMs}`); + } else { + void invoke("desktop_ssh_prompt_answer", { id: prompt.id, answer }) + .then((delivered) => { + // `false` means `ssh` stopped waiting, such as after the broker's timeout. + console.info(`desktop_ssh_prompt_closed prompt_id=${prompt.id} kind=${prompt.kind} outcome=${outcome} delivered=${delivered === true} duration_ms=${durationMs}`); + }) + .catch(() => { + console.warn(`desktop_ssh_prompt_closed prompt_id=${prompt.id} kind=${prompt.kind} outcome=failure error_kind=answer_delivery duration_ms=${durationMs}`); + }); + } + showNext(); + }; + + const showNext = () => { + const prompt = queue.shift(); + if (!prompt) return; + const element = renderPrompt(prompt, finish); + shown = { prompt, element, startedAt: performance.now() }; + body.append(element); + element.querySelector("input, button[data-primary]")?.focus(); + console.info(`desktop_ssh_prompt_shown prompt_id=${prompt.id} kind=${prompt.kind}`); + }; + + // Listen first, then read what arrived before; ids drop the overlap. + void listen("desktop-ssh-prompt", ({ payload }) => enqueue(payload)) + .then(() => invoke("desktop_ssh_prompts")) + .then((pending) => { for (const prompt of pending as DesktopSshPrompt[]) enqueue(prompt); }) + .catch(() => console.warn("desktop_ssh_prompts_load_completed outcome=failure error_kind=native_command")); +} + +function renderPrompt( + prompt: DesktopSshPrompt, + finish: (answer: string | null, outcome: "answered" | "declined" | "dismissed") => void, +) { + const form = document.createElement("form"); + form.className = "desktop-ssh-prompt"; + form.setAttribute("role", "dialog"); + form.setAttribute("aria-modal", "true"); + form.setAttribute("aria-label", `${prompt.computer} asks`); + const title = document.createElement("strong"); + title.textContent = prompt.kind === "notice" ? `Message from ${prompt.computer}` : `${prompt.computer} asks`; + const text = document.createElement("pre"); + text.textContent = prompt.text; + const footer = document.createElement("footer"); + form.append(title, text); + + const button = (label: string, primary: boolean, onClick: () => void) => { + const element = document.createElement("button"); + element.type = primary ? "submit" : "button"; + element.textContent = label; + if (primary) element.dataset.primary = ""; + else element.onclick = onClick; + footer.append(element); + }; + + if (prompt.kind === "secret") { + const input = document.createElement("input"); + input.type = "password"; + input.autocomplete = "off"; + input.setAttribute("aria-label", "Answer"); + form.append(input); + button("Cancel", false, () => finish(null, "declined")); + button("Continue", true, () => undefined); + form.onsubmit = (event) => { + event.preventDefault(); + finish(input.value, "answered"); + }; + } else if (prompt.kind === "yesNo") { + button("No", false, () => finish("no", "declined")); + button("Yes", true, () => undefined); + form.onsubmit = (event) => { + event.preventDefault(); + finish("yes", "answered"); + }; + } else { + button("OK", true, () => undefined); + form.onsubmit = (event) => { + event.preventDefault(); + finish(null, "dismissed"); + }; + } + form.onkeydown = (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + finish(prompt.kind === "yesNo" ? "no" : null, prompt.kind === "notice" ? "dismissed" : "declined"); + }; + form.append(footer); + return form; +} diff --git a/apps/desktop/frontend/desktopSshStartup.test.ts b/apps/desktop/frontend/desktopSshStartup.test.ts new file mode 100644 index 00000000..e4c01a4e --- /dev/null +++ b/apps/desktop/frontend/desktopSshStartup.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom +import startupHtml from "../index.html?raw"; +import { afterEach, expect, it, vi } from "vitest"; +import { offerSshUpdateAfterFailure } from "./desktopSshStartup"; + +afterEach(() => vi.restoreAllMocks()); + +function setUp(failure: unknown) { + document.documentElement.innerHTML = startupHtml; + vi.spyOn(console, "info").mockImplementation(() => undefined); + const invoke = vi.fn(async (command: string) => (command === "desktop_ssh_failure" ? failure : undefined)); + return { invoke, root: document.getElementById("root")!, update: document.querySelector(".desktop-startup-update")! }; +} + +it("updates an SSH Computer that is too old only after a second, warned click", async () => { + const { invoke, root, update } = setUp({ computerId: "ssh-1", kind: "updateRequired", runningVersion: "0.8.0" }); + await offerSshUpdateAfterFailure({ invoke, root, computer: "devbox" }); + + expect(update.hidden).toBe(false); + update.click(); + expect(root.querySelector(".desktop-startup-label")?.textContent).toContain("agents working there stop"); + expect(invoke).not.toHaveBeenCalledWith("desktop_ssh_update"); + update.click(); + + expect(invoke).toHaveBeenCalledWith("desktop_ssh_update"); +}); + +it("offers nothing when the start failed for another reason", async () => { + const { invoke, root, update } = setUp({ computerId: "ssh-1", kind: "failed" }); + await offerSshUpdateAfterFailure({ invoke, root, computer: "devbox" }); + + expect(update.hidden).toBe(true); +}); diff --git a/apps/desktop/frontend/desktopSshStartup.ts b/apps/desktop/frontend/desktopSshStartup.ts new file mode 100644 index 00000000..b4db800d --- /dev/null +++ b/apps/desktop/frontend/desktopSshStartup.ts @@ -0,0 +1,55 @@ +type Invoke = (command: string, args?: Record) => Promise; + +type SshFailure = { + computerId: string; + kind: "updateRequired" | "failed"; + runningVersion?: string; +}; + +/** + * After a failed start, offers the one way past an SSH Computer whose App + * Server is too old for this app: replacing it. That App Server refuses this + * client, so the warning cannot list what it runs; it names what may be lost + * and asks once more before the shell replaces it on the next start. + */ +export async function offerSshUpdateAfterFailure({ + invoke, + root, + computer, +}: { + invoke: Invoke; + root: HTMLElement; + computer: string; +}) { + let failure: SshFailure | null; + try { + failure = await invoke("desktop_ssh_failure") as SshFailure | null; + } catch { + console.warn("desktop_ssh_failure_load_completed outcome=failure error_kind=native_command"); + return; + } + if (failure?.kind !== "updateRequired") return; + const update = root.querySelector(".desktop-startup-update"); + const label = root.querySelector(".desktop-startup-label"); + if (!update || !label) return; + update.hidden = false; + let confirming = false; + update.onclick = () => { + if (!confirming) { + confirming = true; + label.textContent = `Updating restarts OpenAIDE on ${computer}. Other apps connected to it disconnect, and agents working there stop.`; + update.textContent = "Update and restart"; + return; + } + update.disabled = true; + update.textContent = "Updating…"; + console.info("desktop_ssh_update_requested source=startup"); + // Success restarts the app; the shell's own logs carry the outcome. + void invoke("desktop_ssh_update").catch((error: unknown) => { + update.disabled = false; + update.textContent = "Update and restart"; + label.textContent = typeof error === "string" ? error : "OpenAIDE could not update that computer."; + console.warn("desktop_ssh_update_requested outcome=failure error_kind=native_command"); + }); + }; +} diff --git a/apps/desktop/frontend/desktopStartupWindow.test.ts b/apps/desktop/frontend/desktopStartupWindow.test.ts index 483ad64c..e795b29b 100644 --- a/apps/desktop/frontend/desktopStartupWindow.test.ts +++ b/apps/desktop/frontend/desktopStartupWindow.test.ts @@ -15,6 +15,12 @@ vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}) }) vi.mock("../../../packages/frontend/src/startFrontend", () => ({ startFrontend: vi.fn() })); vi.mock("./desktopShell", () => ({ createDesktopShell: vi.fn() })); +/** Fails the startup handshake while other shell commands stay pending. */ +async function rejectStartup(reason: unknown) { + const { invoke } = await import("@tauri-apps/api/core"); + vi.mocked(invoke).mockImplementation((command) => (command === "desktop_bootstrap_context" ? Promise.reject(reason) : new Promise(() => {}))); +} + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); vi.resetModules(); }); it("can drag and use caption buttons while backend startup is pending", async () => { @@ -50,8 +56,7 @@ it("keeps native window controls on decorated platforms", async () => { it("keeps close available after startup fails", async () => { vi.useFakeTimers(); - const { invoke } = await import("@tauri-apps/api/core"); - vi.mocked(invoke).mockRejectedValueOnce(new Error("Startup unavailable")); + await rejectStartup(new Error("Startup unavailable")); document.documentElement.innerHTML = startupHtml; await import("./main"); await vi.waitFor(() => expect(document.querySelector(".desktop-startup-label")!.textContent).toContain("could not start")); @@ -63,8 +68,7 @@ it("keeps close available after startup fails", async () => { it("shows the reason a rejected startup command reports", async () => { vi.useFakeTimers(); - const { invoke } = await import("@tauri-apps/api/core"); - vi.mocked(invoke).mockRejectedValueOnce("The App Server did not become ready in time."); + await rejectStartup("The App Server did not become ready in time."); document.documentElement.innerHTML = startupHtml; await import("./main"); await vi.waitFor(() => expect(document.querySelector(".desktop-startup-label")!.textContent) diff --git a/apps/desktop/frontend/main.tsx b/apps/desktop/frontend/main.tsx index 8ac379be..3cdd3ea1 100644 --- a/apps/desktop/frontend/main.tsx +++ b/apps/desktop/frontend/main.tsx @@ -9,20 +9,27 @@ import { } from "./desktopBootstrap"; import { refuseUnclaimedFileDrops } from "./desktopFileUploads"; +import { initializeSshPrompts } from "./desktopSshPrompts"; +import { offerSshUpdateAfterFailure } from "./desktopSshStartup"; import { initializeStartupWindow } from "./desktopStartupWindow"; refuseUnclaimedFileDrops(window); initializeStartupWindow(); +// `ssh` may ask for a password or a host key decision during startup. +initializeSshPrompts({ invoke, listen }); const startupStartedAt = performance.now(); const startupElapsedTimer = window.setInterval(updateStartupElapsed, 1_000); +let remoteComputer: string | undefined; + async function start() { const bootstrap = await runDesktopBootstrap({ invoke, listen, onProgress: ({ message }) => updateStartupStatus(message), onContext: (context) => { + remoteComputer = context.remoteComputer; const cancel = document.querySelector(".desktop-startup-cancel"); if (cancel) { // A paired computer may be off or out of reach; the way back to this @@ -85,5 +92,6 @@ void start().catch((error) => { retry.hidden = false; retry.onclick = () => window.location.reload(); } + if (remoteComputer) void offerSshUpdateAfterFailure({ invoke, root, computer: remoteComputer }); } }); diff --git a/apps/desktop/index.html b/apps/desktop/index.html index b5df12ec..babceaa1 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -172,6 +172,67 @@ padding: 4px 10px; } + /* `ssh` questions; above the startup screen and the app (desktopSshPrompts.ts). */ + .desktop-ssh-prompt { + position: fixed; + z-index: 2147483000; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + width: min(440px, calc(100vw - 32px)); + box-sizing: border-box; + display: grid; + gap: 10px; + margin: 0; + padding: 18px; + border: 1px solid light-dark(oklch(0.84 0.01 255), oklch(0.36 0.012 255)); + border-radius: 10px; + background: light-dark(oklch(0.99 0.004 255), oklch(0.23 0.009 255)); + color: light-dark(oklch(0.24 0.012 255), oklch(0.91 0.007 255)); + box-shadow: 0 0 0 100vmax rgb(0 0 0 / 32%), 0 12px 32px rgb(0 0 0 / 24%); + font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; + } + + .desktop-ssh-prompt pre { + max-height: 40vh; + overflow: auto; + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + .desktop-ssh-prompt input { + min-height: 30px; + border: 1px solid light-dark(oklch(0.72 0.012 255), oklch(0.43 0.012 255)); + border-radius: 6px; + background: transparent; + color: inherit; + padding: 4px 8px; + font: inherit; + } + + .desktop-ssh-prompt footer { + display: flex; + justify-content: flex-end; + gap: 8px; + } + + .desktop-ssh-prompt button { + min-height: 30px; + border: 1px solid light-dark(oklch(0.72 0.012 255), oklch(0.43 0.012 255)); + border-radius: 6px; + background: light-dark(oklch(0.99 0.004 255), oklch(0.245 0.009 255)); + color: inherit; + padding: 4px 12px; + font: inherit; + } + + .desktop-ssh-prompt button[data-primary] { + border-color: #4d9cff; + background: #4d9cff; + color: white; + } + @keyframes desktop-startup-shine { 0%, 28% { transform: translateX(-115%); } 72%, 100% { transform: translateX(115%); } @@ -228,6 +289,7 @@
+
diff --git a/packages/frontend/src/components/AppSurfaces.test.tsx b/packages/frontend/src/components/AppSurfaces.test.tsx index 221402b6..4e282143 100644 --- a/packages/frontend/src/components/AppSurfaces.test.tsx +++ b/packages/frontend/src/components/AppSurfaces.test.tsx @@ -1610,6 +1610,7 @@ function controllerFor(surface: AppController["bootstrap"]["surface"]): TestCont createInvite: vi.fn(), previewJoinRequest: vi.fn(), remove: vi.fn(), + replacementImpact: vi.fn(), }, getMcpServerDetails: vi.fn(), getSkillDetails: vi.fn(), diff --git a/packages/frontend/src/components/settings/RemoteSection.tsx b/packages/frontend/src/components/settings/RemoteSection.tsx index add1036e..158e6b1b 100644 --- a/packages/frontend/src/components/settings/RemoteSection.tsx +++ b/packages/frontend/src/components/settings/RemoteSection.tsx @@ -1,13 +1,14 @@ import type { DeviceCollectionSnapshot, RemoteDeviceSummary } from "@openaide/app-server-client"; -import { Laptop, Monitor, Plus, QrCode, Smartphone, Wifi } from "lucide-react"; +import { Laptop, Monitor, Plus, QrCode, Server, Smartphone, Wifi } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { RemoteDeviceIntents } from "../../intents/remoteDeviceIntents"; -import { currentFrontendShell, type RemoteComputer, type RemoteComputersCapability } from "../../services/frontendShell"; +import { currentFrontendShell, type RemoteComputer, type RemoteComputersCapability, type SshComputerStatus } from "../../services/frontendShell"; import type { ConnectionComputer } from "../../services/connectionSettings"; import { PopupDialog } from "../Popup"; import { InviteDialog, JoinDialog } from "./devicePairingDialogs"; import type { PhoneConnection } from "./phoneConnection"; import { settingsInfo } from "./settingsInfo"; +import { AddSshComputerDialog, UpdateSshComputerDialog, useSshStatus } from "./sshComputers"; import { errorSentence, SettingsButton, SettingsChoice, SettingsConfirm, SettingsInlineError, SettingsNotice } from "./settingsKit"; import type { SettingsGroup, SettingsRow, SettingsSection } from "./settingsModel"; @@ -19,6 +20,9 @@ export type RemoteSectionProps = { type Dialog = | { kind: "pair" } + | { kind: "add-ssh" } + | { kind: "update-ssh" } + | { kind: "stop-ssh"; computer: RemoteComputer } | { kind: "forget"; computer: RemoteComputer } | { kind: "forget-phone"; computer: ConnectionComputer } | { kind: "invite" } @@ -32,13 +36,23 @@ type Dialog = export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectionProps): SettingsSection | undefined { const capability = currentFrontendShell()?.remoteComputers; const desktop = useRemoteComputers(capability); + const sshStatus = useSshStatus(capability?.ssh); + const [sshNotice, setSshNotice] = useState(); const [dialog, setDialog] = useState(); const close = () => setDialog(undefined); if (!capability && !phone && !deviceIntents) return undefined; + const ssh = capability?.ssh; const remoteName = capability?.current ?? (phone?.state?.remote ? phone.state.computer || "Remote computer" : undefined); const groups: SettingsGroup[] = []; - if (capability) groups.push(desktopComputersGroup(desktop, () => setDialog({ kind: "pair" }), (computer) => setDialog({ kind: "forget", computer }))); + if (capability) { + groups.push(desktopComputersGroup(desktop, { + sshStatus, + sshNotice, + open: setDialog, + canAddSsh: Boolean(ssh), + })); + } else if (phone) groups.push(phoneComputersGroup(phone, (computer) => setDialog({ kind: "forget-phone", computer }))); if (deviceIntents) { const here = phone ? "this phone" : "this computer"; @@ -55,6 +69,34 @@ export function useRemoteSection({ deviceIntents, devices, phone }: RemoteSectio overlay: dialog ? ( <> {dialog.kind === "pair" && capability ? : null} + {dialog.kind === "add-ssh" && ssh ? : null} + {dialog.kind === "update-ssh" && ssh && sshStatus ? ( + + ) : null} + {dialog.kind === "stop-ssh" && ssh ? ( + { + const outcome = await ssh.stop(dialog.computer.id); + setSshNotice(outcome === "notRunning" + ? `OpenAIDE was not kept running on ${dialog.computer.name}.` + : outcome === "stillRunning" + ? `OpenAIDE on ${dialog.computer.name} did not stop yet. Try again in a moment.` + : `OpenAIDE on ${dialog.computer.name} stops once no app is connected to it.`); + }} + pendingLabel="Stopping…" + title={`Stop OpenAIDE on ${dialog.computer.name}?`} + /> + ) : null} {dialog.kind === "forget" && capability ? ( void, - onForget: (computer: RemoteComputer) => void, -): SettingsGroup { +type DesktopComputersView = { + sshStatus: SshComputerStatus | undefined; + sshNotice: string | undefined; + open(dialog: Dialog): void; + canAddSsh: boolean; +}; + +function desktopComputersGroup(desktop: DesktopComputers, view: DesktopComputersView): SettingsGroup { const computers = desktop.computers ?? []; const remote = computers.some((computer) => computer.current); const busy = desktop.switching !== undefined; @@ -192,14 +237,19 @@ function desktopComputersGroup( rows.push({ id: `computer-${computer.id}`, label: computer.name, - description: "Paired computer", - keywords: "computer remote connect switch pc", - leading: