diff --git a/Cargo.lock b/Cargo.lock index 17df45d8bd..c9beec3434 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1421,6 +1421,7 @@ dependencies = [ "chrono", "dunce", "filetime", + "flate2", "fs2", "git2", "ignore", @@ -1434,6 +1435,7 @@ dependencies = [ "serde_yaml", "sha2", "similar", + "tar", "tempfile", "thiserror 2.0.19", "tokio", diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md new file mode 100644 index 0000000000..75b698b4ae --- /dev/null +++ b/docs/architecture/detached-task-dispatch.md @@ -0,0 +1,201 @@ +# Detached task dispatch + +Detached dispatch lets one BitFun process submit work to another BitFun process +without making the submitting process part of the execution topology. The +submitter can disconnect or exit after the target has durably acknowledged the +job. + +This document defines the product boundary, workspace delivery contract, and +transport invariants. It complements +[`remote-workspace-transport.md`](remote-workspace-transport.md) and +[`peer-device-mode.md`](peer-device-mode.md); neither of those features is a +dispatch data plane. + +## Ownership model + +There are three roles: + +- The **controller** selects a target, prepares an optional workspace snapshot, + submits a job, and observes it by cursor. +- The **target host** owns the job, worker process, local session, workspace + lease, event log, permission mailbox, and terminal state. +- A **transport adapter** moves the same narrow JSON protocol over SSH or an + end-to-end encrypted account-device RPC. + +The controller is never a runtime or filesystem proxy for a non-local job. It +stores only an outbound observer record under +`~/.bitfun/dispatch/outbound/`; it must not create the target session in the +controller's normal session store. A target session is an ordinary local +session on the target and can be resumed there. + +The Relay is an opaque router. Device requests, workspace chunks, and responses +are encrypted with the account master key before the Relay receives them. Relay +storage is not used for workspace contents. + +## Workspace delivery + +`workspacePath` in a submit request identifies a directory on the target. It +does not imply that similarly named directories on two machines are related. +Dispatch therefore supports two explicit delivery modes. + +### Existing target directory + +`existing` uses a directory that already exists on the target. Probe returns its +canonical path and Git facts before submit. BitFun never clones, fetches, +checks out, stashes, or rewrites that directory as part of dispatch. + +### One-shot exact snapshot + +`snapshot-exact` captures the controller workspace at submit time and materializes it +below: + +```text +~/.bitfun/dispatch/workspaces//current/ +``` + +The snapshot includes regular files and empty directories, including hidden and +ignored files, because the mode promises the workspace's current contents +rather than a Git checkout. The controller must show that this can include +`.env`, local credentials, build output, and other ignored data and require +explicit confirmation. + +The following entries are not silently copied: + +- every entry named `.git`, because worktree pointers, nested object stores, + hooks, and credentials are repository metadata rather than workspace input; +- symbolic links, to avoid following data outside the selected root or creating + target-dependent aliases; +- sockets, devices, FIFOs, and other special files; +- paths that cannot be represented as portable UTF-8 relative paths. + +Encountering any unsupported entry fails packaging and names the entry. A +successful manifest therefore describes every delivered entry; there is no +best-effort omission. + +The archive and manifest are bounded by explicit file-count, per-file, and total +byte limits: 100,000 files, 100,000 directories, 256 MiB per file, 2 GiB +uncompressed, and 1 GiB compressed. The controller computes a SHA-256 digest +and sends immutable upload metadata. The target writes to an owner-only staging +file, rejects offset mismatches, verifies size and digest, validates every +archive path and entry type, extracts into a new staging directory, and +atomically publishes `current`. Materialization runs as a detached target +process; `workspace-commit` starts or polls it, so a single SSH or Relay RPC +timeout cannot kill a large extraction. A repeated begin/chunk/commit for the +same job and digest is idempotent; a different digest for the same job is a +conflict. + +Packaging is one deterministic traversal, not an operating-system filesystem +snapshot. A file that changes size or modification time while it is read makes +the package fail, but coordinated edits across multiple files can still span +the traversal interval. Callers that require an application-consistent source +must quiesce the source or select a filesystem snapshot as the source path. + +SSH transports the archive with SFTP after `workspace-begin`. Account-device +RPC uses bounded base64 chunks inside the existing end-to-end encrypted +`HostInvoke` envelope. Neither transport puts source bytes in command-line +arguments, process listings, logs, or the outbound observer record. + +## Synchronization semantics + +A snapshot is an immutable input boundary, not a live shared folder: + +1. The controller captures version `S0`. +2. The target verifies and publishes `S0`. +3. The target becomes authoritative for all writes during the job. +4. Observers pull target events, permissions, and terminal state by cursor. + +The controller does not mirror local edits made after `S0`, and target writes +are not merged automatically into a possibly changed controller workspace. +Continuous bidirectional synchronization would require conflict detection, +delete semantics, editor coordination, and a controller that remains online, +which contradicts detached execution. + +Returning code is a separate, explicit result operation. A future result bundle +may expose an artifact or patch derived from `S0` and the terminal target tree; +applying it must remain a user-confirmed local operation. Until that operation +exists, the UI states that snapshot results remain on the target and shows the +managed target path. + +## Protocol + +The target CLI owns the transport-independent protocol and durable store. +Public job verbs are: + +| Verb | Purpose | +| --- | --- | +| `probe` | Negotiate version/capabilities and inspect an optional target path. | +| `submit` | Durably create an idempotent job and detach its worker. | +| `status` | Read target state, event pages, completeness facts, and pending permissions. | +| `cancel` | Persist cancellation intent and stop the authenticated worker process group. | +| `list` | List durable target jobs. | +| `answer` | Resolve one persisted permission request for `remote` approval policy. | +| `append` | Queue an idempotent steering message for the active turn. | + +Workspace upload uses the internal `workspace-begin`, `workspace-chunk`, and +`workspace-commit` verbs. They are target data-plane operations and are not +normal product or Peer Device Mode commands. + +Account-device transport wraps target verbs in names reserved for detached +dispatch, such as `dispatch_target_submit`. They are handled before the +attach-shaped Peer Host bridge and never acquire an attached-controller lease. +Conversely, controller-side commands such as `dispatch_submit` remain +local-only in every Peer Device Mode deny table. Disconnecting the last Peer +controller must not cancel or hide a detached dispatch job. + +An account target must have a compatible `bitfun dispatch` runner. A CLI daemon +already satisfies this. The Desktop account host delegates to an installed +`bitfun` binary (including a package-manager symlink); if none is available, +probe reports the missing runner and submission remains disabled rather than +falling back to local execution. + +## Event and observer contract + +The target event log is append-only within a retained window. A status response +reports: + +- the next byte cursor; +- whether the requested cursor was reset; +- whether older history was truncated; +- how many oversized events were replaced by visible omission markers; +- whether the returned transcript can be considered complete. + +Rotation and oversized events must never be represented as a complete +transcript. Multiple controllers may observe the same job because cursors are +per observer and the target has no controller lease. Explicitly listing jobs +for a selected target adopts observer-only routing records on that controller; +it does not copy sessions or acquire workspace/runtime ownership. + +Target and outbound records are retained for 30 days after terminal state. +Garbage collection never removes queued or running jobs. Removing a terminal +snapshot also removes only the managed directory bound to that job; an +arbitrary user-supplied target directory is never a cleanup target. + +## Approval and supervision + +Every submit requires an explicit policy: + +- `auto` uses the shared Runtime auto-approval metadata. +- `reject-and-report` fails closed when confirmation is required. +- `remote` disables inherited auto-approval while keeping user input + available. The worker persists the safe presentation DTO, status exposes it, + and `answer` records a user-sourced reply before execution resumes. + +Permission responses and appended messages are target-owned mailboxes. They are +idempotent across controller retries and do not depend on the controller that +originally submitted the job. + +## Failure rules + +- A missing or offline target fails submit; the Relay does not queue jobs. +- A lost submit response leaves `submission_unknown`; status or an idempotent + retry reconciles the target's durable truth. +- A live PID that no longer matches the exact worker command is never signaled + and settles the job failed instead of leaving an orphaned non-terminal job. +- A target restart after the Runtime accepted a turn does not replay the prompt, + because replay could duplicate tool side effects. The native target session + remains available for manual resume. +- Prompt and event pages remain below the smallest host transport envelope. +- Workspace digest, archive traversal, unsupported entry, or size failures + happen before job submission and leave no executable target workspace. + Detached materialization failures are persisted and returned by later commit + polls instead of being hidden in a discarded child-process stderr stream. diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index c16aa6bee2..2599212d95 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -24,7 +24,7 @@ bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } -bitfun-services-core = { path = "../../crates/services/services-core", default-features = false, features = ["runtime-ownership"] } +bitfun-services-core = { path = "../../crates/services/services-core", default-features = false, features = ["dispatch-workspace", "runtime-ownership"] } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false, features = ["external-sources"] } diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 292ab1150b..c88af90671 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -116,6 +116,44 @@ describes that later retry; it does not mean the current command retries automat live probes for Network, Git, or MCP integrations that are currently represented by compatibility registrations. +## Detached task dispatch + +`bitfun dispatch` is the target-side, machine-readable interface used by +Desktop, Server Host, SSH, and encrypted account-device RPC. Requests are JSON +on stdin and responses are one JSON value on stdout: + +```bash +printf '%s' '{"workspacePath":"/srv/app"}' | bitfun dispatch probe +printf '%s' '{"jobId":"job-123","cursor":0}' | bitfun dispatch status +printf '%s' '{}' | bitfun dispatch list +printf '%s' '{"jobId":"job-123"}' | bitfun dispatch cancel +``` + +`submit` additionally requires `protocolVersion`, `sessionId`, +`workspacePath`, `agentType`, `prompt`, and an explicit `approvalPolicy`: +`auto`, `reject-and-report`, or `remote`. With `remote`, `status` returns +`pendingPermissions`; answer one with `dispatch answer`. `dispatch append` +adds a steering message to a queued or running job. Call `probe` first and +honor its protocol version and capability list rather than assuming that +different BitFun releases are compatible. + +Jobs and their event logs live under `~/.bitfun/dispatch/jobs/`. The detached +worker and target-side session remain authoritative after the submitting +client disconnects. Each target workspace is serialized so two jobs do not +modify it concurrently. Terminal jobs and managed snapshots are retained for +30 days. Event history is bounded; `status` reports cursor resets, truncation, +and omitted-event counts so clients never mistake a partial transcript for a +complete one. + +Workspace transfer is a separate controller operation. An exact snapshot +includes hidden and ignored regular files, excludes `.git`, rejects links and +special files, verifies an archive and manifest digest, and publishes into the +target-managed dispatch directory atomically. It is a one-time input snapshot, +not live or bidirectional synchronization; target changes are not copied back +automatically. See +[`docs/architecture/detached-task-dispatch.md`](../../../docs/architecture/detached-task-dispatch.md) +for the ownership and transport contract. + ## Always-on account device host (daemon) Account multi-device access requires the target device to hold a live relay connection. On a diff --git a/src/apps/cli/src/dispatch/mod.rs b/src/apps/cli/src/dispatch/mod.rs index ea026582c3..9fa331ff7f 100644 --- a/src/apps/cli/src/dispatch/mod.rs +++ b/src/apps/cli/src/dispatch/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod protocol; mod runner; mod store; mod worker; +mod workspace; use std::path::{Path, PathBuf}; use std::process::Command; @@ -13,10 +14,12 @@ use bitfun_core::service::config::{AuthConfig, GlobalConfig}; use serde::de::DeserializeOwned; use protocol::{ + DispatchAnswerRequest, DispatchAnswerResponse, DispatchAppendRequest, DispatchAppendResponse, DispatchCancelRequest, DispatchCancelResponse, DispatchJobListEntry, DispatchJobState, DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, - DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceProbe, - DISPATCH_PROTOCOL_VERSION, + DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse, + DispatchWorkspaceBeginRequest, DispatchWorkspaceChunkRequest, DispatchWorkspaceCommitRequest, + DispatchWorkspaceProbe, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES, }; use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore}; @@ -52,6 +55,22 @@ pub(crate) async fn run_dispatch_verb( let _: DispatchListRequest = parse(input)?; serde_json::to_value(list()?).context("encode dispatch job list") } + "answer" => { + serde_json::to_value(answer(parse(input)?)?).context("encode permission answer") + } + "append" => serde_json::to_value(append(parse(input)?)?).context("encode appended message"), + "workspace-begin" => serde_json::to_value(workspace::begin(parse::< + DispatchWorkspaceBeginRequest, + >(input)?)?) + .context("encode workspace begin response"), + "workspace-chunk" => serde_json::to_value(workspace::chunk(parse::< + DispatchWorkspaceChunkRequest, + >(input)?)?) + .context("encode workspace chunk response"), + "workspace-commit" => serde_json::to_value(workspace::commit(parse::< + DispatchWorkspaceCommitRequest, + >(input)?)?) + .context("encode workspace commit response"), _ => bail!("unsupported dispatch verb: {verb}"), } } @@ -60,6 +79,10 @@ pub(crate) async fn run_worker(job_id: String) -> Result<()> { worker::run(job_id).await } +pub(crate) fn run_workspace_materializer(job_id: String) -> Result<()> { + workspace::materialize(job_id) +} + async fn probe(request: DispatchProbeRequest) -> Result { let readiness = inspect_model_readiness().await?; let workspace = request @@ -73,7 +96,12 @@ async fn probe(request: DispatchProbeRequest) -> Result { "workspace_serialization".to_string(), "approval_auto".to_string(), "approval_reject_and_report".to_string(), + "approval_remote".to_string(), "frontend_event_projection".to_string(), + "append_message".to_string(), + "event_log_completeness".to_string(), + "workspace_snapshot_exact".to_string(), + "workspace_snapshot_chunked".to_string(), ]; if runner::is_supported() { capabilities.push("detached_worker".to_string()); @@ -169,16 +197,69 @@ fn status(request: DispatchStatusRequest) -> Result { let store = DispatchStore::open_default()?; let state = reconcile_worker_liveness(&store, &request.job_id)?; let page = store.read_events(&request.job_id, request.cursor)?; + if state.state.is_terminal() { + store.clear_pending_permissions(&request.job_id); + } + let pending_permissions = if state.state.is_terminal() { + Vec::new() + } else { + store.list_pending_permissions(&request.job_id)? + }; Ok(DispatchStatusResponse { state: state.state, cursor: page.cursor, events: page.events, - pending_permissions: Vec::new(), + pending_permissions, cursor_reset: page.cursor_reset, + history_truncated: page.history_truncated, + event_log_complete: !page.history_truncated && page.omitted_event_count == 0, + omitted_event_count: page.omitted_event_count, last_error: state.last_error, }) } +fn answer(request: DispatchAnswerRequest) -> Result { + if request.request_id.trim().is_empty() || request.request_id.len() > 512 { + bail!("dispatch permission requestId is invalid"); + } + if matches!( + &request.reply, + bitfun_agent_runtime::sdk::PermissionReply::Reject { + feedback: Some(feedback) + } if feedback.len() > MAX_DISPATCH_TEXT_BYTES + ) { + bail!("dispatch permission feedback exceeds the 32 KiB request limit"); + } + let store = DispatchStore::open_default()?; + let job = store.load_job(&request.job_id)?; + if job.request.approval_policy != protocol::DispatchApprovalPolicy::Remote { + bail!("dispatch job does not use remote approval policy"); + } + let resolved = + store.save_permission_answer(&request.job_id, &request.request_id, request.reply)?; + Ok(DispatchAnswerResponse { resolved }) +} + +fn append(request: DispatchAppendRequest) -> Result { + if request.content.trim().is_empty() { + bail!("dispatch appended message cannot be empty"); + } + let total_bytes = request + .content + .len() + .saturating_add(request.display_content.as_ref().map_or(0, String::len)); + if total_bytes > MAX_DISPATCH_TEXT_BYTES { + bail!("dispatch appended message exceeds the 32 KiB request limit"); + } + let message_id = request.message_id.clone(); + let store = DispatchStore::open_default()?; + let accepted = store.enqueue_append_message(request)?; + Ok(DispatchAppendResponse { + accepted, + message_id, + }) +} + fn cancel(request: DispatchCancelRequest) -> Result { let store = DispatchStore::open_default()?; cancel_in_store(&store, request, runner::terminate_worker) @@ -188,6 +269,22 @@ fn cancel_in_store( store: &DispatchStore, request: DispatchCancelRequest, terminate: impl FnOnce(u32, &str) -> Result, +) -> Result { + cancel_in_store_with_process_checks( + store, + request, + runner::process_alive, + runner::worker_process_alive, + terminate, + ) +} + +fn cancel_in_store_with_process_checks( + store: &DispatchStore, + request: DispatchCancelRequest, + process_alive: impl Fn(u32) -> bool, + worker_process_alive: impl Fn(u32, &str) -> bool, + terminate: impl FnOnce(u32, &str) -> Result, ) -> Result { let before = store.request_cancel(&request.job_id)?; if before.state.is_terminal() { @@ -197,6 +294,23 @@ fn cancel_in_store( } if let Some(pid) = store.read_pid(&request.job_id)? { + if process_alive(pid) && !worker_process_alive(pid, &request.job_id) { + let message = format!( + "dispatch worker pid {pid} no longer matches job '{}'", + request.job_id + ); + let (failed, _) = store.mark_state( + &request.job_id, + DispatchJobState::Failed, + before.turn_id.as_deref(), + Some(message.clone()), + )?; + store.remove_pid(&request.job_id); + store.clear_preparing(&request.job_id); + store.clear_pending_permissions(&request.job_id); + debug_assert_eq!(failed.state, DispatchJobState::Failed); + bail!("{message}; the unrelated process was not signalled"); + } if let Err(error) = terminate(pid, &request.job_id) { let detail = format!("{error:#}"); let _ = store.record_nonterminal_error(&request.job_id, &detail); @@ -221,6 +335,7 @@ fn cancel_in_store( )?; store.clear_preparing(&request.job_id); store.remove_pid(&request.job_id); + store.clear_pending_permissions(&request.job_id); Ok(DispatchCancelResponse { cancelled: cancelled_state.state == DispatchJobState::Cancelled, }) @@ -255,13 +370,22 @@ fn reconcile_worker_liveness_with_spawn( if runner::worker_process_alive(pid, job_id) { return Ok(state); } - // A live PID with the wrong command may be a recycled process. Never - // infer cancellation/failure from it or signal it. + // A live PID with the wrong command is a recycled or replaced process. + // Never signal it, but do settle the orphaned job instead of leaving an + // unobservable non-terminal state forever. if runner::process_alive(pid) { let message = format!("dispatch worker pid {pid} is live but does not match job '{job_id}'"); - store.record_nonterminal_error(job_id, &message)?; - return store.load_state(job_id); + let (reconciled, _) = store.mark_state( + job_id, + DispatchJobState::Failed, + state.turn_id.as_deref(), + Some(message), + )?; + store.remove_pid(job_id); + store.clear_preparing(job_id); + store.clear_pending_permissions(job_id); + return Ok(reconciled); } if runner::worker_process_group_alive(pid) { // The PID marker authenticates only the leader. Once that process @@ -279,6 +403,7 @@ fn reconcile_worker_liveness_with_spawn( )?; store.remove_pid(job_id); store.clear_preparing(job_id); + store.clear_pending_permissions(job_id); return Ok(reconciled); } } @@ -306,6 +431,7 @@ fn reconcile_worker_liveness_with_spawn( let reconciled = store.settle_exited_worker(job_id)?; store.remove_pid(job_id); store.clear_preparing(job_id); + store.clear_pending_permissions(job_id); Ok(reconciled) } @@ -519,8 +645,8 @@ fn validate_submit_request(request: &DispatchSubmitRequest) -> Result<()> { if request.prompt.trim().is_empty() { bail!("dispatch prompt cannot be empty"); } - if request.prompt.len() > 4 * 1024 * 1024 { - bail!("dispatch prompt exceeds the 4 MiB request limit"); + if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { + bail!("dispatch prompt exceeds the 32 KiB request limit"); } Ok(()) } @@ -615,7 +741,7 @@ mod tests { } #[test] - fn cancel_identity_mismatch_stays_retryable_and_non_terminal() { + fn cancel_identity_mismatch_fails_orphaned_job_without_signalling_process() { let dir = tempfile::tempdir().expect("tempdir"); let store = DispatchStore::open(dir.path().join("dispatch")).expect("store"); store @@ -633,9 +759,9 @@ mod tests { runner::terminate_worker, ) .expect_err("an unrelated live process must not be treated as cancelled"); - assert!(error.to_string().contains("does not match")); + assert!(error.to_string().contains("no longer matches")); let state = store.load_state("job-no-match").expect("state"); - assert_eq!(state.state, DispatchJobState::Queued); + assert_eq!(state.state, DispatchJobState::Failed); assert!(state.cancel_requested()); } @@ -648,13 +774,15 @@ mod tests { .expect("create job"); store .write_pid("job-signal-failure", 42) - .expect("record fake pid"); + .expect("record injected pid"); - let error = cancel_in_store( + let error = cancel_in_store_with_process_checks( &store, DispatchCancelRequest { job_id: "job-signal-failure".to_string(), }, + |_pid| true, + |_pid, _job_id| true, |_pid, _job_id| bail!("injected signal failure"), ) .expect_err("signal failure must remain visible"); @@ -672,13 +800,17 @@ mod tests { store .create_job(test_request("job-stopped"), "Task".to_string()) .expect("create job"); - store.write_pid("job-stopped", 42).expect("record fake pid"); + store + .write_pid("job-stopped", 42) + .expect("record injected pid"); - let response = cancel_in_store( + let response = cancel_in_store_with_process_checks( &store, DispatchCancelRequest { job_id: "job-stopped".to_string(), }, + |_pid| false, + |_pid, _job_id| panic!("an absent process must not undergo identity inspection"), |_pid, _job_id| Ok(false), ) .expect("confirmed stopped worker"); diff --git a/src/apps/cli/src/dispatch/permissions.rs b/src/apps/cli/src/dispatch/permissions.rs index 51ef063c02..b2cdca3211 100644 --- a/src/apps/cli/src/dispatch/permissions.rs +++ b/src/apps/cli/src/dispatch/permissions.rs @@ -11,6 +11,7 @@ pub(crate) const fn cli_policy(policy: DispatchApprovalPolicy) -> CliApprovalPol match policy { DispatchApprovalPolicy::Auto => CliApprovalPolicy::Auto, DispatchApprovalPolicy::RejectAndReport => CliApprovalPolicy::Reject, + DispatchApprovalPolicy::Remote => CliApprovalPolicy::DisableAuto, } } @@ -50,5 +51,15 @@ mod tests { approval_metadata(CliApprovalPolicy::Reject), "dispatch must not invent a second approval mechanism" ); + + let remote = metadata(DispatchApprovalPolicy::Remote); + assert!( + remote.get(USER_INPUT_AVAILABLE_CONTEXT_KEY).is_none(), + "remote supervision keeps the shared user-input channel available" + ); + assert_eq!( + remote.get(AUTO_APPROVE_ASK_CONTEXT_KEY), + Some(&Value::Bool(false)) + ); } } diff --git a/src/apps/cli/src/dispatch/protocol.rs b/src/apps/cli/src/dispatch/protocol.rs index f61a1c4f57..e57363c492 100644 --- a/src/apps/cli/src/dispatch/protocol.rs +++ b/src/apps/cli/src/dispatch/protocol.rs @@ -1,6 +1,10 @@ use serde::{Deserialize, Serialize}; -pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 1; +use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; +use bitfun_services_core::dispatch_workspace::WorkspaceSnapshotMetadata; + +pub(crate) const DISPATCH_PROTOCOL_VERSION: u32 = 2; +pub(crate) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -49,6 +53,7 @@ pub(crate) struct DispatchProbeResponse { pub(crate) enum DispatchApprovalPolicy { Auto, RejectAndReport, + Remote, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -100,6 +105,87 @@ pub(crate) struct DispatchStatusRequest { pub(crate) cursor: u64, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchAnswerRequest { + pub(crate) job_id: String, + pub(crate) request_id: String, + #[serde(flatten)] + pub(crate) reply: PermissionReply, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchAnswerResponse { + pub(crate) resolved: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchAppendRequest { + pub(crate) job_id: String, + pub(crate) message_id: String, + pub(crate) content: String, + #[serde(default)] + pub(crate) display_content: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchAppendResponse { + pub(crate) accepted: bool, + pub(crate) message_id: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchWorkspaceBeginRequest { + pub(crate) protocol_version: u32, + pub(crate) job_id: String, + pub(crate) metadata: WorkspaceSnapshotMetadata, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceBeginResponse { + pub(crate) accepted: bool, + pub(crate) offset: u64, + pub(crate) upload_path: String, + pub(crate) committed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) workspace_path: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchWorkspaceChunkRequest { + pub(crate) job_id: String, + pub(crate) offset: u64, + pub(crate) data_base64: String, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceChunkResponse { + pub(crate) accepted: bool, + pub(crate) offset: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DispatchWorkspaceCommitRequest { + pub(crate) job_id: String, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DispatchWorkspaceCommitResponse { + pub(crate) committed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) workspace_path: Option, + pub(crate) metadata: WorkspaceSnapshotMetadata, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct DispatchCancelRequest { @@ -156,6 +242,30 @@ impl DispatchEvent { } } + pub(crate) fn permission_pending(request_id: &str) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "permissionPending".to_string(), + details: serde_json::json!({ "requestId": request_id }), + } + } + + pub(crate) fn permission_resolved(request_id: &str) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "permissionResolved".to_string(), + details: serde_json::json!({ "requestId": request_id }), + } + } + + pub(crate) fn message_appended(message_id: &str) -> Self { + Self::Audit { + timestamp: chrono::Utc::now().to_rfc3339(), + action: "messageAppended".to_string(), + details: serde_json::json!({ "messageId": message_id }), + } + } + pub(crate) fn oversized_event_omitted(encoded_bytes: usize, max_bytes: usize) -> Self { Self::Audit { timestamp: chrono::Utc::now().to_rfc3339(), @@ -209,8 +319,11 @@ pub(crate) struct DispatchStatusResponse { pub(crate) state: DispatchJobState, pub(crate) cursor: u64, pub(crate) events: Vec, - pub(crate) pending_permissions: Vec, + pub(crate) pending_permissions: Vec, pub(crate) cursor_reset: bool, + pub(crate) history_truncated: bool, + pub(crate) event_log_complete: bool, + pub(crate) omitted_event_count: u64, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) last_error: Option, } @@ -231,6 +344,10 @@ pub(crate) struct DispatchJobListEntry { pub(crate) started_at: Option, pub(crate) workspace_path: String, pub(crate) title: String, + pub(crate) agent_type: String, + pub(crate) approval_policy: DispatchApprovalPolicy, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) model: Option, } #[cfg(test)] diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index d20da54b45..24e4904f1c 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -12,6 +12,22 @@ pub(crate) fn is_supported() -> bool { } pub(crate) fn spawn(store: &DispatchStore, job_id: &str) -> Result { + let result = spawn_detached_action("__run", job_id, "dispatch worker"); + if result.is_err() { + store.clear_preparing(job_id); + } + result +} + +pub(crate) fn spawn_workspace_materializer(job_id: &str) -> Result { + spawn_detached_action( + "__workspace_materialize", + job_id, + "dispatch workspace materializer", + ) +} + +fn spawn_detached_action(action: &str, job_id: &str, description: &str) -> Result { if !is_supported() { bail!("dispatch detached workers are supported only on Linux and macOS"); } @@ -20,7 +36,7 @@ pub(crate) fn spawn(store: &DispatchStore, job_id: &str) -> Result { let mut command = bitfun_services_core::process_manager::create_command(executable); command .arg("dispatch") - .arg("__run") + .arg(action) .arg("--job") .arg(job_id) .stdin(Stdio::null()) @@ -31,17 +47,12 @@ pub(crate) fn spawn(store: &DispatchStore, job_id: &str) -> Result { } configure_detached_process(&mut command); - let child = match command.spawn().context("start detached dispatch worker") { - Ok(child) => child, - Err(error) => { - store.clear_preparing(job_id); - return Err(error); - } - }; + let child = command + .spawn() + .with_context(|| format!("start detached {description}"))?; let pid = child.id(); - // The winning child records its own PID after acquiring the per-job worker - // lease. This closes the parent-crash window without letting a duplicate - // retry overwrite the active worker's identity. + // The action acquires its durable job or upload lock in the child. The + // parent PID is informational until that target-owned claim succeeds. Ok(pid) } diff --git a/src/apps/cli/src/dispatch/store.rs b/src/apps/cli/src/dispatch/store.rs index 79e79508de..c4f537d142 100644 --- a/src/apps/cli/src/dispatch/store.rs +++ b/src/apps/cli/src/dispatch/store.rs @@ -3,27 +3,42 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; +use bitfun_agent_runtime::sdk::{PermissionReply, PermissionRequest}; use serde::{Deserialize, Serialize}; use super::protocol::{ - DispatchEvent, DispatchJobListEntry, DispatchJobState, DispatchSubmitRequest, - DISPATCH_PROTOCOL_VERSION, + DispatchAppendRequest, DispatchEvent, DispatchJobListEntry, DispatchJobState, + DispatchSubmitRequest, DISPATCH_PROTOCOL_VERSION, }; const JOB_RECORD_FILE: &str = "job.json"; const STATE_FILE: &str = "state"; const EVENTS_FILE: &str = "events.ndjson"; +const EVENTS_METADATA_FILE: &str = "events.meta.json"; const EVENTS_LOCK_FILE: &str = ".events.lock"; const PID_FILE: &str = "job.pid"; const PREPARING_FILE: &str = "preparing"; const SPAWN_LOCK_FILE: &str = ".spawn.lock"; const WORKER_LOCK_FILE: &str = ".worker.lock"; +const PENDING_PERMISSIONS_DIR: &str = "permissions/pending"; +const PERMISSION_ANSWERS_DIR: &str = "permissions/answers"; +const RESOLVED_PERMISSIONS_DIR: &str = "permissions/resolved"; +const PENDING_MESSAGES_DIR: &str = "messages/pending"; +const CONSUMED_MESSAGES_DIR: &str = "messages/consumed"; const DEFAULT_MAX_EVENTS_BYTES: u64 = 64 * 1024 * 1024; // Keep a single projected event and a complete status page comfortably below // the server transport's 256 KiB WebSocket frame ceiling. const MAX_EVENT_BYTES: usize = 96 * 1024; const MAX_STATUS_PAGE_BYTES: u64 = 128 * 1024; const MAX_STATUS_PAGE_EVENTS: usize = 512; +const MAX_PENDING_PERMISSION_BYTES: u64 = 48 * 1024; +const MAX_PENDING_PERMISSIONS_BYTES: u64 = 64 * 1024; +const MAX_PENDING_PERMISSIONS: usize = 64; +const MAX_STATE_MESSAGE_BYTES: usize = 16 * 1024; +const TERMINAL_JOB_RETENTION_DAYS: i64 = 30; +const RETENTION_GC_INTERVAL_SECONDS: u64 = 24 * 60 * 60; +const RETENTION_GC_MARKER: &str = ".retention-gc"; +const RETENTION_GC_LOCK: &str = ".retention-gc.lock"; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -31,6 +46,15 @@ struct EventLogHeader { cursor_base: u64, } +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct EventLogMetadata { + #[serde(default)] + history_truncated: bool, + #[serde(default)] + omitted_event_count: u64, +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub(crate) struct DispatchJobRecord { @@ -85,6 +109,23 @@ pub(crate) struct EventPage { pub(crate) cursor: u64, pub(crate) events: Vec, pub(crate) cursor_reset: bool, + pub(crate) history_truncated: bool, + pub(crate) omitted_event_count: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StoredPermissionAnswer { + pub(crate) request_id: String, + pub(crate) reply: PermissionReply, + pub(crate) answered_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct StoredAppendMessage { + request: DispatchAppendRequest, + created_at: String, } #[derive(Clone, Debug)] @@ -97,7 +138,11 @@ impl DispatchStore { pub(crate) fn open_default() -> Result { let path_manager = bitfun_core::infrastructure::PathManager::new() .map_err(|error| anyhow!("resolve BitFun storage root: {error}"))?; - Self::open(path_manager.bitfun_home_dir().join("dispatch")) + let store = Self::open(path_manager.bitfun_home_dir().join("dispatch"))?; + if let Err(error) = store.maybe_collect_expired_terminal_jobs() { + tracing::warn!("Dispatch retention cleanup failed: {error:#}"); + } + Ok(store) } pub(crate) fn open(root: PathBuf) -> Result { @@ -168,6 +213,10 @@ impl DispatchStore { atomic_write_json(&job_dir.join(STATE_FILE), &state)?; ensure_private_file(&job_dir.join(EVENTS_LOCK_FILE))?; atomic_write_event_log(&job_dir.join(EVENTS_FILE), 0, None)?; + atomic_write_json( + &job_dir.join(EVENTS_METADATA_FILE), + &EventLogMetadata::default(), + )?; self.append_event_unlocked( &job_dir, &DispatchEvent::approval_policy_selected(record.request.approval_policy), @@ -258,6 +307,7 @@ impl DispatchStore { turn_id: Option<&str>, message: Option, ) -> Result<(DispatchStateRecord, bool)> { + let message = message.map(|message| truncate_utf8_bytes(&message, MAX_STATE_MESSAGE_BYTES)); let job_dir = self.existing_job_dir(job_id)?; let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; let mut current = self.load_state_unlocked(&job_dir)?; @@ -314,7 +364,7 @@ impl DispatchStore { let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; let mut state = self.load_state_unlocked(&job_dir)?; if !state.state.is_terminal() { - state.last_error = Some(error.to_string()); + state.last_error = Some(truncate_utf8_bytes(error, MAX_STATE_MESSAGE_BYTES)); atomic_write_json(&job_dir.join(STATE_FILE), &state)?; } Ok(()) @@ -423,6 +473,8 @@ impl DispatchStore { set_private_file_permissions(&path)?; let len = file.metadata()?.len(); let (header, data_start) = read_event_log_header(&mut file, &path)?; + let mut metadata = load_event_log_metadata(&job_dir.join(EVENTS_METADATA_FILE)); + metadata.history_truncated |= header.cursor_base > 0; let data_len = len.saturating_sub(data_start); let retained_end = header.cursor_base.saturating_add(data_len); let (start, cursor_reset) = if cursor < header.cursor_base || cursor > retained_end { @@ -460,9 +512,222 @@ impl DispatchStore { .saturating_add(consumed as u64), events, cursor_reset, + history_truncated: metadata.history_truncated, + omitted_event_count: metadata.omitted_event_count, }) } + pub(crate) fn save_pending_permission( + &self, + job_id: &str, + request: &PermissionRequest, + ) -> Result<()> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let state = self.load_state_unlocked(&job_dir)?; + if state.state.is_terminal() { + bail!("dispatch job is already terminal"); + } + let path = mailbox_path(&job_dir, PENDING_PERMISSIONS_DIR, &request.request_id)?; + let encoded_bytes = serde_json::to_vec_pretty(request) + .context("encode dispatch permission request")? + .len() + .saturating_add(1) as u64; + if encoded_bytes > MAX_PENDING_PERMISSION_BYTES { + bail!("dispatch permission request exceeds the 48 KiB safety limit"); + } + match fs::symlink_metadata(&path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let (count, bytes) = mailbox_usage(&job_dir.join(PENDING_PERMISSIONS_DIR))?; + if count >= MAX_PENDING_PERMISSIONS + || bytes.saturating_add(encoded_bytes) > MAX_PENDING_PERMISSIONS_BYTES + { + bail!("dispatch pending permission mailbox exceeds the status safety limit"); + } + } + Err(error) => return Err(error.into()), + } + write_json_if_absent_or_equal(&path, request) + } + + pub(crate) fn list_pending_permissions(&self, job_id: &str) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let mut requests = + read_json_directory::(&job_dir.join(PENDING_PERMISSIONS_DIR))?; + requests.sort_by(|left, right| { + left.round_id + .cmp(&right.round_id) + .then_with(|| left.order.cmp(&right.order)) + .then_with(|| left.request_id.cmp(&right.request_id)) + }); + Ok(requests) + } + + /// Persist a controller answer. `true` means the answer is durably queued + /// or was already resolved with the same request id. + pub(crate) fn save_permission_answer( + &self, + job_id: &str, + request_id: &str, + reply: PermissionReply, + ) -> Result { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let resolved_path = mailbox_path(&job_dir, RESOLVED_PERMISSIONS_DIR, request_id)?; + if let Some(existing) = + read_optional_regular_json::(&resolved_path)? + { + ensure_permission_answer_matches(&existing, request_id, &reply)?; + return Ok(true); + } + let state = self.load_state_unlocked(&job_dir)?; + if state.state.is_terminal() { + bail!("dispatch job is already terminal"); + } + let pending_path = mailbox_path(&job_dir, PENDING_PERMISSIONS_DIR, request_id)?; + let pending = read_optional_regular_json::(&pending_path)? + .ok_or_else(|| anyhow!("dispatch permission request not found: {request_id}"))?; + if pending.request_id != request_id { + bail!("dispatch permission mailbox identity mismatch"); + } + let answer_path = mailbox_path(&job_dir, PERMISSION_ANSWERS_DIR, request_id)?; + if let Some(existing) = read_optional_regular_json::(&answer_path)? + { + ensure_permission_answer_matches(&existing, request_id, &reply)?; + return Ok(true); + } + let answer = StoredPermissionAnswer { + request_id: request_id.to_string(), + reply, + answered_at: chrono::Utc::now().to_rfc3339(), + }; + write_json_if_absent_or_equal(&answer_path, &answer)?; + Ok(true) + } + + pub(crate) fn list_permission_answers( + &self, + job_id: &str, + ) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let mut answers = + read_json_directory::(&job_dir.join(PERMISSION_ANSWERS_DIR))?; + answers.sort_by(|left, right| { + left.answered_at + .cmp(&right.answered_at) + .then_with(|| left.request_id.cmp(&right.request_id)) + }); + Ok(answers) + } + + pub(crate) fn mark_permission_resolved( + &self, + job_id: &str, + answer: &StoredPermissionAnswer, + ) -> Result<()> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let resolved_path = mailbox_path(&job_dir, RESOLVED_PERMISSIONS_DIR, &answer.request_id)?; + write_json_if_absent_or_equal(&resolved_path, answer)?; + remove_file_if_present(&mailbox_path( + &job_dir, + PENDING_PERMISSIONS_DIR, + &answer.request_id, + )?); + remove_file_if_present(&mailbox_path( + &job_dir, + PERMISSION_ANSWERS_DIR, + &answer.request_id, + )?); + Ok(()) + } + + pub(crate) fn clear_pending_permissions(&self, job_id: &str) { + let Ok(job_dir) = self.job_dir(job_id) else { + return; + }; + for directory in [PENDING_PERMISSIONS_DIR, PERMISSION_ANSWERS_DIR] { + if let Err(error) = fs::remove_dir_all(job_dir.join(directory)) { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + "Failed to clear dispatch permission mailbox for {}: {error}", + job_id + ); + } + } + } + } + + pub(crate) fn enqueue_append_message(&self, request: DispatchAppendRequest) -> Result { + validate_id("messageId", &request.message_id)?; + let job_dir = self.existing_job_dir(&request.job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let consumed_path = mailbox_path(&job_dir, CONSUMED_MESSAGES_DIR, &request.message_id)?; + if let Some(existing) = read_optional_regular_json::(&consumed_path)? + { + if existing != request { + bail!("dispatch messageId is already bound to different content"); + } + return Ok(true); + } + let state = self.load_state_unlocked(&job_dir)?; + if state.state.is_terminal() { + bail!("cannot append to a terminal dispatch job"); + } + if !matches!( + state.state, + DispatchJobState::Queued | DispatchJobState::Running + ) { + bail!("dispatch job is not accepting appended messages"); + } + let stored = StoredAppendMessage { + request: request.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }; + let path = mailbox_path(&job_dir, PENDING_MESSAGES_DIR, &request.message_id)?; + if let Some(existing) = read_optional_regular_json::(&path)? { + if existing.request != request { + bail!("dispatch messageId is already bound to different content"); + } + return Ok(true); + } + write_json_if_absent_or_equal(&path, &stored)?; + Ok(true) + } + + pub(crate) fn list_pending_append_messages( + &self, + job_id: &str, + ) -> Result> { + let job_dir = self.existing_job_dir(job_id)?; + let mut messages = + read_json_directory::(&job_dir.join(PENDING_MESSAGES_DIR))?; + messages.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.request.message_id.cmp(&right.request.message_id)) + }); + Ok(messages.into_iter().map(|stored| stored.request).collect()) + } + + pub(crate) fn mark_append_message_consumed( + &self, + job_id: &str, + request: &DispatchAppendRequest, + ) -> Result<()> { + let job_dir = self.existing_job_dir(job_id)?; + let _lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let consumed_path = mailbox_path(&job_dir, CONSUMED_MESSAGES_DIR, &request.message_id)?; + write_json_if_absent_or_equal(&consumed_path, request)?; + remove_file_if_present(&mailbox_path( + &job_dir, + PENDING_MESSAGES_DIR, + &request.message_id, + )?); + Ok(()) + } + pub(crate) fn list_jobs(&self) -> Result> { let jobs_dir = self.root.join("jobs"); let mut entries = Vec::new(); @@ -489,6 +754,9 @@ impl DispatchStore { started_at: state.started_at, workspace_path: job.request.workspace_path, title: job.title, + agent_type: job.request.agent_type, + approval_policy: job.request.approval_policy, + model: job.request.model, }); } entries.sort_by(|left, right| right.started_at.cmp(&left.started_at)); @@ -560,6 +828,181 @@ impl DispatchStore { .join(format!("{digest:x}.lock")) } + pub(crate) fn root(&self) -> &Path { + &self.root + } + + pub(crate) fn workspace_upload_dir(&self, job_id: &str) -> Result { + validate_id("jobId", job_id)?; + Ok(self.root.join("workspaces").join(job_id)) + } + + fn maybe_collect_expired_terminal_jobs(&self) -> Result<()> { + let marker = self.root.join(RETENTION_GC_MARKER); + if fs::metadata(&marker) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|elapsed| elapsed.as_secs() < RETENTION_GC_INTERVAL_SECONDS) + { + return Ok(()); + } + let _lock = JobLock::exclusive(&self.root.join(RETENTION_GC_LOCK))?; + if fs::metadata(&marker) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|elapsed| elapsed.as_secs() < RETENTION_GC_INTERVAL_SECONDS) + { + return Ok(()); + } + self.collect_expired_terminal_jobs(chrono::Utc::now())?; + atomic_write(&marker, chrono::Utc::now().to_rfc3339().as_bytes()) + } + + fn collect_expired_terminal_jobs(&self, now: chrono::DateTime) -> Result { + let jobs_root = self.root.join("jobs"); + let mut removed = 0; + for entry in fs::read_dir(&jobs_root) + .with_context(|| format!("read dispatch jobs {}", jobs_root.display()))? + { + let entry = entry?; + let Some(job_id) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if validate_id("jobId", &job_id).is_err() { + continue; + } + let job_dir = entry.path(); + let metadata = fs::symlink_metadata(&job_dir)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + continue; + } + let lock = JobLock::exclusive(&job_dir.join(".lock"))?; + let state = match self.load_state_unlocked(&job_dir) { + Ok(state) => state, + Err(error) => { + tracing::warn!( + "Skipping unreadable dispatch job during retention cleanup: job_id={} error={error:#}", + job_id + ); + continue; + } + }; + if !state.state.is_terminal() { + continue; + } + let Some(finished_at) = state + .finished_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&chrono::Utc)) + else { + continue; + }; + if now.signed_duration_since(finished_at).num_days() < TERMINAL_JOB_RETENTION_DAYS { + continue; + } + let tombstone = jobs_root.join(format!( + ".gc-{}-{}", + job_id, + uuid::Uuid::new_v4().as_simple() + )); + fs::rename(&job_dir, &tombstone).with_context(|| { + format!("quarantine expired dispatch job {}", job_dir.display()) + })?; + drop(lock); + fs::remove_dir_all(&tombstone) + .with_context(|| format!("remove expired dispatch job {}", tombstone.display()))?; + + let workspace_dir = self.root.join("workspaces").join(&job_id); + match fs::symlink_metadata(&workspace_dir) { + Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_dir() => { + fs::remove_dir_all(&workspace_dir).with_context(|| { + format!( + "remove expired dispatch workspace {}", + workspace_dir.display() + ) + })?; + } + Ok(_) => { + tracing::warn!( + "Skipping unsafe expired dispatch workspace path: {}", + workspace_dir.display() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let upload_lock = self + .root + .join("workspaces") + .join(format!(".{job_id}.upload.lock")); + match fs::symlink_metadata(&upload_lock) { + Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_file() => { + fs::remove_file(&upload_lock).with_context(|| { + format!( + "remove expired dispatch workspace lock {}", + upload_lock.display() + ) + })?; + } + Ok(_) => { + tracing::warn!( + "Skipping unsafe expired dispatch workspace lock: {}", + upload_lock.display() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + removed += 1; + } + let workspaces_root = self.root.join("workspaces"); + for entry in fs::read_dir(&workspaces_root) + .with_context(|| format!("read dispatch workspaces {}", workspaces_root.display()))? + { + let entry = entry?; + let Some(job_id) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if validate_id("jobId", &job_id).is_err() || jobs_root.join(&job_id).exists() { + continue; + } + let workspace_dir = entry.path(); + let metadata = fs::symlink_metadata(&workspace_dir)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + continue; + } + let old_enough = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|elapsed| { + elapsed.as_secs() >= (TERMINAL_JOB_RETENTION_DAYS as u64) * 24 * 60 * 60 + }); + if !old_enough { + continue; + } + let tombstone = workspaces_root.join(format!( + ".gc-{}-{}", + job_id, + uuid::Uuid::new_v4().as_simple() + )); + fs::rename(&workspace_dir, &tombstone).with_context(|| { + format!( + "quarantine orphaned dispatch workspace {}", + workspace_dir.display() + ) + })?; + fs::remove_dir_all(&tombstone).with_context(|| { + format!("remove orphaned dispatch workspace {}", tombstone.display()) + })?; + removed += 1; + } + Ok(removed) + } + fn load_state_unlocked(&self, job_dir: &Path) -> Result { read_json(&job_dir.join(STATE_FILE)) } @@ -583,7 +1026,8 @@ impl DispatchStore { .with_context(|| format!("open dispatch events {}", path.display()))?; set_private_file_permissions(&path)?; let encoded = serde_json::to_vec(event).context("encode dispatch event")?; - let encoded = if encoded.len() > MAX_EVENT_BYTES { + let event_was_omitted = encoded.len() > MAX_EVENT_BYTES; + let encoded = if event_was_omitted { serde_json::to_vec(&DispatchEvent::oversized_event_omitted( encoded.len(), MAX_EVENT_BYTES, @@ -593,6 +1037,17 @@ impl DispatchStore { encoded }; let (header, data_start) = read_event_log_header(&mut file, &path)?; + let metadata_path = job_dir.join(EVENTS_METADATA_FILE); + let mut event_metadata = load_event_log_metadata(&metadata_path); + event_metadata.history_truncated |= header.cursor_base > 0; + if event_was_omitted { + // Persist the conservative completeness fact before the marker. + // A crash may over-count by one, but can never claim completeness + // after source content was dropped. + event_metadata.omitted_event_count = + event_metadata.omitted_event_count.saturating_add(1); + atomic_write_json(&metadata_path, &event_metadata)?; + } let physical_len = truncate_incomplete_event_tail(&mut file)?; let current_len = physical_len.saturating_sub(data_start); if current_len @@ -601,6 +1056,8 @@ impl DispatchStore { > self.max_events_bytes { let cursor_base = header.cursor_base.saturating_add(current_len); + event_metadata.history_truncated = true; + atomic_write_json(&metadata_path, &event_metadata)?; atomic_write_event_log(&path, cursor_base, Some(&encoded))?; return Ok(cursor_base .saturating_add(encoded.len() as u64) @@ -683,15 +1140,27 @@ impl DispatchLease { } } -struct JobLock { +pub(super) struct JobLock { _file: File, } impl JobLock { - fn exclusive(path: &Path) -> Result { + pub(super) fn exclusive(path: &Path) -> Result { Self::open(path, true) } + pub(super) fn try_exclusive(path: &Path) -> Result> { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(path) + .with_context(|| format!("open dispatch job lock {}", path.display()))?; + set_private_file_permissions(path)?; + try_lock_file_exclusive(&file).map(|acquired| acquired.then_some(Self { _file: file })) + } + fn shared(path: &Path) -> Result { Self::open(path, false) } @@ -728,7 +1197,7 @@ impl FileLock { } } -fn validate_id(field: &str, value: &str) -> Result<()> { +pub(super) fn validate_id(field: &str, value: &str) -> Result<()> { if value.is_empty() || value.len() > 128 || !value @@ -744,13 +1213,169 @@ fn validate_id(field: &str, value: &str) -> Result<()> { Ok(()) } +fn mailbox_path(job_dir: &Path, directory: &str, id: &str) -> Result { + if id.trim().is_empty() || id.len() > 1024 { + bail!("dispatch mailbox identity is empty or too long"); + } + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(id.as_bytes()); + Ok(job_dir.join(directory).join(format!("{digest:x}.json"))) +} + +fn ensure_permission_answer_matches( + existing: &StoredPermissionAnswer, + request_id: &str, + reply: &PermissionReply, +) -> Result<()> { + if existing.request_id != request_id || existing.reply != *reply { + bail!("dispatch permission requestId is already bound to a different answer"); + } + Ok(()) +} + +fn load_event_log_metadata(path: &Path) -> EventLogMetadata { + match read_optional_regular_json(path) { + Ok(Some(metadata)) => metadata, + Ok(None) => { + tracing::warn!( + "Dispatch event completeness metadata is missing: {}", + path.display() + ); + EventLogMetadata { + history_truncated: true, + omitted_event_count: 0, + } + } + Err(error) => { + tracing::warn!( + "Dispatch event completeness metadata is unreadable: path={} error={error:#}", + path.display() + ); + EventLogMetadata { + history_truncated: true, + omitted_event_count: 0, + } + } + } +} + +fn read_optional_regular_json(path: &Path) -> Result> +where + T: for<'de> Deserialize<'de>, +{ + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "dispatch mailbox path is not a regular file: {}", + path.display() + ); + } + read_json(path).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => { + Err(error).with_context(|| format!("inspect dispatch mailbox {}", path.display())) + } + } +} + +fn read_json_directory(directory: &Path) -> Result> +where + T: for<'de> Deserialize<'de>, +{ + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(error) + .with_context(|| format!("read dispatch mailbox {}", directory.display())) + } + }; + let mut values = Vec::new(); + for entry in entries { + let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("json") + { + bail!( + "dispatch mailbox contains an invalid entry: {}", + entry.path().display() + ); + } + values.push(read_json(&entry.path())?); + } + Ok(values) +} + +fn mailbox_usage(directory: &Path) -> Result<(usize, u64)> { + let entries = match fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, 0)), + Err(error) => { + return Err(error) + .with_context(|| format!("read dispatch mailbox {}", directory.display())) + } + }; + let mut count = 0_usize; + let mut bytes = 0_u64; + for entry in entries { + let entry = entry?; + let metadata = fs::symlink_metadata(entry.path())?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("json") + { + bail!( + "dispatch mailbox contains an invalid entry: {}", + entry.path().display() + ); + } + count = count.saturating_add(1); + bytes = bytes.saturating_add(metadata.len()); + } + Ok((count, bytes)) +} + +fn write_json_if_absent_or_equal(path: &Path, value: &T) -> Result<()> +where + T: for<'de> Deserialize<'de> + Serialize + PartialEq, +{ + if let Some(parent) = path.parent() { + create_private_dir(parent)?; + } + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!( + "dispatch mailbox path is not a regular file: {}", + path.display() + ); + } + let existing = read_json::(path)?; + if existing != *value { + bail!("dispatch mailbox identity is already bound to different content"); + } + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + atomic_write_json(path, value) + } + Err(error) => { + Err(error).with_context(|| format!("inspect dispatch mailbox {}", path.display())) + } + } +} + fn submit_intent_fingerprint(request: &DispatchSubmitRequest) -> Result { use sha2::{Digest, Sha256}; let encoded = serde_json::to_vec(request).context("encode dispatch submit intent")?; Ok(format!("{:x}", Sha256::digest(encoded))) } -fn read_json Deserialize<'de>>(path: &Path) -> Result { +pub(super) fn read_json Deserialize<'de>>(path: &Path) -> Result { let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; serde_json::from_slice(&bytes).with_context(|| format!("decode {}", path.display())) } @@ -817,7 +1442,19 @@ fn truncate_incomplete_event_tail(file: &mut File) -> Result { Ok(retained as u64) } -fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> { +fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_string(); + } + let suffix = "…"; + let mut end = max_bytes.saturating_sub(suffix.len()); + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &value[..end], suffix) +} + +pub(super) fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> { let mut bytes = serde_json::to_vec_pretty(value).context("encode dispatch state")?; bytes.push(b'\n'); atomic_write(path, &bytes) @@ -866,7 +1503,7 @@ fn ensure_private_file(path: &Path) -> Result<()> { set_private_file_permissions(path) } -fn create_private_dir(path: &Path) -> Result<()> { +pub(super) fn create_private_dir(path: &Path) -> Result<()> { fs::create_dir_all(path).with_context(|| format!("create {}", path.display()))?; #[cfg(unix)] { @@ -877,7 +1514,7 @@ fn create_private_dir(path: &Path) -> Result<()> { Ok(()) } -fn set_private_file_permissions(path: &Path) -> Result<()> { +pub(super) fn set_private_file_permissions(path: &Path) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -889,7 +1526,7 @@ fn set_private_file_permissions(path: &Path) -> Result<()> { Ok(()) } -fn sync_directory(path: &Path) -> Result<()> { +pub(super) fn sync_directory(path: &Path) -> Result<()> { #[cfg(unix)] { File::open(path) @@ -902,7 +1539,7 @@ fn sync_directory(path: &Path) -> Result<()> { Ok(()) } -fn remove_file_if_present(path: &Path) { +pub(super) fn remove_file_if_present(path: &Path) { if let Err(error) = fs::remove_file(path) { if error.kind() != std::io::ErrorKind::NotFound { tracing::warn!("Failed to remove dispatch file {}: {error}", path.display()); @@ -955,6 +1592,8 @@ fn try_lock_file_exclusive(_file: &File) -> Result { mod tests { use super::*; use crate::dispatch::protocol::{DispatchApprovalPolicy, DispatchSubmitRequest}; + use bitfun_agent_runtime::sdk::{PermissionRequestSource, PermissionRequestSourceKind}; + use serde_json::Map; fn request(job_id: &str) -> DispatchSubmitRequest { DispatchSubmitRequest { @@ -976,6 +1615,28 @@ mod tests { (dir, store) } + fn permission_request(job_id: &str) -> PermissionRequest { + PermissionRequest { + request_id: "permission-1".to_string(), + round_id: "round-1".to_string(), + order: 0, + tool_call_id: Some("tool-call-1".to_string()), + project_path: Some("/tmp/workspace".to_string()), + project_id: "project-1".to_string(), + session_id: format!("session-{job_id}"), + agent_id: "agentic".to_string(), + action: "write".to_string(), + resources: vec!["src/main.rs".to_string()], + save_resources: Vec::new(), + source: PermissionRequestSource { + kind: PermissionRequestSourceKind::ToolCall, + identity: "Write".to_string(), + }, + delegation: None, + display_metadata: Map::new(), + } + } + #[test] fn event_cursor_is_monotonic_and_does_not_replay() { let (_dir, store) = store(); @@ -1073,6 +1734,85 @@ mod tests { assert!(still_succeeded.last_error.is_none()); } + #[test] + fn permission_answers_are_idempotent_before_and_after_worker_consumption() { + let (_dir, store) = store(); + store + .create_job(request("job-permission"), "Task".to_string()) + .expect("create job"); + let permission = permission_request("job-permission"); + store + .save_pending_permission("job-permission", &permission) + .expect("save pending permission"); + + assert!(store + .save_permission_answer( + "job-permission", + &permission.request_id, + PermissionReply::Once, + ) + .expect("first answer")); + assert!(store + .save_permission_answer( + "job-permission", + &permission.request_id, + PermissionReply::Once, + ) + .expect("retry pending answer")); + let answer = store + .list_permission_answers("job-permission") + .expect("list answers") + .pop() + .expect("answer"); + store + .mark_permission_resolved("job-permission", &answer) + .expect("resolve answer"); + assert!(store + .save_permission_answer( + "job-permission", + &permission.request_id, + PermissionReply::Once, + ) + .expect("retry resolved answer")); + assert!(store + .save_permission_answer( + "job-permission", + &permission.request_id, + PermissionReply::Always, + ) + .is_err()); + } + + #[test] + fn appended_messages_are_idempotently_bound_to_their_content() { + let (_dir, store) = store(); + store + .create_job(request("job-append"), "Task".to_string()) + .expect("create job"); + let message = DispatchAppendRequest { + job_id: "job-append".to_string(), + message_id: "message-1".to_string(), + content: "Continue with tests".to_string(), + display_content: None, + }; + + assert!(store + .enqueue_append_message(message.clone()) + .expect("first append")); + assert!(store + .enqueue_append_message(message.clone()) + .expect("retry pending append")); + store + .mark_append_message_consumed("job-append", &message) + .expect("consume append"); + assert!(store + .enqueue_append_message(message.clone()) + .expect("retry consumed append")); + let mut conflicting = message; + conflicting.content = "Different content".to_string(); + assert!(store.enqueue_append_message(conflicting).is_err()); + } + #[test] fn worker_exit_settlement_observes_cancel_request_under_the_state_lock() { let (_dir, store) = store(); @@ -1511,6 +2251,22 @@ mod tests { assert_eq!(details["maxBytes"], MAX_EVENT_BYTES); } + #[test] + fn missing_completeness_metadata_fails_closed() { + let (_dir, store) = store(); + store + .create_job(request("job-metadata"), "Task".to_string()) + .expect("create job"); + let metadata_path = store + .job_dir("job-metadata") + .expect("job directory") + .join(EVENTS_METADATA_FILE); + fs::remove_file(metadata_path).expect("remove metadata"); + + let page = store.read_events("job-metadata", 0).expect("read events"); + assert!(page.history_truncated); + } + #[test] fn status_pages_cap_event_count_without_skipping_cursor_bytes() { let (_dir, store) = store(); @@ -1589,6 +2345,46 @@ mod tests { assert!(bitfun_home.join("dispatch/workspaces").is_dir()); } + #[test] + fn retention_removes_only_expired_terminal_jobs_and_their_managed_workspace() { + let (_dir, store) = store(); + for job_id in ["expired", "recent", "running"] { + store + .create_job(request(job_id), "Task".to_string()) + .expect("create job"); + create_private_dir(&store.workspace_upload_dir(job_id).expect("workspace path")) + .expect("create workspace"); + } + + for job_id in ["expired", "recent"] { + store + .mark_state(job_id, DispatchJobState::Succeeded, None, None) + .expect("mark terminal"); + } + let now = chrono::Utc::now(); + let mut expired = store.load_state("expired").expect("expired state"); + expired.finished_at = + Some((now - chrono::Duration::days(TERMINAL_JOB_RETENTION_DAYS + 1)).to_rfc3339()); + atomic_write_json( + &store.job_dir("expired").expect("job path").join(STATE_FILE), + &expired, + ) + .expect("age terminal state"); + + assert_eq!( + store + .collect_expired_terminal_jobs(now) + .expect("collect expired jobs"), + 1 + ); + assert!(!store.root.join("jobs/expired").exists()); + assert!(!store.root.join("workspaces/expired").exists()); + assert!(store.root.join("jobs/recent").exists()); + assert!(store.root.join("workspaces/recent").exists()); + assert!(store.root.join("jobs/running").exists()); + assert!(store.root.join("workspaces/running").exists()); + } + #[cfg(unix)] #[test] fn job_storage_uses_owner_only_permissions() { diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index 43cd6436b9..bfab4f1969 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -1,5 +1,6 @@ use std::collections::{HashSet, VecDeque}; use std::path::Path; +use std::time::Duration; use anyhow::{anyhow, bail, Context, Result}; use bitfun_agent_runtime::sdk::{ @@ -37,6 +38,7 @@ pub(crate) async fn run(job_id: String) -> Result<()> { Some(format!("{error:#}")), ); } + store.clear_pending_permissions(&job_id); store.clear_preparing(&job_id); store.remove_pid_if_matches(&job_id, worker_pid); shutdown_mcp_servers().await; @@ -103,6 +105,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { ) .await?; let agent_runtime = runtime.agent_runtime().clone(); + let compatibility = runtime.compatibility().clone(); let mut event_rx = agent_runtime .subscribe_events() .map_err(|error| anyhow!(error.into_message()))?; @@ -162,13 +165,15 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { .into_iter() .collect::>(); let mut handled_permissions = HashSet::new(); + let mut mailbox_tick = tokio::time::interval(Duration::from_millis(200)); + mailbox_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let (terminal_state, terminal_error) = loop { if let Some(request) = initial_permissions.pop_front() { if permission_targets_job(&request, &job.request.session_id) && handled_permissions.insert(request.request_id.clone()) { - let reason = reject_permission( + if let Some(reason) = handle_permission( store, job_id, &agent_runtime, @@ -177,8 +182,10 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { request, job.request.approval_policy, ) - .await?; - break (DispatchJobState::Failed, Some(reason)); + .await? + { + break (DispatchJobState::Failed, Some(reason)); + } } continue; } @@ -243,7 +250,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { { continue; } - let reason = reject_permission( + if let Some(reason) = handle_permission( store, job_id, &agent_runtime, @@ -252,8 +259,22 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { request, job.request.approval_policy, ) - .await?; - break (DispatchJobState::Failed, Some(reason)); + .await? + { + break (DispatchJobState::Failed, Some(reason)); + } + } + _ = mailbox_tick.tick() => { + if let Some(outcome) = process_mailboxes( + store, + job_id, + &agent_runtime, + &compatibility, + &job.request.session_id, + &turn_id, + ).await? { + break outcome; + } } } }; @@ -276,10 +297,11 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { ), }; store.mark_state(job_id, terminal_state, Some(&turn_id), terminal_error)?; + store.clear_pending_permissions(job_id); Ok(()) } -async fn reject_permission( +async fn handle_permission( store: &DispatchStore, job_id: &str, runtime: &bitfun_agent_runtime::sdk::AgentRuntime, @@ -287,13 +309,22 @@ async fn reject_permission( turn_id: &str, request: PermissionRequest, policy: DispatchApprovalPolicy, -) -> Result { +) -> Result> { + if policy == DispatchApprovalPolicy::Remote { + store.save_pending_permission(job_id, &request)?; + store.append_event( + job_id, + &DispatchEvent::permission_pending(&request.request_id), + )?; + return Ok(None); + } let reason = match policy { DispatchApprovalPolicy::RejectAndReport => REJECT_AND_REPORT_REASON.to_string(), DispatchApprovalPolicy::Auto => format!( "Dispatch Auto policy could not safely auto-approve permission request {}", request.request_id ), + DispatchApprovalPolicy::Remote => unreachable!("remote handled above"), }; store.append_event( job_id, @@ -314,7 +345,71 @@ async fn reject_permission( .map_err(|error| anyhow!(error.into_message())) .context("reject unattended dispatch permission")?; cancel_turn(runtime, session_id, turn_id, "dispatch_permission_rejected").await; - Ok(reason) + Ok(Some(reason)) +} + +async fn process_mailboxes( + store: &DispatchStore, + job_id: &str, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + compatibility: &bitfun_core::product_runtime::CoreAgentRuntimeCompatibility, + session_id: &str, + turn_id: &str, +) -> Result)>> { + let state = store.load_state(job_id)?; + if state.cancel_requested() { + cancel_turn(runtime, session_id, turn_id, "dispatch_cancel_requested").await; + return Ok(Some(( + DispatchJobState::Cancelled, + Some("Dispatch worker observed a cancellation request".to_string()), + ))); + } + + for answer in store.list_permission_answers(job_id)? { + runtime + .respond_permission_with_source( + &answer.request_id, + answer.reply.clone(), + PermissionReplySource::User, + ) + .await + .map_err(|error| anyhow!(error.into_message())) + .with_context(|| { + format!( + "apply remote dispatch permission response {}", + answer.request_id + ) + })?; + store.mark_permission_resolved(job_id, &answer)?; + store.append_event( + job_id, + &DispatchEvent::permission_resolved(&answer.request_id), + )?; + } + + for request in store.list_pending_append_messages(job_id)? { + compatibility + .submit_steering( + session_id.to_string(), + turn_id.to_string(), + request.content.clone(), + request.display_content.clone(), + ) + .await + .map_err(anyhow::Error::msg) + .with_context(|| { + format!( + "append message {} to running dispatch turn", + request.message_id + ) + })?; + store.mark_append_message_consumed(job_id, &request)?; + store.append_event( + job_id, + &DispatchEvent::message_appended(&request.message_id), + )?; + } + Ok(None) } async fn cancel_turn( @@ -344,7 +439,7 @@ fn permission_targets_job(request: &PermissionRequest, session_id: &str) -> bool fn event_belongs_to_job(event: &AgenticEvent, session_id: &str, turn_id: &str) -> bool { if matches!(event, AgenticEvent::SubagentSessionLinked { .. }) { - // Phase 1 has no child-session observer or dispatch marker. Publishing + // Detached dispatch has no child-session observer or dispatch marker. Publishing // this link would create an empty local-looking child in the Web UI, // while every later child event is correctly outside the parent scope. return false; diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs new file mode 100644 index 0000000000..36573ce4ac --- /dev/null +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -0,0 +1,680 @@ +use std::fs::{self, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use base64::Engine as _; +use bitfun_services_core::dispatch_workspace::{ + extract_workspace_snapshot, WorkspaceSnapshotMetadata, MAX_SNAPSHOT_ARCHIVE_BYTES, + MAX_SNAPSHOT_DIRECTORIES, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_UNCOMPRESSED_BYTES, + WORKSPACE_SNAPSHOT_FORMAT_VERSION, +}; +use serde::{Deserialize, Serialize}; + +use super::protocol::{ + DispatchWorkspaceBeginRequest, DispatchWorkspaceBeginResponse, DispatchWorkspaceChunkRequest, + DispatchWorkspaceChunkResponse, DispatchWorkspaceCommitRequest, + DispatchWorkspaceCommitResponse, DISPATCH_PROTOCOL_VERSION, +}; +use super::store::{ + atomic_write_json, create_private_dir, read_json, remove_file_if_present, + set_private_file_permissions, sync_directory, DispatchStore, JobLock, +}; + +const UPLOAD_RECORD_FILE: &str = "upload.json"; +const UPLOAD_ARCHIVE_FILE: &str = "workspace.tar.gz"; +const CURRENT_WORKSPACE_DIR: &str = "current"; +const MAX_CHUNK_BYTES: usize = 256 * 1024; +const MAX_CHUNK_BASE64_BYTES: usize = 384 * 1024; +const MAX_MATERIALIZATION_ERROR_BYTES: usize = 16 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum WorkspaceUploadState { + Uploading, + Committed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkspaceUploadRecord { + protocol_version: u32, + job_id: String, + metadata: WorkspaceSnapshotMetadata, + state: WorkspaceUploadState, + created_at: String, + #[serde(default)] + committed_at: Option, + #[serde(default)] + workspace_path: Option, + #[serde(default)] + last_error: Option, +} + +pub(crate) fn begin( + request: DispatchWorkspaceBeginRequest, +) -> Result { + validate_begin(&request)?; + let store = DispatchStore::open_default()?; + let upload_dir = store.workspace_upload_dir(&request.job_id)?; + let lock_path = workspace_upload_lock_path(&store, &request.job_id); + let Some(_lock) = JobLock::try_exclusive(&lock_path)? else { + let existing: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) + .context("workspace upload is currently being initialized")?; + ensure_begin_binding(&existing, &request)?; + ensure_upload_not_failed(&existing)?; + if existing.state == WorkspaceUploadState::Committed { + let workspace_path = + validate_committed_workspace(&upload_dir, existing.workspace_path.as_deref())?; + return Ok(DispatchWorkspaceBeginResponse { + accepted: true, + offset: request.metadata.archive_size, + upload_path: upload_dir + .join(UPLOAD_ARCHIVE_FILE) + .to_string_lossy() + .to_string(), + committed: true, + workspace_path: Some(workspace_path), + }); + } + let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + let offset = fs::symlink_metadata(&archive_path) + .ok() + .filter(|metadata| !metadata.file_type().is_symlink() && metadata.is_file()) + .map(|metadata| metadata.len().min(request.metadata.archive_size)) + .unwrap_or(0); + return Ok(DispatchWorkspaceBeginResponse { + accepted: true, + offset, + upload_path: archive_path.to_string_lossy().to_string(), + committed: false, + workspace_path: None, + }); + }; + + let record_path = upload_dir.join(UPLOAD_RECORD_FILE); + if let Ok(existing) = read_json::(&record_path) { + ensure_begin_binding(&existing, &request)?; + ensure_upload_not_failed(&existing)?; + if existing.state == WorkspaceUploadState::Committed { + let workspace_path = + validate_committed_workspace(&upload_dir, existing.workspace_path.as_deref())?; + return Ok(DispatchWorkspaceBeginResponse { + accepted: true, + offset: request.metadata.archive_size, + upload_path: upload_dir + .join(UPLOAD_ARCHIVE_FILE) + .to_string_lossy() + .to_string(), + committed: true, + workspace_path: Some(workspace_path), + }); + } + } else { + match fs::symlink_metadata(&upload_dir) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("workspace upload path is not a private directory"); + } + fs::remove_dir_all(&upload_dir).with_context(|| { + format!("reset incomplete workspace upload {}", upload_dir.display()) + })?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("inspect workspace upload {}", upload_dir.display())) + } + } + create_private_dir(&upload_dir)?; + let record = WorkspaceUploadRecord { + protocol_version: request.protocol_version, + job_id: request.job_id.clone(), + metadata: request.metadata.clone(), + state: WorkspaceUploadState::Uploading, + created_at: chrono::Utc::now().to_rfc3339(), + committed_at: None, + workspace_path: None, + last_error: None, + }; + atomic_write_json(&record_path, &record)?; + } + + let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + let archive_metadata = fs::symlink_metadata(&archive_path); + let offset = match archive_metadata { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("workspace upload archive is not a regular file"); + } + set_private_file_permissions(&archive_path)?; + if metadata.len() > request.metadata.archive_size { + let file = OpenOptions::new() + .write(true) + .open(&archive_path) + .context("open oversized workspace upload archive")?; + file.set_len(0) + .context("reset oversized workspace upload archive")?; + 0 + } else { + metadata.len() + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&archive_path) + .context("create workspace upload archive")?; + drop(file); + set_private_file_permissions(&archive_path)?; + 0 + } + Err(error) => return Err(error).context("inspect workspace upload archive"), + }; + Ok(DispatchWorkspaceBeginResponse { + accepted: true, + offset, + upload_path: archive_path.to_string_lossy().to_string(), + committed: false, + workspace_path: None, + }) +} + +pub(crate) fn chunk( + request: DispatchWorkspaceChunkRequest, +) -> Result { + if request.data_base64.len() > MAX_CHUNK_BASE64_BYTES { + bail!("workspace upload chunk exceeds the encoded safety limit"); + } + let data = base64::engine::general_purpose::STANDARD + .decode(request.data_base64.as_bytes()) + .context("decode workspace upload chunk")?; + if data.is_empty() || data.len() > MAX_CHUNK_BYTES { + bail!( + "workspace upload chunk must contain 1-{} bytes", + MAX_CHUNK_BYTES + ); + } + let store = DispatchStore::open_default()?; + let upload_dir = store.workspace_upload_dir(&request.job_id)?; + let lock_path = workspace_upload_lock_path(&store, &request.job_id); + let _lock = JobLock::exclusive(&lock_path)?; + let record: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) + .context("workspace upload was not initialized")?; + ensure_upload_identity(&record, &request.job_id)?; + ensure_upload_not_failed(&record)?; + if record.state != WorkspaceUploadState::Uploading { + bail!("workspace upload is not accepting chunks"); + } + let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(&archive_path) + .context("open workspace upload archive")?; + set_private_file_permissions(&archive_path)?; + let current = file.metadata()?.len(); + let chunk_end = request.offset.saturating_add(data.len() as u64); + if chunk_end > record.metadata.archive_size { + bail!("workspace upload chunk exceeds the declared archive size"); + } + if request.offset < current { + if chunk_end > current { + bail!("workspace upload chunk overlaps the retained archive tail"); + } + file.seek(SeekFrom::Start(request.offset))?; + let mut existing = vec![0_u8; data.len()]; + file.read_exact(&mut existing)?; + if existing != data { + bail!("workspace upload retry does not match retained bytes"); + } + return Ok(DispatchWorkspaceChunkResponse { + accepted: true, + offset: current, + }); + } + if request.offset != current { + bail!( + "workspace upload offset mismatch: expected {}, received {}", + current, + request.offset + ); + } + file.seek(SeekFrom::End(0))?; + file.write_all(&data)?; + file.sync_data()?; + Ok(DispatchWorkspaceChunkResponse { + accepted: true, + offset: chunk_end, + }) +} + +pub(crate) fn commit( + request: DispatchWorkspaceCommitRequest, +) -> Result { + let store = DispatchStore::open_default()?; + let upload_dir = store.workspace_upload_dir(&request.job_id)?; + let lock_path = workspace_upload_lock_path(&store, &request.job_id); + let Some(_lock) = JobLock::try_exclusive(&lock_path)? else { + let record: WorkspaceUploadRecord = read_json(&upload_dir.join(UPLOAD_RECORD_FILE)) + .context("workspace upload was not initialized")?; + ensure_upload_identity(&record, &request.job_id)?; + ensure_upload_not_failed(&record)?; + return Ok(pending_commit_response(&record)); + }; + let record_path = upload_dir.join(UPLOAD_RECORD_FILE); + let mut record: WorkspaceUploadRecord = + read_json(&record_path).context("workspace upload was not initialized")?; + ensure_upload_identity(&record, &request.job_id)?; + ensure_upload_not_failed(&record)?; + if record.state == WorkspaceUploadState::Committed { + let workspace_path = + validate_committed_workspace(&upload_dir, record.workspace_path.as_deref())?; + return Ok(DispatchWorkspaceCommitResponse { + committed: true, + workspace_path: Some(workspace_path), + metadata: record.metadata, + }); + } + + if managed_workspace_exists(&upload_dir)? { + // Extraction publishes this directory only after every digest and + // manifest check succeeds. Recover the narrow crash window between + // directory publication and record publication. + let workspace_path = mark_workspace_committed(&record_path, &upload_dir, &mut record)?; + return Ok(DispatchWorkspaceCommitResponse { + committed: true, + workspace_path: Some(workspace_path), + metadata: record.metadata, + }); + } + + let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + validate_complete_archive(&archive_path, &record.metadata)?; + super::runner::spawn_workspace_materializer(&request.job_id)?; + Ok(pending_commit_response(&record)) +} + +/// Detached target-side materialization. The short `workspace-commit` RPC +/// starts this process and subsequent commit calls poll the durable record, so +/// extraction is not bounded by an SSH or Relay request timeout. +pub(crate) fn materialize(job_id: String) -> Result<()> { + let store = DispatchStore::open_default()?; + materialize_in_store(&store, &job_id) +} + +fn materialize_in_store(store: &DispatchStore, job_id: &str) -> Result<()> { + let upload_dir = store.workspace_upload_dir(job_id)?; + let lock_path = workspace_upload_lock_path(store, job_id); + let _lock = JobLock::exclusive(&lock_path)?; + let record_path = upload_dir.join(UPLOAD_RECORD_FILE); + let mut record: WorkspaceUploadRecord = + read_json(&record_path).context("workspace upload was not initialized")?; + ensure_upload_identity(&record, job_id)?; + ensure_upload_not_failed(&record)?; + if record.state == WorkspaceUploadState::Committed { + validate_committed_workspace(&upload_dir, record.workspace_path.as_deref())?; + return Ok(()); + } + let current = upload_dir.join(CURRENT_WORKSPACE_DIR); + let result = (|| -> Result<()> { + if managed_workspace_exists(&upload_dir)? { + mark_workspace_committed(&record_path, &upload_dir, &mut record)?; + return Ok(()); + } + + remove_stale_staging_directories(&upload_dir)?; + let archive_path = upload_dir.join(UPLOAD_ARCHIVE_FILE); + validate_complete_archive(&archive_path, &record.metadata)?; + let staging = upload_dir.join(format!(".staging-{}", uuid::Uuid::new_v4().as_simple())); + extract_workspace_snapshot(&archive_path, &staging, &record.metadata)?; + fs::rename(&staging, ¤t).with_context(|| { + format!( + "publish dispatch workspace {} -> {}", + staging.display(), + current.display() + ) + })?; + sync_directory(&upload_dir)?; + mark_workspace_committed(&record_path, &upload_dir, &mut record)?; + remove_file_if_present(&archive_path); + Ok(()) + })(); + if let Err(error) = result { + // Once `current` exists, a later commit can recover the narrow crash + // window between atomic publication and record publication. Before + // publication, persist a bounded diagnostic so controllers do not + // poll an irrecoverably bad archive until their transport timeout. + if !is_real_directory(¤t) { + record.state = WorkspaceUploadState::Failed; + record.last_error = Some(truncate_utf8(&format!("{error:#}"))); + let _ = atomic_write_json(&record_path, &record); + } + return Err(error); + } + Ok(()) +} + +fn ensure_upload_identity(record: &WorkspaceUploadRecord, job_id: &str) -> Result<()> { + if record.job_id != job_id { + bail!("workspace upload identity mismatch"); + } + Ok(()) +} + +fn ensure_begin_binding( + record: &WorkspaceUploadRecord, + request: &DispatchWorkspaceBeginRequest, +) -> Result<()> { + if record.protocol_version != request.protocol_version + || record.job_id != request.job_id + || record.metadata != request.metadata + { + bail!("workspace upload job is already bound to different snapshot metadata"); + } + Ok(()) +} + +fn ensure_upload_not_failed(record: &WorkspaceUploadRecord) -> Result<()> { + if record.state == WorkspaceUploadState::Failed { + bail!( + "workspace materialization failed: {}", + record + .last_error + .as_deref() + .unwrap_or("target did not retain a diagnostic") + ); + } + Ok(()) +} + +fn pending_commit_response(record: &WorkspaceUploadRecord) -> DispatchWorkspaceCommitResponse { + DispatchWorkspaceCommitResponse { + committed: false, + workspace_path: None, + metadata: record.metadata.clone(), + } +} + +fn is_real_directory(path: &Path) -> bool { + fs::symlink_metadata(path) + .ok() + .is_some_and(|metadata| !metadata.file_type().is_symlink() && metadata.is_dir()) +} + +fn managed_workspace_exists(upload_dir: &Path) -> Result { + let current = upload_dir.join(CURRENT_WORKSPACE_DIR); + match fs::symlink_metadata(¤t) { + Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_dir() => Ok(true), + Ok(_) => bail!("managed dispatch workspace path is not a real directory"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).context("inspect managed dispatch workspace"), + } +} + +fn mark_workspace_committed( + record_path: &Path, + upload_dir: &Path, + record: &mut WorkspaceUploadRecord, +) -> Result { + let workspace_path = validate_committed_workspace(upload_dir, None)?; + record.state = WorkspaceUploadState::Committed; + record.committed_at = Some(chrono::Utc::now().to_rfc3339()); + record.workspace_path = Some(workspace_path.clone()); + atomic_write_json(record_path, record)?; + Ok(workspace_path) +} + +fn validate_complete_archive( + archive_path: &Path, + metadata: &WorkspaceSnapshotMetadata, +) -> Result<()> { + let archive = + fs::symlink_metadata(archive_path).context("inspect complete workspace upload archive")?; + if archive.file_type().is_symlink() || !archive.is_file() { + bail!("workspace upload archive is not a regular file"); + } + if archive.len() != metadata.archive_size { + bail!( + "workspace upload is incomplete: expected {} bytes, received {}", + metadata.archive_size, + archive.len() + ); + } + Ok(()) +} + +fn remove_stale_staging_directories(upload_dir: &Path) -> Result<()> { + for entry in fs::read_dir(upload_dir) + .with_context(|| format!("read workspace upload directory {}", upload_dir.display()))? + { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { + continue; + }; + if !name.starts_with(".staging-") { + continue; + } + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("workspace upload contains an unsafe staging path"); + } + fs::remove_dir_all(&path) + .with_context(|| format!("remove stale workspace staging {}", path.display()))?; + } + Ok(()) +} + +fn validate_begin(request: &DispatchWorkspaceBeginRequest) -> Result<()> { + if request.protocol_version != DISPATCH_PROTOCOL_VERSION { + bail!( + "unsupported dispatch protocolVersion {}; target requires {}", + request.protocol_version, + DISPATCH_PROTOCOL_VERSION + ); + } + super::store::validate_id("jobId", &request.job_id)?; + let metadata = &request.metadata; + if metadata.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION { + bail!("unsupported workspace snapshot format"); + } + if metadata.archive_size == 0 || metadata.archive_size > MAX_SNAPSHOT_ARCHIVE_BYTES { + bail!("workspace snapshot archive size is outside the target limit"); + } + if metadata.file_count > MAX_SNAPSHOT_FILES + || metadata.directory_count > MAX_SNAPSHOT_DIRECTORIES + || metadata.uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES + { + bail!("workspace snapshot summary exceeds target safety limits"); + } + for digest in [&metadata.archive_sha256, &metadata.manifest_sha256] { + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("workspace snapshot metadata contains an invalid SHA-256 digest"); + } + } + Ok(()) +} + +fn validate_committed_workspace( + upload_dir: &Path, + recorded_workspace_path: Option<&str>, +) -> Result { + let current = upload_dir.join(CURRENT_WORKSPACE_DIR); + let metadata = + fs::symlink_metadata(¤t).context("inspect committed dispatch workspace")?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("committed dispatch workspace is not a real directory"); + } + let canonical = current + .canonicalize() + .context("resolve committed dispatch workspace")?; + if recorded_workspace_path.is_some_and(|recorded| Path::new(recorded) != canonical) { + bail!("committed dispatch workspace path no longer matches its durable record"); + } + canonical + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("committed dispatch workspace path is not valid UTF-8")) +} + +fn workspace_upload_lock_path(store: &DispatchStore, job_id: &str) -> PathBuf { + store + .root() + .join("workspaces") + .join(format!(".{job_id}.upload.lock")) +} + +fn truncate_utf8(value: &str) -> String { + if value.len() <= MAX_MATERIALIZATION_ERROR_BYTES { + return value.to_string(); + } + let mut end = MAX_MATERIALIZATION_ERROR_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_services_core::dispatch_workspace::create_exact_workspace_snapshot; + + #[test] + fn validation_rejects_unbounded_or_malformed_uploads() { + let request = DispatchWorkspaceBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + metadata: WorkspaceSnapshotMetadata { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + archive_size: 1, + archive_sha256: "x".repeat(64), + manifest_sha256: "0".repeat(64), + file_count: 0, + directory_count: 0, + uncompressed_bytes: 0, + }, + }; + assert!(validate_begin(&request).is_err()); + } + + #[test] + fn snapshot_fixture_metadata_is_accepted() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("file.txt"), b"hello").expect("file"); + let metadata = + create_exact_workspace_snapshot(&source, &temp.path().join("snapshot.tar.gz")) + .expect("snapshot"); + validate_begin(&DispatchWorkspaceBeginRequest { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + metadata, + }) + .expect("valid metadata"); + } + + #[test] + fn materializer_verifies_and_atomically_publishes_the_uploaded_snapshot() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("file.txt"), b"materialized").expect("source file"); + let source_archive = temp.path().join("source.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &source_archive).expect("snapshot"); + let upload_dir = store.workspace_upload_dir("job-1").expect("upload path"); + create_private_dir(&upload_dir).expect("upload directory"); + fs::copy(&source_archive, upload_dir.join(UPLOAD_ARCHIVE_FILE)).expect("stage archive"); + atomic_write_json( + &upload_dir.join(UPLOAD_RECORD_FILE), + &WorkspaceUploadRecord { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + metadata: metadata.clone(), + state: WorkspaceUploadState::Uploading, + created_at: chrono::Utc::now().to_rfc3339(), + committed_at: None, + workspace_path: None, + last_error: None, + }, + ) + .expect("upload record"); + + materialize_in_store(&store, "job-1").expect("materialize"); + + assert_eq!( + fs::read(upload_dir.join(CURRENT_WORKSPACE_DIR).join("file.txt")) + .expect("materialized file"), + b"materialized" + ); + assert!(!upload_dir.join(UPLOAD_ARCHIVE_FILE).exists()); + let record: WorkspaceUploadRecord = + read_json(&upload_dir.join(UPLOAD_RECORD_FILE)).expect("committed record"); + assert_eq!(record.state, WorkspaceUploadState::Committed); + assert_eq!(record.metadata, metadata); + assert!(record.workspace_path.is_some()); + } + + #[test] + fn materialization_failure_is_persisted_for_commit_pollers() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = DispatchStore::open(temp.path().join("dispatch")).expect("store"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("file.txt"), b"original").expect("source file"); + let source_archive = temp.path().join("source.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &source_archive).expect("snapshot"); + let upload_dir = store.workspace_upload_dir("job-1").expect("upload path"); + create_private_dir(&upload_dir).expect("upload directory"); + let staged_archive = upload_dir.join(UPLOAD_ARCHIVE_FILE); + fs::copy(&source_archive, &staged_archive).expect("stage archive"); + let mut bytes = fs::read(&staged_archive).expect("archive"); + bytes[0] ^= 1; + fs::write(&staged_archive, bytes).expect("tamper archive"); + atomic_write_json( + &upload_dir.join(UPLOAD_RECORD_FILE), + &WorkspaceUploadRecord { + protocol_version: DISPATCH_PROTOCOL_VERSION, + job_id: "job-1".to_string(), + metadata, + state: WorkspaceUploadState::Uploading, + created_at: chrono::Utc::now().to_rfc3339(), + committed_at: None, + workspace_path: None, + last_error: None, + }, + ) + .expect("upload record"); + + materialize_in_store(&store, "job-1").expect_err("tampering must fail"); + + let record: WorkspaceUploadRecord = + read_json(&upload_dir.join(UPLOAD_RECORD_FILE)).expect("failed record"); + assert_eq!(record.state, WorkspaceUploadState::Failed); + assert!(record + .last_error + .as_deref() + .is_some_and(|message| message.contains("SHA-256 mismatch"))); + assert!(ensure_upload_not_failed(&record).is_err()); + } + + #[cfg(unix)] + #[test] + fn committed_workspace_validation_rejects_a_replaced_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let upload = temp.path().join("upload"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&upload).expect("upload"); + fs::create_dir_all(&outside).expect("outside"); + symlink(&outside, upload.join(CURRENT_WORKSPACE_DIR)).expect("replace current"); + + assert!(validate_committed_workspace(&upload, None).is_err()); + } +} diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 654d8312d2..e02dd2770b 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -593,6 +593,21 @@ pub(crate) enum DispatchAction { Cancel, /// List jobs owned by this machine List, + /// Answer a permission request for a remotely supervised job + Answer, + /// Append a steering message to a queued or running job + Append, + #[command(name = "__workspace_begin", hide = true)] + WorkspaceBegin, + #[command(name = "__workspace_chunk", hide = true)] + WorkspaceChunk, + #[command(name = "__workspace_commit", hide = true)] + WorkspaceCommit, + #[command(name = "__workspace_materialize", hide = true)] + WorkspaceMaterialize { + #[arg(long)] + job: String, + }, #[command(name = "__run", hide = true)] Run { #[arg(long)] @@ -1799,6 +1814,21 @@ mod dispatch_command_tests { }) if job == "job-1" )); assert!(is_dispatch_command(&worker.command)); + let materializer = Cli::try_parse_from([ + "bitfun", + "dispatch", + "__workspace_materialize", + "--job", + "job-1", + ]) + .expect("parse internal workspace materializer"); + assert!(matches!( + materializer.command, + Some(Commands::Dispatch { + action: DispatchAction::WorkspaceMaterialize { ref job } + }) if job == "job-1" + )); + assert!(is_dispatch_command(&materializer.command)); let unrelated = Cli::try_parse_from(["bitfun", "config", "show"]).expect("parse config"); assert!(!is_dispatch_command(&unrelated.command)); diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 0061a78b4b..ff4b20d943 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -81,6 +81,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_status", "dispatch_cancel", "dispatch_list_jobs", + "dispatch_answer", + "dispatch_append", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -135,6 +137,8 @@ mod tests { "dispatch_status", "dispatch_cancel", "dispatch_list_jobs", + "dispatch_answer", + "dispatch_append", ] { assert!(is_local_only_command(command), "{command}"); } diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index 614b643b57..3866b43edc 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -56,6 +56,16 @@ async fn handle_host_invoke_inner(command: &str, args: Value) -> HostInvokeBridg return HostInvokeBridgeResult::err("HostInvoke command is empty"); } + // Detached dispatch is target-owned and must not acquire a Peer controller + // lease. Route the distinct target command family directly to the durable + // CLI runner before applying the generic HostInvoke deny list. + if let Some(verb) = dispatch_target_verb(command) { + return match crate::dispatch::run_dispatch_verb(verb, args).await { + Ok(value) => HostInvokeBridgeResult::ok_value(value), + Err(error) => HostInvokeBridgeResult::err(format!("{error:#}")), + }; + } + // Control plane — same special-case path as desktop execute_local_remote_command. if command == "peer_control_attach" { let controller_id = parse_controller_device_id(&args); @@ -109,6 +119,22 @@ async fn handle_host_invoke_inner(command: &str, args: Value) -> HostInvokeBridg } } +fn dispatch_target_verb(command: &str) -> Option<&'static str> { + match command { + "dispatch_target_probe" => Some("probe"), + "dispatch_target_submit" => Some("submit"), + "dispatch_target_status" => Some("status"), + "dispatch_target_cancel" => Some("cancel"), + "dispatch_target_list" => Some("list"), + "dispatch_target_answer" => Some("answer"), + "dispatch_target_append" => Some("append"), + "dispatch_target_workspace_begin" => Some("workspace-begin"), + "dispatch_target_workspace_chunk" => Some("workspace-chunk"), + "dispatch_target_workspace_commit" => Some("workspace-commit"), + _ => None, + } +} + /// Peer-side DeviceEvent is a no-op ack (controller is the consumer). pub(crate) fn handle_device_event_command() -> RemoteResponse { RemoteResponse::DeviceEventAccepted @@ -150,6 +176,16 @@ mod tests { } } + #[test] + fn detached_dispatch_uses_a_distinct_target_command_family() { + assert_eq!( + dispatch_target_verb("dispatch_target_submit"), + Some("submit") + ); + assert_eq!(dispatch_target_verb("dispatch_submit"), None); + assert_eq!(dispatch_target_verb("dispatch_target_unknown"), None); + } + #[tokio::test] async fn attach_detach_updates_subscribers() { let _ = handle_host_invoke( diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 92f9e295aa..cdd487153a 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -27,6 +27,8 @@ use crate::{ ExternalPolicyModeArg, ExternalPolicyScopeArg, SessionAction, }; +const MAX_DISPATCH_STDIN_BYTES: u64 = 2 * 1024 * 1024; + pub(crate) struct ExecCommandArgs { pub message: Option, pub agent: String, @@ -44,20 +46,32 @@ pub(crate) struct ExecCommandArgs { pub(crate) async fn handle_dispatch_action(action: DispatchAction) -> Result<()> { let verb = match action { DispatchAction::Run { job } => return crate::dispatch::run_worker(job).await, + DispatchAction::WorkspaceMaterialize { job } => { + return crate::dispatch::run_workspace_materializer(job) + } DispatchAction::Probe => "probe", DispatchAction::Submit => "submit", DispatchAction::Status => "status", DispatchAction::Cancel => "cancel", DispatchAction::List => "list", + DispatchAction::Answer => "answer", + DispatchAction::Append => "append", + DispatchAction::WorkspaceBegin => "workspace-begin", + DispatchAction::WorkspaceChunk => "workspace-chunk", + DispatchAction::WorkspaceCommit => "workspace-commit", }; let result = async { use std::io::{IsTerminal, Read}; let mut raw = String::new(); - let mut stdin = std::io::stdin(); + let stdin = std::io::stdin(); if !stdin.is_terminal() { stdin + .take(MAX_DISPATCH_STDIN_BYTES + 1) .read_to_string(&mut raw) .context("read dispatch JSON from stdin")?; + if raw.len() as u64 > MAX_DISPATCH_STDIN_BYTES { + anyhow::bail!("dispatch JSON input exceeds the 2 MiB safety limit"); + } } let input = if raw.trim().is_empty() { serde_json::json!({}) diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 710edce099..9ce1527402 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -6,14 +6,19 @@ use std::sync::Arc; +use async_trait::async_trait; use bitfun_core::infrastructure::PathManager; use bitfun_core::service::dispatch::{ - cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, - list_dispatch_targets, poll_dispatch_cli_install, probe_dispatch_target, - start_dispatch_cli_install, submit_dispatch, DispatchConnectionRequest, + answer_device_dispatch, answer_dispatch, append_device_dispatch, append_dispatch, + cancel_device_dispatch, cancel_dispatch, cancel_dispatch_cli_install, + get_device_dispatch_status, get_dispatch_status, list_device_dispatch_jobs, list_dispatch_jobs, + list_dispatch_targets, poll_dispatch_cli_install, probe_device_dispatch_target, + probe_dispatch_target, start_dispatch_cli_install, submit_device_dispatch, submit_dispatch, + DeviceDispatchRpc, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, - DispatchStatusRequest, DispatchSubmitRequest, DispatchTargetOption, OutboundDispatchStore, + DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, DispatchTargetOption, + DispatchTargetRequest, OutboundDispatchStore, }; use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -23,6 +28,54 @@ use tauri::State; use super::app_state::AppState; +struct AccountDeviceDispatchRpc; + +#[async_trait] +impl DeviceDispatchRpc for AccountDeviceDispatchRpc { + async fn invoke(&self, device_id: &str, command: &str, args: Value) -> anyhow::Result { + let command_json = serde_json::to_string(&serde_json::json!({ + "cmd": "host_invoke", + "command": command, + "args": args, + }))?; + let raw = super::remote_connect_api::account_device_rpc( + device_id.to_string(), + command_json, + None, + ) + .await + .map_err(anyhow::Error::msg)?; + decode_device_dispatch_rpc(&raw) + } +} + +fn decode_device_dispatch_rpc(raw: &str) -> anyhow::Result { + let envelope: Value = + serde_json::from_str(raw).map_err(|error| anyhow::anyhow!("decode device RPC: {error}"))?; + match envelope.get("resp").and_then(Value::as_str) { + Some("host_invoke_result") if envelope.get("ok").and_then(Value::as_bool) == Some(true) => { + Ok(envelope.get("value").cloned().unwrap_or(Value::Null)) + } + Some("host_invoke_result") => Err(anyhow::anyhow!( + "{}", + envelope + .get("error") + .and_then(Value::as_str) + .unwrap_or("Device dispatch target rejected the command") + )), + Some("error") => Err(anyhow::anyhow!( + "{}", + envelope + .get("message") + .and_then(Value::as_str) + .unwrap_or("Device dispatch RPC failed") + )), + _ => Err(anyhow::anyhow!( + "Device dispatch target returned an unexpected RPC envelope" + )), + } +} + #[tauri::command] pub async fn dispatch_list_targets( state: State<'_, AppState>, @@ -32,9 +85,30 @@ pub async fn dispatch_list_targets( .get_ssh_manager_async() .await .map_err(|error| error.to_string())?; - list_dispatch_targets(&manager, request) + let mut targets = list_dispatch_targets(&manager, request) .await - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + let current_device_id = super::remote_connect_api::remote_connect_get_device_info() + .await + .ok() + .map(|device| device.device_id); + if let Ok(devices) = super::remote_connect_api::account_list_devices().await { + targets.extend(devices.into_iter().filter_map(|device| { + if current_device_id.as_deref() == Some(device.device_id.as_str()) { + return None; + } + Some(DispatchTargetOption { + kind: "device".to_string(), + connection_id: None, + device_id: Some(device.device_id), + display_name: device.device_name, + description: None, + default_workspace: None, + online: Some(device.online), + }) + })); + } + Ok(targets) } #[tauri::command] @@ -42,6 +116,11 @@ pub async fn dispatch_probe_target( state: State<'_, AppState>, request: DispatchProbeTargetRequest, ) -> Result { + if matches!(&request.target, DispatchTargetRequest::Device { .. }) { + return probe_device_dispatch_target(&AccountDeviceDispatchRpc, request) + .await + .map_err(|error| error.to_string()); + } let manager = state .get_ssh_manager_async() .await @@ -99,6 +178,23 @@ pub async fn dispatch_submit( path_manager: State<'_, Arc>, request: DispatchSubmitRequest, ) -> Result { + if let DispatchTargetRequest::Device { device_id, .. } = &request.target { + let display_name = super::remote_connect_api::account_list_devices() + .await + .map_err(|error| format!("list account devices before dispatch: {error}"))? + .into_iter() + .find(|device| device.device_id == *device_id) + .ok_or_else(|| "Dispatch device is not registered on the current account".to_string()) + .and_then(|device| { + device.online.then_some(device.device_name).ok_or_else(|| { + "Dispatch device is offline; no local fallback was attempted".to_string() + }) + })?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + return submit_device_dispatch(&AccountDeviceDispatchRpc, &store, display_name, request) + .await + .map_err(|error| error.to_string()); + } let manager = state .get_ssh_manager_async() .await @@ -115,11 +211,23 @@ pub async fn dispatch_status( path_manager: State<'_, Arc>, request: DispatchStatusRequest, ) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + if matches!( + store + .get(&request.job_id) + .await + .map_err(|error| error.to_string())? + .map(|record| record.target), + Some(DispatchTarget::Device { .. }) + ) { + return get_device_dispatch_status(&AccountDeviceDispatchRpc, &store, request) + .await + .map_err(|error| error.to_string()); + } let manager = state .get_ssh_manager_async() .await .map_err(|error| error.to_string())?; - let store = OutboundDispatchStore::new(path_manager.as_ref()); get_dispatch_status(&manager, &store, request) .await .map_err(|error| error.to_string()) @@ -131,11 +239,23 @@ pub async fn dispatch_cancel( path_manager: State<'_, Arc>, request: DispatchJobRequest, ) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + if matches!( + store + .get(&request.job_id) + .await + .map_err(|error| error.to_string())? + .map(|record| record.target), + Some(DispatchTarget::Device { .. }) + ) { + return cancel_device_dispatch(&AccountDeviceDispatchRpc, &store, request) + .await + .map_err(|error| error.to_string()); + } let manager = state .get_ssh_manager_async() .await .map_err(|error| error.to_string())?; - let store = OutboundDispatchStore::new(path_manager.as_ref()); cancel_dispatch(&manager, &store, request) .await .map_err(|error| error.to_string()) @@ -147,6 +267,23 @@ pub async fn dispatch_list_jobs( path_manager: State<'_, Arc>, request: DispatchListJobsRequest, ) -> Result { + if let Some(DispatchTargetRequest::Device { device_id, .. }) = &request.target { + let display_name = super::remote_connect_api::account_list_devices() + .await + .map_err(|error| format!("list account devices before observing dispatch: {error}"))? + .into_iter() + .find(|device| device.device_id == *device_id) + .ok_or_else(|| "Dispatch device is not registered on the current account".to_string()) + .and_then(|device| { + device.online.then_some(device.device_name).ok_or_else(|| { + "Dispatch device is offline; its jobs cannot be listed".to_string() + }) + })?; + let store = OutboundDispatchStore::new(path_manager.as_ref()); + return list_device_dispatch_jobs(&AccountDeviceDispatchRpc, &store, display_name, request) + .await + .map_err(|error| error.to_string()); + } let manager = state .get_ssh_manager_async() .await @@ -156,3 +293,81 @@ pub async fn dispatch_list_jobs( .await .map_err(|error| error.to_string()) } + +#[tauri::command] +pub async fn dispatch_answer( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchAnswerRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + if matches!( + store + .get(&request.job_id) + .await + .map_err(|error| error.to_string())? + .map(|record| record.target), + Some(DispatchTarget::Device { .. }) + ) { + return answer_device_dispatch(&AccountDeviceDispatchRpc, &store, request) + .await + .map_err(|error| error.to_string()); + } + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + answer_dispatch(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn dispatch_append( + state: State<'_, AppState>, + path_manager: State<'_, Arc>, + request: DispatchAppendRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + if matches!( + store + .get(&request.job_id) + .await + .map_err(|error| error.to_string())? + .map(|record| record.target), + Some(DispatchTarget::Device { .. }) + ) { + return append_device_dispatch(&AccountDeviceDispatchRpc, &store, request) + .await + .map_err(|error| error.to_string()); + } + let manager = state + .get_ssh_manager_async() + .await + .map_err(|error| error.to_string())?; + append_dispatch(&manager, &store, request) + .await + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::decode_device_dispatch_rpc; + + #[test] + fn device_dispatch_requires_a_correlated_host_invoke_acknowledgement() { + let value = decode_device_dispatch_rpc( + r#"{"resp":"host_invoke_result","ok":true,"value":{"accepted":true,"jobId":"job-1","sessionId":"session-1"}}"#, + ) + .expect("true acknowledgement"); + assert_eq!(value["jobId"], "job-1"); + assert_eq!(value["sessionId"], "session-1"); + + let rejected = decode_device_dispatch_rpc( + r#"{"resp":"host_invoke_result","ok":false,"error":"target rejected"}"#, + ) + .expect_err("negative acknowledgement"); + assert!(rejected.to_string().contains("target rejected")); + assert!(decode_device_dispatch_rpc(r#"{"sent":true}"#).is_err()); + } +} diff --git a/src/apps/desktop/src/api/dispatch_host.rs b/src/apps/desktop/src/api/dispatch_host.rs new file mode 100644 index 0000000000..48484e7083 --- /dev/null +++ b/src/apps/desktop/src/api/dispatch_host.rs @@ -0,0 +1,172 @@ +//! Target-side detached-dispatch adapter for account device RPC. +//! +//! Desktop deliberately delegates execution to the same `bitfun dispatch` +//! runner used by SSH and CLI Peer Host. This keeps one durable job/session +//! owner and avoids creating a second desktop-only dispatch implementation. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{anyhow, Context}; +use serde_json::Value; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +const TARGET_COMMAND_TIMEOUT: Duration = Duration::from_secs(110); +const MAX_TARGET_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +pub(crate) async fn dispatch(command: &str, args: Value) -> anyhow::Result { + let verb = target_cli_verb(command) + .ok_or_else(|| anyhow!("Unknown detached dispatch target command '{command}'"))?; + let executable = discover_cli().ok_or_else(|| { + anyhow!( + "BitFun CLI dispatch runner is not installed on this device; install `bitfun` in ~/.local/bin or PATH" + ) + })?; + invoke_cli(&executable, verb, args).await +} + +pub(crate) fn is_target_command(command: &str) -> bool { + target_cli_verb(command).is_some() +} + +fn target_cli_verb(command: &str) -> Option<&'static str> { + match command { + "dispatch_target_probe" => Some("probe"), + "dispatch_target_submit" => Some("submit"), + "dispatch_target_status" => Some("status"), + "dispatch_target_cancel" => Some("cancel"), + "dispatch_target_list" => Some("list"), + "dispatch_target_answer" => Some("answer"), + "dispatch_target_append" => Some("append"), + "dispatch_target_workspace_begin" => Some("__workspace_begin"), + "dispatch_target_workspace_chunk" => Some("__workspace_chunk"), + "dispatch_target_workspace_commit" => Some("__workspace_commit"), + _ => None, + } +} + +async fn invoke_cli(executable: &Path, verb: &str, args: Value) -> anyhow::Result { + let request = serde_json::to_vec(&args).context("serialize target dispatch request")?; + let mut child = Command::new(executable) + .arg("dispatch") + .arg(verb) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("start dispatch runner {}", executable.display()))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("Dispatch runner stdin was unavailable"))?; + stdin + .write_all(&request) + .await + .context("write target dispatch request")?; + stdin.shutdown().await?; + drop(stdin); + + let output = tokio::time::timeout(TARGET_COMMAND_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| anyhow!("Target dispatch command timed out"))? + .context("wait for target dispatch command")?; + if output.stdout.len() > MAX_TARGET_RESPONSE_BYTES + || output.stderr.len() > MAX_TARGET_RESPONSE_BYTES + { + anyhow::bail!("Target dispatch response exceeded the 4 MiB safety limit"); + } + let stdout = std::str::from_utf8(&output.stdout) + .context("Target dispatch runner returned non-UTF-8 output")? + .trim(); + let response: Value = serde_json::from_str(stdout).with_context(|| { + format!( + "Target dispatch runner returned invalid JSON: {}", + bounded_text(stdout) + ) + })?; + if !output.status.success() { + let message = response + .get("error") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| String::from_utf8_lossy(&output.stderr).trim().to_string()); + anyhow::bail!( + "{}", + if message.is_empty() { + "Target dispatch runner failed".to_string() + } else { + message + } + ); + } + Ok(response) +} + +fn discover_cli() -> Option { + let executable_name = if cfg!(windows) { + "bitfun.exe" + } else { + "bitfun" + }; + let mut candidates = Vec::new(); + if let Some(home) = dirs::home_dir() { + candidates.push(home.join(".local").join("bin").join(executable_name)); + } + if let Ok(current) = std::env::current_exe() { + if let Some(parent) = current.parent() { + candidates.push(parent.join(executable_name)); + } + } + if let Some(path) = std::env::var_os("PATH") { + candidates + .extend(std::env::split_paths(&path).map(|directory| directory.join(executable_name))); + } + candidates.into_iter().find_map(resolve_cli_candidate) +} + +fn resolve_cli_candidate(candidate: PathBuf) -> Option { + let canonical = candidate.canonicalize().ok()?; + std::fs::symlink_metadata(&canonical) + .is_ok_and(|metadata| !metadata.file_type().is_symlink() && metadata.is_file()) + .then_some(canonical) +} + +fn bounded_text(value: &str) -> String { + const MAX_CHARS: usize = 240; + let mut result = value.chars().take(MAX_CHARS).collect::(); + if value.chars().count() > MAX_CHARS { + result.push_str("..."); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outbound_controller_commands_are_not_target_commands() { + assert!(is_target_command("dispatch_target_status")); + assert!(!is_target_command("dispatch_status")); + assert!(!is_target_command("account_device_rpc")); + } + + #[cfg(unix)] + #[test] + fn cli_discovery_accepts_a_symlink_to_a_regular_binary() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let binary = temp.path().join("bitfun-real"); + std::fs::write(&binary, b"binary").expect("binary"); + let link = temp.path().join("bitfun"); + symlink(&binary, &link).expect("symlink"); + + assert_eq!( + resolve_cli_candidate(link), + Some(binary.canonicalize().expect("canonical binary")) + ); + } +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 8aac084989..8b471682bc 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -18,6 +18,7 @@ pub mod custom_agent_api; pub mod debug_api; pub mod diff_api; pub mod dispatch_api; +pub(crate) mod dispatch_host; pub mod dto; pub mod editor_ai_api; pub mod external_hooks_api; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 28738b7dbd..c936838df0 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -104,6 +104,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_status", "dispatch_cancel", "dispatch_list_jobs", + "dispatch_answer", + "dispatch_append", // One-click relay deploy SSHes from the controller to a user host "relay_deploy_preflight", "relay_deploy_install_docker", diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 040a18c0eb..497bea7f5d 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -2608,7 +2608,7 @@ pub async fn account_connect_devices() -> Result, String> session_id, content, agent_type, - workspace_path: _, + workspace_path, }) => { let Some(_routing_effect) = lock_current_device_routing(&event_owner).await @@ -2630,7 +2630,17 @@ pub async fn account_connect_devices() -> Result, String> let policy = DialogSubmissionPolicy::for_source( DialogTriggerSource::RemoteRelay, ); - let wp = resolve_local_workspace_path(); + let wp = match resolve_requested_local_workspace_path( + workspace_path.as_deref(), + ) { + Ok(path) => path, + Err(error) => { + log::warn!( + "ExecuteOnDevice rejected invalid workspace: {error}" + ); + continue; + } + }; let agent = agent_type.unwrap_or_else(|| "agentic".to_string()); if let Err(e) = scheduler @@ -4206,20 +4216,28 @@ async fn export_and_upload_session( Ok(Some(hash)) } -/// Resolve the local workspace path for task execution. Returns the first -/// project directory found under ~/.bitfun/projects/, or "/" as fallback. -fn resolve_local_workspace_path() -> String { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")); - let projects = home.join(".bitfun").join("projects"); - if let Ok(entries) = std::fs::read_dir(&projects) { - for entry in entries.flatten() { - if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { - // Return the workspace slug dir path as a string - return entry.path().to_string_lossy().to_string(); - } - } +/// The legacy one-way execution command is path-addressed. It must never +/// silently choose an unrelated local project when the sender omitted or +/// mistyped the target path. +fn resolve_requested_local_workspace_path(workspace_path: Option<&str>) -> Result { + let requested = workspace_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| "workspace_path is required".to_string())?; + let path = std::path::PathBuf::from(requested); + if !path.is_absolute() { + return Err("workspace_path must be absolute".to_string()); + } + let canonical = path + .canonicalize() + .map_err(|error| format!("resolve workspace_path: {error}"))?; + if !canonical.is_dir() { + return Err("workspace_path is not a directory".to_string()); } - "/".to_string() + canonical + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| "workspace_path is not valid UTF-8".to_string()) } /// Execute a RemoteCommand locally (for RPC requests from other devices). @@ -4231,6 +4249,19 @@ async fn execute_local_remote_command( match cmd { RemoteCommand::HostInvoke { command, args } => { + // Detached jobs are independent of Peer controller attachment. + // Route their distinct target command family directly to the + // durable dispatch runner before the generic webview bridge. + if crate::api::dispatch_host::is_target_command(command) { + let result = crate::api::dispatch_host::dispatch(command, args.clone()).await; + let (ok, value, error) = match result { + Ok(value) => (true, Some(value), None), + Err(error) => (false, None, Some(format!("{error:#}"))), + }; + return serde_json::to_value(RemoteResponse::HostInvokeResult { ok, value, error }) + .map_err(|e| anyhow::anyhow!("serialize response: {e}")); + } + // Control-plane peer attach/detach/ping can run without webview bridge. if command == "peer_control_attach" { let controller_id = args diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index fd3102307a..4aa2634a63 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -365,6 +365,8 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "dispatch_probe_target", RemoteWorkspacePolicy::WorkspaceAgnostic, ), + ("dispatch_answer", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("dispatch_append", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_submit", RemoteWorkspacePolicy::WorkspaceAgnostic), ( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 8aa67d340c..6cbc766a58 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1751,6 +1751,8 @@ pub async fn run() { api::dispatch_api::dispatch_status, api::dispatch_api::dispatch_cancel, api::dispatch_api::dispatch_list_jobs, + api::dispatch_api::dispatch_answer, + api::dispatch_api::dispatch_append, // Relay self-deploy API api::relay_deploy_api::relay_deploy_preflight, api::relay_deploy_api::relay_deploy_install_docker, diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 14f96e96f9..65b3818345 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -8,12 +8,13 @@ use bitfun_core::external_sources::{ ExternalSourceOperationError, ExternalSourceOperationErrorCode, ExternalSourceOperationResult, }; use bitfun_core::service::dispatch::{ - cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, - list_dispatch_targets, poll_dispatch_cli_install, probe_dispatch_target, - start_dispatch_cli_install, submit_dispatch, DispatchConnectionRequest, - DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, - DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, - DispatchStatusRequest, DispatchSubmitRequest, OutboundDispatchStore, + answer_dispatch, append_dispatch, cancel_dispatch, cancel_dispatch_cli_install, + get_dispatch_status, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, + probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, DispatchAnswerRequest, + DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, + DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, + DispatchSubmitRequest, OutboundDispatchStore, }; use serde::de::DeserializeOwned; @@ -31,6 +32,8 @@ pub(crate) fn supports(method: &str) -> bool { | "dispatch_status" | "dispatch_cancel" | "dispatch_list_jobs" + | "dispatch_answer" + | "dispatch_append" ) } @@ -111,6 +114,18 @@ pub(crate) async fn dispatch( .await .map_err(operation_error) } + "dispatch_answer" => { + let request = parse_request::(¶ms)?; + answer_dispatch(&host.ssh_manager, &store(host), request) + .await + .map_err(operation_error) + } + "dispatch_append" => { + let request = parse_request::(¶ms)?; + append_dispatch(&host.ssh_manager, &store(host), request) + .await + .map_err(operation_error) + } _ => Err(ExternalSourceOperationError::host_capability_unavailable( "Unknown detached dispatch operation", )), @@ -168,6 +183,8 @@ mod tests { "dispatch_status", "dispatch_cancel", "dispatch_list_jobs", + "dispatch_answer", + "dispatch_append", ] { assert!(supports(method), "{method}"); } diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 4aab1a7f07..8eaa92d5a3 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -96,6 +96,7 @@ bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-fea # Core service owner crate bitfun-services-core = { path = "../../services/services-core", default-features = false, features = [ + "dispatch-workspace", "lsp", "markdown", "permission", diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 99bb6482a5..a2787d7561 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -28,7 +28,7 @@ use bitfun_runtime_services::RuntimeServices; use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; use crate::agentic::coordination::{ - ConversationCoordinator, DialogScheduler, SessionMaintenancePermit, + ConversationCoordinator, DialogScheduler, DialogSteerOutcome, SessionMaintenancePermit, }; use crate::agentic::core::Session; use crate::agentic::events::EventQueue; @@ -512,6 +512,22 @@ impl CoreAgentRuntimeCompatibility { } } + /// Buffer a user steering message into a currently running turn. + /// + /// Detached dispatch and compatibility hosts use this narrow facade so + /// they do not reach through the public Runtime SDK into scheduler state. + pub async fn submit_steering( + &self, + session_id: String, + turn_id: String, + content: String, + display_content: Option, + ) -> Result { + self.scheduler + .submit_steering(session_id, turn_id, content, display_content) + .await + } + /// Applies the same Core deployment owner before a product compatibility /// path attaches to or mutates a structured workspace scope. pub fn ensure_workspace_runtime_ownership( diff --git a/src/crates/assembly/core/src/service/dispatch/controller.rs b/src/crates/assembly/core/src/service/dispatch/controller.rs index 0291b897a3..9696a2fc17 100644 --- a/src/crates/assembly/core/src/service/dispatch/controller.rs +++ b/src/crates/assembly/core/src/service/dispatch/controller.rs @@ -7,9 +7,13 @@ use bitfun_services_integrations::remote_ssh::{ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use super::{DispatchTarget, DispatchTargetRequest, OutboundDispatchRecord, OutboundDispatchStore}; +use super::{ + adopt_target_jobs, DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest, + OutboundDispatchRecord, OutboundDispatchStore, +}; -const DISPATCH_PROTOCOL_VERSION: u64 = 1; +pub(super) const DISPATCH_PROTOCOL_VERSION: u64 = 2; +pub(super) const MAX_DISPATCH_TEXT_BYTES: usize = 32 * 1024; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -46,6 +50,8 @@ pub struct DispatchInstallPollRequest { #[serde(rename_all = "camelCase")] pub struct DispatchSubmitRequest { pub target: DispatchTargetRequest, + #[serde(default)] + pub workspace_delivery: DispatchWorkspaceDeliveryRequest, pub job_id: String, pub session_id: String, pub agent_type: String, @@ -71,6 +77,34 @@ pub struct DispatchJobRequest { pub job_id: String, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DispatchPermissionReplyKind { + Once, + Always, + Reject, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchAnswerRequest { + pub job_id: String, + pub request_id: String, + pub reply: DispatchPermissionReplyKind, + #[serde(default)] + pub feedback: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchAppendRequest { + pub job_id: String, + pub message_id: String, + pub content: String, + #[serde(default)] + pub display_content: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DispatchListJobsRequest { @@ -84,11 +118,15 @@ pub struct DispatchTargetOption { pub kind: String, #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_id: Option, pub display_name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub online: Option, } pub async fn list_targets( @@ -98,9 +136,11 @@ pub async fn list_targets( let mut targets = vec![DispatchTargetOption { kind: "local".to_string(), connection_id: None, + device_id: None, display_name: "Local".to_string(), description: None, default_workspace: None, + online: None, }]; targets.extend( manager @@ -110,12 +150,14 @@ pub async fn list_targets( .map(|connection| DispatchTargetOption { kind: "ssh".to_string(), connection_id: Some(connection.id), + device_id: None, display_name: connection.name, description: Some(format!( "{}@{}:{}", connection.username, connection.host, connection.port )), default_workspace: connection.default_workspace, + online: None, }), ); Ok(targets) @@ -130,7 +172,7 @@ pub async fn probe_target( workspace_path, } = request.target else { - anyhow::bail!("Phase-one dispatch probing supports SSH targets only"); + anyhow::bail!("SSH dispatch probing requires an SSH target"); }; dispatch_ssh::probe(manager, &connection_id, nonempty(&workspace_path)).await } @@ -161,28 +203,27 @@ pub async fn submit( store: &OutboundDispatchStore, request: DispatchSubmitRequest, ) -> anyhow::Result { - if !matches!( - request.approval_policy.as_str(), - "auto" | "reject-and-report" - ) { - anyhow::bail!( - "Dispatch approvalPolicy must be explicitly set to auto or reject-and-report" - ); - } - if request.prompt.trim().is_empty() { - anyhow::bail!("Dispatch prompt cannot be empty"); - } + validate_submit_request(&request)?; let DispatchTargetRequest::Ssh { connection_id, - workspace_path, + workspace_path: requested_workspace_path, } = &request.target else { - anyhow::bail!("Phase-one dispatch submission supports SSH targets only"); + anyhow::bail!("SSH dispatch submission requires an SSH target"); }; - if connection_id.trim().is_empty() || workspace_path.trim().is_empty() { - anyhow::bail!("SSH dispatch requires a connectionId and workspacePath"); + if connection_id.trim().is_empty() { + anyhow::bail!("SSH dispatch requires a connectionId"); } + let workspace_path = resolve_ssh_workspace( + manager, + store, + connection_id, + requested_workspace_path, + &request.workspace_delivery, + &request.job_id, + ) + .await?; // Re-check the executable that will receive this submission. The picker // probe can be stale, and headless callers can bypass the UI entirely. @@ -200,6 +241,12 @@ pub async fn submit( })?; dispatch_ssh::validate_dispatch_protocol(protocol, Some(&request.approval_policy))?; validate_submission_preflight(protocol, request.model.as_deref())?; + let workspace_path = protocol + .pointer("/workspace/path") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .unwrap_or(workspace_path.as_str()) + .to_string(); let display_name = manager .get_saved_connections() @@ -221,7 +268,13 @@ pub async fn submit( workspace_path.clone(), &request.prompt, "submitting", - )?; + )? + .with_submission_metadata( + request.title.clone(), + request.agent_type.clone(), + request.approval_policy.clone(), + request.model.clone(), + ); let bound_record = store.bind_if_absent(&requested_record).await?; if bound_record.session_id != request.session_id || !same_target_identity(&bound_record.target, &requested_record.target) @@ -274,6 +327,60 @@ pub async fn submit( Ok(response) } +async fn resolve_ssh_workspace( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + connection_id: &str, + requested_workspace_path: &str, + delivery: &DispatchWorkspaceDeliveryRequest, + job_id: &str, +) -> anyhow::Result { + match delivery { + DispatchWorkspaceDeliveryRequest::Existing => { + let workspace_path = requested_workspace_path.trim(); + if workspace_path.is_empty() { + anyhow::bail!("existing SSH dispatch requires a workspacePath"); + } + Ok(workspace_path.to_string()) + } + DispatchWorkspaceDeliveryRequest::SnapshotExact { + source_workspace_path, + sensitive_files_confirmed, + } => { + if !sensitive_files_confirmed { + anyhow::bail!( + "exact workspace snapshot requires confirmation that ignored and sensitive files may be transferred" + ); + } + let prepared = store + .prepare_workspace_snapshot(job_id, source_workspace_path) + .await?; + let begin_request = json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": job_id, + "metadata": prepared.metadata, + }); + let committed = dispatch_ssh::upload_workspace_snapshot( + manager, + connection_id, + &begin_request, + &prepared.archive_path, + ) + .await?; + committed + .get("workspacePath") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + anyhow::anyhow!( + "dispatch target did not return the materialized workspace path" + ) + }) + } + } +} + pub async fn status( manager: &SSHConnectionManager, store: &OutboundDispatchStore, @@ -284,7 +391,7 @@ pub async fn status( .await? .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; let DispatchTarget::Ssh { connection_id, .. } = &record.target else { - anyhow::bail!("Phase-one dispatch status supports SSH targets only"); + anyhow::bail!("SSH dispatch status requires an SSH target"); }; let response = dispatch_ssh::status( manager, @@ -304,6 +411,9 @@ pub async fn status( store .update_progress(&record.job_id, request.cursor, state) .await?; + // A successful status proves that the target durably owns the job and its + // materialized snapshot. The controller no longer needs the source archive. + let _ = store.remove_workspace_snapshot(&record.job_id).await; Ok(response) } @@ -317,7 +427,7 @@ pub async fn cancel( .await? .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; let DispatchTarget::Ssh { connection_id, .. } = &record.target else { - anyhow::bail!("Phase-one dispatch cancellation supports SSH targets only"); + anyhow::bail!("SSH dispatch cancellation requires an SSH target"); }; let response = dispatch_ssh::cancel(manager, connection_id, &json!({ "jobId": request.job_id })).await?; @@ -333,6 +443,46 @@ pub async fn cancel( Ok(response) } +pub async fn answer( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchAnswerRequest, +) -> anyhow::Result { + validate_answer_request(&request)?; + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("SSH dispatch permission answers require an SSH target"); + }; + let mut payload = json!({ + "jobId": request.job_id, + "requestId": request.request_id, + "reply": request.reply, + }); + if let Some(feedback) = request.feedback.filter(|value| !value.trim().is_empty()) { + payload["feedback"] = Value::String(feedback); + } + dispatch_ssh::answer(manager, connection_id, &payload).await +} + +pub async fn append( + manager: &SSHConnectionManager, + store: &OutboundDispatchStore, + request: DispatchAppendRequest, +) -> anyhow::Result { + validate_append_request(&request)?; + let record = store + .get(&request.job_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Outbound dispatch job was not found"))?; + let DispatchTarget::Ssh { connection_id, .. } = &record.target else { + anyhow::bail!("SSH dispatch message append requires an SSH target"); + }; + dispatch_ssh::append(manager, connection_id, &serde_json::to_value(request)?).await +} + pub async fn list_jobs( manager: &SSHConnectionManager, store: &OutboundDispatchStore, @@ -342,12 +492,89 @@ pub async fn list_jobs( return Ok(serde_json::to_value(store.list().await?)?); }; let DispatchTargetRequest::Ssh { connection_id, .. } = target else { - anyhow::bail!("Phase-one dispatch listing supports SSH targets only"); + anyhow::bail!("SSH dispatch listing requires an SSH target"); }; - dispatch_ssh::list(manager, &connection_id, &json!({})).await + let display_name = manager + .get_saved_connections() + .await + .into_iter() + .find(|connection| connection.id == connection_id) + .map(|connection| connection.name) + .unwrap_or_else(|| connection_id.clone()); + let response = dispatch_ssh::list(manager, &connection_id, &json!({})).await?; + adopt_target_jobs( + store, + &DispatchTarget::Ssh { + connection_id, + workspace_path: String::new(), + display_name, + }, + &response, + ) + .await?; + Ok(response) +} + +pub(super) fn validate_submit_request(request: &DispatchSubmitRequest) -> anyhow::Result<()> { + if !matches!( + request.approval_policy.as_str(), + "auto" | "reject-and-report" | "remote" + ) { + anyhow::bail!( + "Dispatch approvalPolicy must be explicitly set to auto, reject-and-report, or remote" + ); + } + if request.job_id.trim().is_empty() || request.session_id.trim().is_empty() { + anyhow::bail!("Dispatch jobId and sessionId cannot be empty"); + } + if request.agent_type.trim().is_empty() { + anyhow::bail!("Dispatch agentType cannot be empty"); + } + if request.prompt.trim().is_empty() { + anyhow::bail!("Dispatch prompt cannot be empty"); + } + if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES { + anyhow::bail!("Dispatch prompt exceeds the 32 KiB request limit"); + } + Ok(()) } -fn validate_submit_ack(response: &Value, job_id: &str, session_id: &str) -> anyhow::Result<()> { +pub(super) fn validate_append_request(request: &DispatchAppendRequest) -> anyhow::Result<()> { + if request.message_id.trim().is_empty() || request.message_id.len() > 128 { + anyhow::bail!("Dispatch messageId must contain 1-128 bytes"); + } + if request.content.trim().is_empty() { + anyhow::bail!("Dispatch appended message cannot be empty"); + } + let total_bytes = request + .content + .len() + .saturating_add(request.display_content.as_ref().map_or(0, String::len)); + if total_bytes > MAX_DISPATCH_TEXT_BYTES { + anyhow::bail!("Dispatch appended message exceeds the 32 KiB request limit"); + } + Ok(()) +} + +pub(super) fn validate_answer_request(request: &DispatchAnswerRequest) -> anyhow::Result<()> { + if request.request_id.trim().is_empty() || request.request_id.len() > 512 { + anyhow::bail!("Dispatch permission requestId is invalid"); + } + if request + .feedback + .as_ref() + .is_some_and(|feedback| feedback.len() > MAX_DISPATCH_TEXT_BYTES) + { + anyhow::bail!("Dispatch permission feedback exceeds the 32 KiB request limit"); + } + Ok(()) +} + +pub(super) fn validate_submit_ack( + response: &Value, + job_id: &str, + session_id: &str, +) -> anyhow::Result<()> { if response.get("accepted").and_then(Value::as_bool) != Some(true) { anyhow::bail!("Dispatch target did not accept the job"); } @@ -359,7 +586,7 @@ fn validate_submit_ack(response: &Value, job_id: &str, session_id: &str) -> anyh Ok(()) } -fn same_target_identity(left: &DispatchTarget, right: &DispatchTarget) -> bool { +pub(super) fn same_target_identity(left: &DispatchTarget, right: &DispatchTarget) -> bool { match (left, right) { (DispatchTarget::Local, DispatchTarget::Local) => true, ( @@ -390,7 +617,7 @@ fn same_target_identity(left: &DispatchTarget, right: &DispatchTarget) -> bool { } } -fn validate_submission_preflight( +pub(super) fn validate_submission_preflight( protocol: &Value, requested_model: Option<&str>, ) -> anyhow::Result<()> { diff --git a/src/crates/assembly/core/src/service/dispatch/device_controller.rs b/src/crates/assembly/core/src/service/dispatch/device_controller.rs new file mode 100644 index 0000000000..c745f2b632 --- /dev/null +++ b/src/crates/assembly/core/src/service/dispatch/device_controller.rs @@ -0,0 +1,500 @@ +use std::path::Path; + +use anyhow::{anyhow, Context}; +use async_trait::async_trait; +use base64::Engine as _; +use bitfun_services_integrations::remote_ssh::dispatch_ssh::{self, DispatchSshProbe}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use super::controller::{ + same_target_identity, validate_answer_request, validate_append_request, + validate_submission_preflight, validate_submit_ack, validate_submit_request, + DispatchAnswerRequest, DispatchAppendRequest, DispatchJobRequest, DispatchListJobsRequest, + DispatchProbeTargetRequest, DispatchStatusRequest, DispatchSubmitRequest, + DISPATCH_PROTOCOL_VERSION, +}; +use super::{ + adopt_target_jobs, DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest, + OutboundDispatchRecord, OutboundDispatchStore, +}; + +const DEVICE_WORKSPACE_CHUNK_BYTES: usize = 256 * 1024; +const DEVICE_WORKSPACE_COMMIT_POLL_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(750); +const DEVICE_WORKSPACE_COMMIT_WAIT: std::time::Duration = std::time::Duration::from_secs(15 * 60); + +/// Account-device routing is a platform adapter. The product controller owns +/// dispatch semantics while Desktop supplies the encrypted Relay RPC. +#[async_trait] +pub trait DeviceDispatchRpc: Send + Sync { + async fn invoke(&self, device_id: &str, command: &str, args: Value) -> anyhow::Result; +} + +pub async fn probe_device( + rpc: &dyn DeviceDispatchRpc, + request: DispatchProbeTargetRequest, +) -> anyhow::Result { + let DispatchTargetRequest::Device { + device_id, + workspace_path, + } = request.target + else { + anyhow::bail!("Device dispatch probe requires a device target"); + }; + if device_id.trim().is_empty() { + anyhow::bail!("Device dispatch requires a deviceId"); + } + let args = if workspace_path.trim().is_empty() { + json!({}) + } else { + json!({ "workspacePath": workspace_path }) + }; + let protocol = rpc + .invoke(&device_id, "dispatch_target_probe", args) + .await?; + let protocol_error = dispatch_ssh::validate_dispatch_protocol(&protocol, None) + .err() + .map(|error| error.to_string()); + Ok(DispatchSshProbe { + cli_installed: true, + cli_path: None, + os: protocol + .get("os") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + arch: protocol + .get("arch") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + install_supported: false, + install_error: None, + protocol_error, + release: None, + protocol: Some(protocol), + }) +} + +pub async fn submit_device( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + display_name: String, + request: DispatchSubmitRequest, +) -> anyhow::Result { + validate_submit_request(&request)?; + let DispatchTargetRequest::Device { + device_id, + workspace_path: requested_workspace_path, + } = &request.target + else { + anyhow::bail!("Device dispatch submission requires a device target"); + }; + if device_id.trim().is_empty() { + anyhow::bail!("Device dispatch requires a deviceId"); + } + + let workspace_path = resolve_device_workspace( + rpc, + store, + device_id, + requested_workspace_path, + &request.workspace_delivery, + &request.job_id, + ) + .await?; + let protocol = rpc + .invoke( + device_id, + "dispatch_target_probe", + json!({ "workspacePath": workspace_path }), + ) + .await + .context("probe device immediately before dispatch submission")?; + dispatch_ssh::validate_dispatch_protocol(&protocol, Some(&request.approval_policy))?; + validate_submission_preflight(&protocol, request.model.as_deref())?; + let workspace_path = protocol + .pointer("/workspace/path") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| anyhow!("Device dispatch target returned no canonical workspace path"))? + .to_string(); + + let resolved_target = DispatchTarget::Device { + device_id: device_id.clone(), + workspace_path: workspace_path.clone(), + display_name, + }; + let requested_record = OutboundDispatchRecord::new( + request.job_id.clone(), + resolved_target, + request.session_id.clone(), + workspace_path.clone(), + &request.prompt, + "submitting", + )? + .with_submission_metadata( + request.title.clone(), + request.agent_type.clone(), + request.approval_policy.clone(), + request.model.clone(), + ); + let bound_record = store.bind_if_absent(&requested_record).await?; + if bound_record.session_id != request.session_id + || !same_target_identity(&bound_record.target, &requested_record.target) + { + anyhow::bail!("Dispatch jobId is already bound to another target or session"); + } + + let mut payload = json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": request.job_id, + "sessionId": request.session_id, + "workspacePath": workspace_path, + "agentType": request.agent_type, + "prompt": request.prompt, + "approvalPolicy": request.approval_policy, + }); + if let Some(model) = request.model.filter(|value| !value.trim().is_empty()) { + payload["model"] = Value::String(model); + } + if let Some(title) = request.title.filter(|value| !value.trim().is_empty()) { + payload["title"] = Value::String(title); + } + + let response = match rpc + .invoke(device_id, "dispatch_target_submit", payload) + .await + { + Ok(response) => response, + Err(error) => { + let _ = store + .update_progress(&requested_record.job_id, 0, "submission_unknown") + .await; + return Err(error); + } + }; + if let Err(error) = validate_submit_ack( + &response, + &requested_record.job_id, + &requested_record.session_id, + ) { + let _ = store + .update_progress(&requested_record.job_id, 0, "submission_unknown") + .await; + return Err(error); + } + let state = response + .get("state") + .and_then(Value::as_str) + .unwrap_or("queued") + .to_string(); + store + .update_progress(&requested_record.job_id, 0, state) + .await?; + Ok(response) +} + +pub async fn status_device( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchStatusRequest, +) -> anyhow::Result { + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + let response = rpc + .invoke( + device_id, + "dispatch_target_status", + json!({ "jobId": request.job_id, "cursor": request.cursor }), + ) + .await?; + let state = response + .get("state") + .and_then(Value::as_str) + .unwrap_or(record.last_state.as_str()) + .to_string(); + store + .update_progress(&record.job_id, request.cursor, state) + .await?; + let _ = store.remove_workspace_snapshot(&record.job_id).await; + Ok(response) +} + +pub async fn cancel_device( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchJobRequest, +) -> anyhow::Result { + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + let response = rpc + .invoke( + device_id, + "dispatch_target_cancel", + json!({ "jobId": request.job_id }), + ) + .await?; + if response + .get("cancelled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + store + .update_progress(&record.job_id, record.last_cursor, "cancelled") + .await?; + } + Ok(response) +} + +pub async fn answer_device( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchAnswerRequest, +) -> anyhow::Result { + validate_answer_request(&request)?; + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + rpc.invoke( + device_id, + "dispatch_target_answer", + serde_json::to_value(request)?, + ) + .await +} + +pub async fn append_device( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + request: DispatchAppendRequest, +) -> anyhow::Result { + validate_append_request(&request)?; + let record = load_device_record(store, &request.job_id).await?; + let DispatchTarget::Device { device_id, .. } = &record.target else { + unreachable!("load_device_record validates target kind") + }; + rpc.invoke( + device_id, + "dispatch_target_append", + serde_json::to_value(request)?, + ) + .await +} + +pub async fn list_device_jobs( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + display_name: String, + request: DispatchListJobsRequest, +) -> anyhow::Result { + let Some(DispatchTargetRequest::Device { device_id, .. }) = request.target else { + anyhow::bail!("Device dispatch listing requires a device target"); + }; + let response = rpc + .invoke(&device_id, "dispatch_target_list", json!({})) + .await?; + adopt_target_jobs( + store, + &DispatchTarget::Device { + device_id, + workspace_path: String::new(), + display_name, + }, + &response, + ) + .await?; + Ok(response) +} + +async fn resolve_device_workspace( + rpc: &dyn DeviceDispatchRpc, + store: &OutboundDispatchStore, + device_id: &str, + requested_workspace_path: &str, + delivery: &DispatchWorkspaceDeliveryRequest, + job_id: &str, +) -> anyhow::Result { + match delivery { + DispatchWorkspaceDeliveryRequest::Existing => { + let path = requested_workspace_path.trim(); + if path.is_empty() { + anyhow::bail!("existing device dispatch requires a workspacePath"); + } + Ok(path.to_string()) + } + DispatchWorkspaceDeliveryRequest::SnapshotExact { + source_workspace_path, + sensitive_files_confirmed, + } => { + if !sensitive_files_confirmed { + anyhow::bail!( + "exact workspace snapshot requires confirmation that ignored and sensitive files may be transferred" + ); + } + let prepared = store + .prepare_workspace_snapshot(job_id, source_workspace_path) + .await?; + upload_device_workspace( + rpc, + device_id, + job_id, + &prepared.archive_path, + &prepared.metadata, + ) + .await + } + } +} + +async fn upload_device_workspace( + rpc: &dyn DeviceDispatchRpc, + device_id: &str, + job_id: &str, + archive_path: &Path, + metadata: &bitfun_services_core::dispatch_workspace::WorkspaceSnapshotMetadata, +) -> anyhow::Result { + let begin = rpc + .invoke( + device_id, + "dispatch_target_workspace_begin", + json!({ + "protocolVersion": DISPATCH_PROTOCOL_VERSION, + "jobId": job_id, + "metadata": metadata, + }), + ) + .await?; + if begin + .get("committed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return required_workspace_path(&begin); + } + if begin.get("accepted").and_then(Value::as_bool) != Some(true) { + anyhow::bail!("Device dispatch target did not accept the workspace snapshot"); + } + let mut offset = begin + .get("offset") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("Device dispatch target returned no workspace upload offset"))?; + if offset > metadata.archive_size { + anyhow::bail!("Device dispatch target returned an invalid workspace upload offset"); + } + + let mut archive = tokio::fs::File::open(archive_path) + .await + .with_context(|| format!("open workspace snapshot {}", archive_path.display()))?; + archive.seek(std::io::SeekFrom::Start(offset)).await?; + let mut buffer = vec![0_u8; DEVICE_WORKSPACE_CHUNK_BYTES]; + while offset < metadata.archive_size { + let remaining = (metadata.archive_size - offset) as usize; + let read_limit = remaining.min(buffer.len()); + let read = archive.read(&mut buffer[..read_limit]).await?; + if read == 0 { + anyhow::bail!( + "Workspace snapshot ended at {offset} of {} bytes", + metadata.archive_size + ); + } + let next_offset = offset + read as u64; + let response = rpc + .invoke( + device_id, + "dispatch_target_workspace_chunk", + json!({ + "jobId": job_id, + "offset": offset, + "dataBase64": base64::engine::general_purpose::STANDARD.encode(&buffer[..read]), + }), + ) + .await?; + if response.get("accepted").and_then(Value::as_bool) != Some(true) + || response.get("offset").and_then(Value::as_u64) != Some(next_offset) + { + anyhow::bail!( + "Device dispatch target returned a mismatched workspace chunk acknowledgement" + ); + } + offset = next_offset; + } + + let deadline = tokio::time::Instant::now() + DEVICE_WORKSPACE_COMMIT_WAIT; + loop { + let committed = rpc + .invoke( + device_id, + "dispatch_target_workspace_commit", + json!({ "jobId": job_id }), + ) + .await?; + if committed + .pointer("/metadata/archiveSha256") + .and_then(Value::as_str) + != Some(metadata.archive_sha256.as_str()) + { + anyhow::bail!("Device dispatch target returned mismatched workspace snapshot metadata"); + } + if committed.get("committed").and_then(Value::as_bool) == Some(true) { + return required_workspace_path(&committed); + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!( + "Device dispatch target workspace materialization did not finish within 15 minutes" + ); + } + tokio::time::sleep(DEVICE_WORKSPACE_COMMIT_POLL_INTERVAL).await; + } +} + +fn required_workspace_path(response: &Value) -> anyhow::Result { + response + .get("workspacePath") + .and_then(Value::as_str) + .filter(|path| !path.trim().is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("Device dispatch target returned no materialized workspace path")) +} + +async fn load_device_record( + store: &OutboundDispatchStore, + job_id: &str, +) -> anyhow::Result { + let record = store + .get(job_id) + .await? + .ok_or_else(|| anyhow!("Outbound dispatch job was not found"))?; + if !matches!(record.target, DispatchTarget::Device { .. }) { + anyhow::bail!("Outbound dispatch job is not bound to a device target"); + } + Ok(record) +} + +#[cfg(test)] +mod tests { + #[test] + fn target_command_names_are_separate_from_outbound_commands() { + for command in [ + "dispatch_target_probe", + "dispatch_target_submit", + "dispatch_target_status", + "dispatch_target_cancel", + "dispatch_target_list", + "dispatch_target_answer", + "dispatch_target_append", + "dispatch_target_workspace_begin", + "dispatch_target_workspace_chunk", + "dispatch_target_workspace_commit", + ] { + assert!(command.starts_with("dispatch_target_")); + assert_ne!(command, "dispatch_submit"); + } + } +} diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index 7b15356865..4910e41ecf 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -1,9 +1,15 @@ #[cfg(feature = "ssh-remote")] mod controller; +#[cfg(feature = "ssh-remote")] +mod device_controller; mod target; use std::path::{Path, PathBuf}; +use anyhow::Context as _; +use bitfun_services_core::dispatch_workspace::{ + create_exact_workspace_snapshot, sha256_file, WorkspaceSnapshotMetadata, +}; use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -14,18 +20,60 @@ use crate::infrastructure::PathManager; #[cfg(feature = "ssh-remote")] pub use controller::{ - cancel as cancel_dispatch, install_cli_cancel as cancel_dispatch_cli_install, + answer as answer_dispatch, append as append_dispatch, cancel as cancel_dispatch, + install_cli_cancel as cancel_dispatch_cli_install, install_cli_poll as poll_dispatch_cli_install, install_cli_start as start_dispatch_cli_install, list_jobs as list_dispatch_jobs, list_targets as list_dispatch_targets, probe_target as probe_dispatch_target, status as get_dispatch_status, - submit as submit_dispatch, DispatchConnectionRequest, DispatchInstallPollRequest, - DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, - DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, + submit as submit_dispatch, DispatchAnswerRequest, DispatchAppendRequest, + DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, + DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, + DispatchPermissionReplyKind, DispatchProbeTargetRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchTargetOption, }; -pub use target::{DispatchTarget, DispatchTargetRequest}; +#[cfg(feature = "ssh-remote")] +pub use device_controller::{ + answer_device as answer_device_dispatch, append_device as append_device_dispatch, + cancel_device as cancel_device_dispatch, list_device_jobs as list_device_dispatch_jobs, + probe_device as probe_device_dispatch_target, status_device as get_device_dispatch_status, + submit_device as submit_device_dispatch, DeviceDispatchRpc, +}; +pub use target::{DispatchTarget, DispatchTargetRequest, DispatchWorkspaceDeliveryRequest}; const PROMPT_PREVIEW_CHARS: usize = 160; +const OUTBOUND_WORKSPACE_UPLOADS_DIR: &str = ".workspace-uploads"; +const TERMINAL_OUTBOUND_RETENTION_DAYS: i64 = 30; + +#[derive(Debug, Clone)] +pub struct PreparedOutboundWorkspaceSnapshot { + pub archive_path: PathBuf, + pub metadata: WorkspaceSnapshotMetadata, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OutboundWorkspaceSnapshotRecord { + source_workspace_path: String, + metadata: WorkspaceSnapshotMetadata, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DispatchTargetJobEntry { + job_id: String, + session_id: String, + state: String, + #[serde(default)] + started_at: Option, + workspace_path: String, + title: String, + #[serde(default)] + agent_type: Option, + #[serde(default)] + approval_policy: Option, + #[serde(default)] + model: Option, +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -35,6 +83,14 @@ pub struct OutboundDispatchRecord { pub session_id: String, pub workspace_path: String, pub prompt_preview: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, pub last_cursor: u64, pub last_state: String, pub created_at: DateTime, @@ -58,12 +114,30 @@ impl OutboundDispatchRecord { session_id, workspace_path, prompt_preview: prompt.chars().take(PROMPT_PREVIEW_CHARS).collect(), + title: None, + agent_type: None, + approval_policy: None, + model: None, last_cursor: 0, last_state: state.into(), created_at: now, updated_at: now, }) } + + pub fn with_submission_metadata( + mut self, + title: Option, + agent_type: String, + approval_policy: String, + model: Option, + ) -> Self { + self.title = title.filter(|value| !value.trim().is_empty()); + self.agent_type = Some(agent_type); + self.approval_policy = Some(approval_policy); + self.model = model.filter(|value| !value.trim().is_empty()); + self + } } #[derive(Debug, Error)] @@ -118,7 +192,7 @@ impl OutboundDispatchStore { /// Dispatch submission is idempotent across renderer retries and may race /// across multiple controller processes. The first binding wins; callers /// must compare the returned record with their requested target/session - /// before contacting a target. + /// before asking the target to execute the job. pub async fn bind_if_absent( &self, record: &OutboundDispatchRecord, @@ -194,6 +268,29 @@ impl OutboundDispatchStore { .read_optional::(&path) .await { + Ok(Some(record)) + if is_terminal_state(&record.last_state) + && Utc::now() + .signed_duration_since(record.updated_at) + .num_days() + >= TERMINAL_OUTBOUND_RETENTION_DAYS => + { + if let Err(error) = self.remove_workspace_snapshot(&record.job_id).await { + log::warn!( + "Failed to remove expired outbound dispatch snapshot: job_id={} error={}", + record.job_id, + error + ); + records.push(record); + } else if let Err(error) = self.remove(&record.job_id).await { + log::warn!( + "Failed to remove expired outbound dispatch record: job_id={} error={}", + record.job_id, + error + ); + records.push(record); + } + } Ok(Some(record)) => records.push(record), Ok(None) => {} Err(error) => { @@ -224,6 +321,132 @@ impl OutboundDispatchStore { } } + /// Build or reopen the immutable snapshot bound to one outbound job. + /// + /// Keeping the verified artifact after an ambiguous submit is essential: + /// an idempotent retry must not capture a newer local tree and conflict + /// with the snapshot that the target may already have committed. + pub async fn prepare_workspace_snapshot( + &self, + job_id: &str, + source_workspace_path: &str, + ) -> anyhow::Result { + validate_id(job_id)?; + let source = std::path::PathBuf::from(source_workspace_path.trim()); + if !source.is_absolute() { + anyhow::bail!("snapshot sourceWorkspacePath must be absolute"); + } + let source = tokio::task::spawn_blocking(move || source.canonicalize()) + .await + .map_err(|error| anyhow::anyhow!("snapshot path task failed: {error}"))? + .map_err(|error| anyhow::anyhow!("resolve snapshot source: {error}"))?; + if !source.is_dir() { + anyhow::bail!("snapshot source is not a directory"); + } + let source_wire = source + .to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("snapshot source path is not valid UTF-8"))?; + let uploads = self.root.join(OUTBOUND_WORKSPACE_UPLOADS_DIR); + fs::create_dir_all(&uploads).await?; + harden_directory_permissions(&uploads).await?; + let uploads = tokio::task::spawn_blocking(move || uploads.canonicalize()) + .await + .map_err(|error| anyhow::anyhow!("snapshot staging path task failed: {error}"))? + .map_err(|error| anyhow::anyhow!("resolve snapshot staging directory: {error}"))?; + if uploads.starts_with(&source) { + anyhow::bail!( + "snapshot source cannot contain the controller dispatch staging directory" + ); + } + let record_path = uploads.join(format!("{job_id}.json")); + let archive_path = uploads.join(format!("{job_id}.tar.gz")); + let _lock = self + .json_store + .acquire_cross_process_lock(&record_path) + .await?; + + if let Some(record) = self + .json_store + .read_optional::(&record_path) + .await? + { + if record.source_workspace_path != source_wire { + anyhow::bail!( + "dispatch jobId is already bound to a snapshot from another source workspace" + ); + } + let archive = archive_path.clone(); + let expected = record.metadata.clone(); + let valid = tokio::task::spawn_blocking(move || -> anyhow::Result { + let metadata = match std::fs::symlink_metadata(&archive) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() != expected.archive_size + { + return Ok(false); + } + Ok(sha256_file(&archive)?.eq_ignore_ascii_case(&expected.archive_sha256)) + }) + .await + .map_err(|error| anyhow::anyhow!("snapshot verification task failed: {error}"))??; + if valid { + return Ok(PreparedOutboundWorkspaceSnapshot { + archive_path, + metadata: record.metadata, + }); + } + let _ = fs::remove_file(&record_path).await; + let _ = fs::remove_file(&archive_path).await; + } else { + let _ = fs::remove_file(&archive_path).await; + } + + let package_source = source.clone(); + let package_archive = archive_path.clone(); + let metadata = tokio::task::spawn_blocking(move || { + create_exact_workspace_snapshot(&package_source, &package_archive) + }) + .await + .map_err(|error| anyhow::anyhow!("snapshot packaging task failed: {error}"))??; + let record = OutboundWorkspaceSnapshotRecord { + source_workspace_path: source_wire, + metadata: metadata.clone(), + }; + self.json_store + .write_atomic_strict(&record_path, &record) + .await?; + harden_file_permissions(&record_path).await?; + harden_file_permissions(&archive_path).await?; + Ok(PreparedOutboundWorkspaceSnapshot { + archive_path, + metadata, + }) + } + + pub async fn remove_workspace_snapshot(&self, job_id: &str) -> anyhow::Result<()> { + validate_id(job_id)?; + let uploads = self.root.join(OUTBOUND_WORKSPACE_UPLOADS_DIR); + let record_path = uploads.join(format!("{job_id}.json")); + let archive_path = uploads.join(format!("{job_id}.tar.gz")); + let _lock = self + .json_store + .acquire_cross_process_lock(&record_path) + .await?; + for path in [record_path, archive_path] { + match fs::remove_file(&path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + fn record_path(&self, job_id: &str) -> Result { validate_id(job_id)?; Ok(self.root.join(format!("{job_id}.json"))) @@ -236,6 +459,149 @@ impl OutboundDispatchStore { } } +async fn adopt_target_jobs( + store: &OutboundDispatchStore, + target: &DispatchTarget, + response: &serde_json::Value, +) -> anyhow::Result<()> { + let entries: Vec = + serde_json::from_value(response.clone()).context("decode target dispatch job list")?; + for entry in entries { + if !matches!( + entry.state.as_str(), + "queued" | "running" | "succeeded" | "failed" | "cancelled" + ) { + anyhow::bail!("dispatch target returned an invalid job state"); + } + bitfun_agent_runtime::session_control::validate_session_id(&entry.session_id) + .map_err(anyhow::Error::msg)?; + let workspace_path = entry.workspace_path.trim(); + if !target_workspace_path_is_absolute(workspace_path) { + anyhow::bail!("dispatch target returned an invalid workspace path"); + } + let resolved_target = match target { + DispatchTarget::Ssh { + connection_id, + display_name, + .. + } => DispatchTarget::Ssh { + connection_id: connection_id.clone(), + workspace_path: workspace_path.to_string(), + display_name: display_name.clone(), + }, + DispatchTarget::Device { + device_id, + display_name, + .. + } => DispatchTarget::Device { + device_id: device_id.clone(), + workspace_path: workspace_path.to_string(), + display_name: display_name.clone(), + }, + DispatchTarget::Local => { + anyhow::bail!("local jobs cannot be adopted as outbound dispatch observers") + } + }; + let title = entry + .title + .chars() + .take(PROMPT_PREVIEW_CHARS) + .collect::(); + if entry + .approval_policy + .as_deref() + .is_some_and(|policy| !matches!(policy, "auto" | "reject-and-report" | "remote")) + { + anyhow::bail!("dispatch target returned an invalid approval policy"); + } + let mut requested = OutboundDispatchRecord::new( + entry.job_id, + resolved_target, + entry.session_id, + workspace_path.to_string(), + &title, + entry.state.clone(), + )?; + requested.title = (!title.trim().is_empty()).then_some(title); + requested.agent_type = entry.agent_type.filter(|value| !value.trim().is_empty()); + requested.approval_policy = entry.approval_policy; + requested.model = entry.model.filter(|value| !value.trim().is_empty()); + if let Some(started_at) = entry + .started_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + { + requested.created_at = started_at; + requested.updated_at = started_at; + } + let bound = store.bind_if_absent(&requested).await?; + if bound.session_id != requested.session_id + || !same_target_identity_for_store(&bound.target, &requested.target) + { + anyhow::bail!( + "dispatch jobId is already bound to another target or session on this controller" + ); + } + store + .update_progress(&requested.job_id, 0, entry.state) + .await?; + } + Ok(()) +} + +/// Validate a path returned by the target without applying the controller +/// process's host path semantics. The target may run POSIX while the controller +/// runs Windows, or vice versa. +fn target_workspace_path_is_absolute(path: &str) -> bool { + let path = path.trim(); + if path.starts_with('/') { + return true; + } + + let bytes = path.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') + { + return true; + } + + let Some(unc_path) = path.strip_prefix(r"\\") else { + return false; + }; + let mut components = unc_path + .split(|character| matches!(character, '\\' | '/')) + .filter(|component| !component.is_empty()); + components.next().is_some() && components.next().is_some() +} + +fn same_target_identity_for_store(left: &DispatchTarget, right: &DispatchTarget) -> bool { + match (left, right) { + ( + DispatchTarget::Ssh { + connection_id: left, + .. + }, + DispatchTarget::Ssh { + connection_id: right, + .. + }, + ) => left == right, + ( + DispatchTarget::Device { + device_id: left, .. + }, + DispatchTarget::Device { + device_id: right, .. + }, + ) => left == right, + (DispatchTarget::Local, DispatchTarget::Local) => true, + _ => false, + } +} + fn validate_id(value: &str) -> Result<(), DispatchStoreError> { if value.is_empty() || value.len() > 128 @@ -406,4 +772,57 @@ mod tests { .expect("record"); assert_eq!(record.prompt_preview.chars().count(), PROMPT_PREVIEW_CHARS); } + + #[test] + fn target_workspace_paths_use_target_platform_semantics() { + assert!(target_workspace_path_is_absolute("/srv/app")); + assert!(target_workspace_path_is_absolute(r"C:\work\app")); + assert!(target_workspace_path_is_absolute("D:/work/app")); + assert!(target_workspace_path_is_absolute(r"\\server\share\app")); + + assert!(!target_workspace_path_is_absolute("")); + assert!(!target_workspace_path_is_absolute("relative/app")); + assert!(!target_workspace_path_is_absolute(r"C:relative\app")); + assert!(!target_workspace_path_is_absolute(r"\root-relative")); + assert!(!target_workspace_path_is_absolute(r"\\server")); + } + + #[tokio::test] + async fn listing_a_target_adopts_observer_records_without_runtime_ownership() { + let temp = tempfile::tempdir().expect("tempdir"); + let store = OutboundDispatchStore::from_root(temp.path().join("outbound")); + adopt_target_jobs( + &store, + &target(), + &serde_json::json!([{ + "jobId": "job-observed", + "sessionId": "00000000-0000-4000-8000-000000000001", + "state": "running", + "startedAt": "2026-07-28T00:00:00Z", + "workspacePath": "/srv/canonical-app", + "title": "Observed task", + "agentType": "review", + "approvalPolicy": "remote", + "model": "target-model" + }]), + ) + .await + .expect("adopt target jobs"); + + let record = store + .get("job-observed") + .await + .expect("read observer") + .expect("observer record"); + assert_eq!(record.last_state, "running"); + assert_eq!(record.title.as_deref(), Some("Observed task")); + assert_eq!(record.agent_type.as_deref(), Some("review")); + assert_eq!(record.approval_policy.as_deref(), Some("remote")); + assert_eq!(record.model.as_deref(), Some("target-model")); + assert!(matches!( + record.target, + DispatchTarget::Ssh { workspace_path, .. } + if workspace_path == "/srv/canonical-app" + )); + } } diff --git a/src/crates/assembly/core/src/service/dispatch/target.rs b/src/crates/assembly/core/src/service/dispatch/target.rs index dc5a3aac93..8fce36c30d 100644 --- a/src/crates/assembly/core/src/service/dispatch/target.rs +++ b/src/crates/assembly/core/src/service/dispatch/target.rs @@ -1,5 +1,23 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum DispatchWorkspaceDeliveryRequest { + Existing, + SnapshotExact { + #[serde(rename = "sourceWorkspacePath")] + source_workspace_path: String, + #[serde(rename = "sensitiveFilesConfirmed")] + sensitive_files_confirmed: bool, + }, +} + +impl Default for DispatchWorkspaceDeliveryRequest { + fn default() -> Self { + Self::Existing + } +} + /// The execution location selected while a chat session is being created. /// /// Dispatch is deliberately orthogonal to `SessionExecutionTarget`: the latter @@ -106,4 +124,21 @@ mod tests { }) ); } + + #[test] + fn exact_snapshot_requires_an_explicit_source_and_confirmation_fact() { + let value = serde_json::to_value(DispatchWorkspaceDeliveryRequest::SnapshotExact { + source_workspace_path: "/work/app".to_string(), + sensitive_files_confirmed: true, + }) + .expect("serialize delivery"); + assert_eq!( + value, + serde_json::json!({ + "kind": "snapshot-exact", + "sourceWorkspacePath": "/work/app", + "sensitiveFilesConfirmed": true + }) + ); + } } diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 6547ad51e6..9ca92a45e5 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -30,6 +30,8 @@ fs2 = { workspace = true } notify = { workspace = true, optional = true } ignore = { workspace = true } sha2 = { workspace = true } +flate2 = { workspace = true, optional = true } +tar = { workspace = true, optional = true } which = { workspace = true } similar = { workspace = true } regex = { workspace = true } @@ -58,6 +60,7 @@ markdown = ["dep:serde_yaml"] workspace-runtime = ["dep:anyhow", "dep:async-trait", "dep:bitfun-runtime-ports", "dep:dunce"] runtime-ownership = ["dep:dunce"] permission = ["dep:async-trait", "dep:bitfun-runtime-ports", "dep:rusqlite", "bitfun-runtime-ports/permission"] +dispatch-workspace = ["dep:anyhow", "dep:flate2", "dep:tar"] [dev-dependencies] filetime = { workspace = true } diff --git a/src/crates/services/services-core/src/dispatch_workspace.rs b/src/crates/services/services-core/src/dispatch_workspace.rs new file mode 100644 index 0000000000..aaba4e555d --- /dev/null +++ b/src/crates/services/services-core/src/dispatch_workspace.rs @@ -0,0 +1,949 @@ +//! Safe, transport-neutral workspace snapshot packaging for detached dispatch. +//! +//! A snapshot is a one-shot input boundary. It deliberately does not contain +//! Git metadata and never follows links outside the selected workspace. + +use std::collections::{BTreeMap, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path}; + +use anyhow::{anyhow, bail, Context, Result}; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use flate2::Compression; +use ignore::WalkBuilder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tar::{Archive, Builder, EntryType, Header}; + +pub const WORKSPACE_SNAPSHOT_FORMAT_VERSION: u32 = 1; +pub const MAX_SNAPSHOT_FILES: u64 = 100_000; +pub const MAX_SNAPSHOT_DIRECTORIES: u64 = 100_000; +pub const MAX_SNAPSHOT_FILE_BYTES: u64 = 256 * 1024 * 1024; +pub const MAX_SNAPSHOT_UNCOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024; +pub const MAX_SNAPSHOT_ARCHIVE_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_MANIFEST_BYTES: u64 = 16 * 1024 * 1024; +const MANIFEST_ARCHIVE_PATH: &str = ".bitfun-dispatch/manifest.json"; +const WORKSPACE_ARCHIVE_ROOT: &str = "workspace"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceSnapshotEntry { + pub path: String, + pub kind: WorkspaceSnapshotEntryKind, + pub size: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha256: Option, + pub executable: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorkspaceSnapshotEntryKind { + File, + Directory, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceSnapshotManifest { + pub format_version: u32, + pub mode: String, + pub includes_ignored_files: bool, + pub excludes_git_metadata: bool, + pub file_count: u64, + pub directory_count: u64, + pub uncompressed_bytes: u64, + pub entries: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkspaceSnapshotMetadata { + pub format_version: u32, + pub archive_size: u64, + pub archive_sha256: String, + pub manifest_sha256: String, + pub file_count: u64, + pub directory_count: u64, + pub uncompressed_bytes: u64, +} + +/// Package every regular workspace file, including hidden and ignored files. +/// +/// `.git` entries are the one explicit metadata exclusion. Unsupported entries +/// fail the whole operation instead of producing an incomplete snapshot. +pub fn create_exact_workspace_snapshot( + source: &Path, + archive_path: &Path, +) -> Result { + let result = create_exact_workspace_snapshot_inner(source, archive_path); + if result.is_err() { + let _ = fs::remove_file(archive_path); + } + result +} + +fn create_exact_workspace_snapshot_inner( + source: &Path, + archive_path: &Path, +) -> Result { + let source_metadata = fs::symlink_metadata(source) + .with_context(|| format!("inspect workspace {}", source.display()))?; + if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { + bail!( + "workspace snapshot source is not a real directory: {}", + source.display() + ); + } + let source = source + .canonicalize() + .with_context(|| format!("resolve workspace {}", source.display()))?; + let archive_parent = archive_path + .parent() + .ok_or_else(|| anyhow!("workspace archive path has no parent"))?; + fs::create_dir_all(archive_parent) + .with_context(|| format!("create snapshot staging {}", archive_parent.display()))?; + + let archive_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(archive_path) + .with_context(|| format!("create workspace snapshot {}", archive_path.display()))?; + set_private_file_permissions(archive_path)?; + let encoder = GzEncoder::new(archive_file, Compression::default()); + let mut archive = Builder::new(encoder); + archive.mode(tar::HeaderMode::Deterministic); + + let mut walk = WalkBuilder::new(&source); + walk.hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false) + .follow_links(false) + .sort_by_file_path(|left, right| left.cmp(right)); + let filter_root = source.clone(); + walk.filter_entry(move |entry| { + entry.path() == filter_root || entry.file_name().to_str() != Some(".git") + }); + + let mut entries = Vec::new(); + let mut file_count = 0_u64; + let mut directory_count = 0_u64; + let mut uncompressed_bytes = 0_u64; + for walked in walk.build() { + let walked = walked.context("walk workspace for dispatch snapshot")?; + let path = walked.path(); + if path == source { + continue; + } + let relative = path + .strip_prefix(&source) + .with_context(|| format!("resolve snapshot path {}", path.display()))?; + let relative_wire = portable_relative_path(relative)?; + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspect snapshot entry {}", path.display()))?; + if metadata.file_type().is_symlink() { + bail!( + "workspace snapshot does not support symbolic link '{}'", + relative_wire + ); + } + if metadata.is_dir() { + directory_count = directory_count.saturating_add(1); + if directory_count > MAX_SNAPSHOT_DIRECTORIES { + bail!( + "workspace snapshot exceeds the {} directory limit", + MAX_SNAPSHOT_DIRECTORIES + ); + } + append_directory(&mut archive, &relative_wire)?; + entries.push(WorkspaceSnapshotEntry { + path: relative_wire, + kind: WorkspaceSnapshotEntryKind::Directory, + size: 0, + sha256: None, + executable: false, + }); + continue; + } + if !metadata.is_file() { + bail!( + "workspace snapshot contains unsupported special file '{}'", + relative_wire + ); + } + let size = metadata.len(); + if size > MAX_SNAPSHOT_FILE_BYTES { + bail!( + "workspace snapshot file '{}' exceeds the {} MiB per-file limit", + relative_wire, + MAX_SNAPSHOT_FILE_BYTES / (1024 * 1024) + ); + } + file_count = file_count.saturating_add(1); + if file_count > MAX_SNAPSHOT_FILES { + bail!( + "workspace snapshot exceeds the {} file limit", + MAX_SNAPSHOT_FILES + ); + } + uncompressed_bytes = uncompressed_bytes.saturating_add(size); + if uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { + bail!( + "workspace snapshot exceeds the {} MiB uncompressed limit", + MAX_SNAPSHOT_UNCOMPRESSED_BYTES / (1024 * 1024) + ); + } + let executable = is_executable(&metadata); + let sha256 = append_file(&mut archive, path, &relative_wire, &metadata, executable)?; + entries.push(WorkspaceSnapshotEntry { + path: relative_wire, + kind: WorkspaceSnapshotEntryKind::File, + size, + sha256: Some(sha256), + executable, + }); + } + + let manifest = WorkspaceSnapshotManifest { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + mode: "exact".to_string(), + includes_ignored_files: true, + excludes_git_metadata: true, + file_count, + directory_count, + uncompressed_bytes, + entries, + }; + let manifest_bytes = + serde_json::to_vec(&manifest).context("encode workspace snapshot manifest")?; + if manifest_bytes.len() as u64 > MAX_MANIFEST_BYTES { + bail!("workspace snapshot manifest exceeds the safety limit"); + } + append_bytes(&mut archive, MANIFEST_ARCHIVE_PATH, &manifest_bytes, false)?; + let manifest_sha256 = sha256_bytes(&manifest_bytes); + + let encoder = archive + .into_inner() + .context("finish workspace snapshot tar stream")?; + let archive_file = encoder + .finish() + .context("finish workspace snapshot compression")?; + archive_file + .sync_all() + .context("sync workspace snapshot archive")?; + let archive_size = archive_file + .metadata() + .context("inspect workspace snapshot archive")? + .len(); + drop(archive_file); + if archive_size > MAX_SNAPSHOT_ARCHIVE_BYTES { + bail!( + "workspace snapshot archive exceeds the {} MiB compressed limit", + MAX_SNAPSHOT_ARCHIVE_BYTES / (1024 * 1024) + ); + } + let archive_sha256 = sha256_file(archive_path)?; + Ok(WorkspaceSnapshotMetadata { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + archive_size, + archive_sha256, + manifest_sha256, + file_count, + directory_count, + uncompressed_bytes, + }) +} + +/// Verify and extract a snapshot into a brand-new staging directory. +/// +/// Callers publish the directory atomically only after this returns. This +/// function never removes or overwrites an existing destination. +pub fn extract_workspace_snapshot( + archive_path: &Path, + destination: &Path, + expected: &WorkspaceSnapshotMetadata, +) -> Result { + if expected.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION { + bail!( + "unsupported workspace snapshot format {}; target requires {}", + expected.format_version, + WORKSPACE_SNAPSHOT_FORMAT_VERSION + ); + } + let archive_metadata = fs::symlink_metadata(archive_path) + .with_context(|| format!("inspect workspace archive {}", archive_path.display()))?; + if archive_metadata.file_type().is_symlink() || !archive_metadata.is_file() { + bail!("workspace snapshot archive is not a regular file"); + } + if archive_metadata.len() != expected.archive_size { + bail!( + "workspace snapshot archive size mismatch: expected {}, received {}", + expected.archive_size, + archive_metadata.len() + ); + } + if archive_metadata.len() > MAX_SNAPSHOT_ARCHIVE_BYTES { + bail!("workspace snapshot archive exceeds the target safety limit"); + } + let actual_archive_sha256 = sha256_file(archive_path)?; + if !actual_archive_sha256.eq_ignore_ascii_case(&expected.archive_sha256) { + bail!("workspace snapshot archive SHA-256 mismatch"); + } + match fs::symlink_metadata(destination) { + Ok(_) => bail!( + "workspace snapshot destination already exists: {}", + destination.display() + ), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("inspect snapshot destination {}", destination.display())) + } + } + fs::create_dir_all(destination) + .with_context(|| format!("create snapshot destination {}", destination.display()))?; + set_private_directory_permissions(destination)?; + + let result = extract_workspace_snapshot_inner(archive_path, destination, expected); + if result.is_err() { + let _ = fs::remove_dir_all(destination); + } + result +} + +fn extract_workspace_snapshot_inner( + archive_path: &Path, + destination: &Path, + expected: &WorkspaceSnapshotMetadata, +) -> Result { + let decoder = GzDecoder::new( + File::open(archive_path) + .with_context(|| format!("open workspace archive {}", archive_path.display()))?, + ); + let mut archive = Archive::new(decoder); + let mut actual_entries: BTreeMap = BTreeMap::new(); + let mut seen_archive_paths = HashSet::new(); + let mut manifest_bytes: Option> = None; + let mut file_count = 0_u64; + let mut directory_count = 0_u64; + let mut uncompressed_bytes = 0_u64; + + for entry in archive + .entries() + .context("read workspace snapshot archive")? + { + let mut entry = entry.context("read workspace snapshot entry")?; + let entry_path = entry + .path() + .context("decode workspace snapshot path")? + .into_owned(); + let entry_wire = portable_relative_path(&entry_path)?; + if !seen_archive_paths.insert(entry_wire.clone()) { + bail!("workspace snapshot contains duplicate entry '{entry_wire}'"); + } + if entry_wire == MANIFEST_ARCHIVE_PATH { + if entry.header().entry_type() != EntryType::Regular { + bail!("workspace snapshot manifest is not a regular file"); + } + if entry.size() > MAX_MANIFEST_BYTES { + bail!("workspace snapshot manifest exceeds the safety limit"); + } + let mut bytes = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut bytes) + .context("read workspace snapshot manifest")?; + manifest_bytes = Some(bytes); + continue; + } + + let relative = entry_path + .strip_prefix(WORKSPACE_ARCHIVE_ROOT) + .with_context(|| format!("unexpected workspace snapshot entry '{entry_wire}'"))?; + let relative_wire = portable_relative_path(relative)?; + if relative_wire.is_empty() { + bail!("workspace snapshot contains an empty workspace entry"); + } + if contains_git_metadata(relative) { + bail!("workspace snapshot contains forbidden Git metadata '{relative_wire}'"); + } + if let Some(parent) = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + let parent_wire = portable_relative_path(parent)?; + if !matches!( + actual_entries.get(&parent_wire).map(|entry| entry.kind), + Some(WorkspaceSnapshotEntryKind::Directory) + ) { + bail!( + "workspace snapshot entry '{}' appears before its declared parent directory '{}'", + relative_wire, + parent_wire + ); + } + } + let output_path = destination.join(relative); + ensure_path_below(destination, &output_path)?; + match entry.header().entry_type() { + EntryType::Directory => { + directory_count = directory_count.saturating_add(1); + if directory_count > MAX_SNAPSHOT_DIRECTORIES { + bail!("workspace snapshot exceeds the target directory limit"); + } + fs::create_dir_all(&output_path).with_context(|| { + format!("create snapshot directory {}", output_path.display()) + })?; + set_private_directory_permissions(&output_path)?; + actual_entries.insert( + relative_wire.clone(), + WorkspaceSnapshotEntry { + path: relative_wire, + kind: WorkspaceSnapshotEntryKind::Directory, + size: 0, + sha256: None, + executable: false, + }, + ); + } + EntryType::Regular => { + let size = entry.size(); + if size > MAX_SNAPSHOT_FILE_BYTES { + bail!("workspace snapshot file '{relative_wire}' exceeds the target limit"); + } + file_count = file_count.saturating_add(1); + if file_count > MAX_SNAPSHOT_FILES { + bail!("workspace snapshot exceeds the target file limit"); + } + uncompressed_bytes = uncompressed_bytes.saturating_add(size); + if uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES { + bail!("workspace snapshot exceeds the target uncompressed limit"); + } + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create snapshot parent {}", parent.display()))?; + set_private_directory_permissions(parent)?; + } + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&output_path) + .with_context(|| format!("create snapshot file {}", output_path.display()))?; + set_private_file_permissions(&output_path)?; + let mut hashing = HashingReader::new(&mut entry); + io::copy(&mut hashing, &mut output) + .with_context(|| format!("extract snapshot file '{relative_wire}'"))?; + let (actual_size, sha256) = hashing.finish(); + if actual_size != size { + bail!( + "workspace snapshot file '{}' size mismatch: expected {}, extracted {}", + relative_wire, + size, + actual_size + ); + } + output + .sync_all() + .with_context(|| format!("sync snapshot file {}", output_path.display()))?; + let executable = entry.header().mode().unwrap_or(0) & 0o111 != 0; + set_snapshot_file_permissions(&output_path, executable)?; + actual_entries.insert( + relative_wire.clone(), + WorkspaceSnapshotEntry { + path: relative_wire, + kind: WorkspaceSnapshotEntryKind::File, + size, + sha256: Some(sha256), + executable, + }, + ); + } + other => bail!( + "workspace snapshot entry '{}' has unsupported archive type {:?}", + relative_wire, + other + ), + } + } + + let manifest_bytes = + manifest_bytes.ok_or_else(|| anyhow!("workspace snapshot manifest is missing"))?; + if !sha256_bytes(&manifest_bytes).eq_ignore_ascii_case(&expected.manifest_sha256) { + bail!("workspace snapshot manifest SHA-256 mismatch"); + } + let manifest: WorkspaceSnapshotManifest = + serde_json::from_slice(&manifest_bytes).context("decode workspace snapshot manifest")?; + validate_manifest(&manifest, expected)?; + let expected_entries = manifest + .entries + .iter() + .map(|entry| (entry.path.clone(), entry.clone())) + .collect::>(); + if expected_entries.len() != manifest.entries.len() { + bail!("workspace snapshot manifest contains duplicate paths"); + } + if expected_entries != actual_entries { + bail!("workspace snapshot contents do not match the signed manifest"); + } + sync_directory(destination)?; + Ok(manifest) +} + +fn validate_manifest( + manifest: &WorkspaceSnapshotManifest, + expected: &WorkspaceSnapshotMetadata, +) -> Result<()> { + if manifest.format_version != WORKSPACE_SNAPSHOT_FORMAT_VERSION + || manifest.mode != "exact" + || !manifest.includes_ignored_files + || !manifest.excludes_git_metadata + { + bail!("workspace snapshot manifest contract is incompatible"); + } + if manifest.file_count != expected.file_count + || manifest.directory_count != expected.directory_count + || manifest.uncompressed_bytes != expected.uncompressed_bytes + { + bail!("workspace snapshot manifest summary does not match upload metadata"); + } + if manifest.file_count > MAX_SNAPSHOT_FILES + || manifest.directory_count > MAX_SNAPSHOT_DIRECTORIES + || manifest.uncompressed_bytes > MAX_SNAPSHOT_UNCOMPRESSED_BYTES + { + bail!("workspace snapshot manifest exceeds target safety limits"); + } + let mut entry_file_count = 0_u64; + let mut entry_directory_count = 0_u64; + let mut entry_uncompressed_bytes = 0_u64; + for entry in &manifest.entries { + let path = Path::new(&entry.path); + if entry.path.is_empty() + || portable_relative_path(path)? != entry.path + || contains_git_metadata(path) + { + bail!( + "workspace snapshot manifest contains invalid path '{}'", + entry.path + ); + } + match entry.kind { + WorkspaceSnapshotEntryKind::File => { + entry_file_count = entry_file_count.saturating_add(1); + entry_uncompressed_bytes = entry_uncompressed_bytes.saturating_add(entry.size); + if entry.size > MAX_SNAPSHOT_FILE_BYTES { + bail!("workspace snapshot manifest contains an oversized file"); + } + if entry + .sha256 + .as_deref() + .is_none_or(|hash| !valid_sha256(hash)) + { + bail!("workspace snapshot manifest has an invalid file digest"); + } + } + WorkspaceSnapshotEntryKind::Directory => { + entry_directory_count = entry_directory_count.saturating_add(1); + if entry.size != 0 || entry.sha256.is_some() || entry.executable { + bail!("workspace snapshot manifest has invalid directory metadata"); + } + } + } + } + if entry_file_count != manifest.file_count + || entry_directory_count != manifest.directory_count + || entry_uncompressed_bytes != manifest.uncompressed_bytes + { + bail!("workspace snapshot manifest summary does not match its entries"); + } + Ok(()) +} + +fn append_directory(archive: &mut Builder, relative_wire: &str) -> Result<()> { + let archive_path = format!("{WORKSPACE_ARCHIVE_ROOT}/{relative_wire}"); + let mut header = safe_header(0, 0o755, EntryType::Directory); + archive + .append_data(&mut header, &archive_path, io::empty()) + .with_context(|| format!("append snapshot directory '{relative_wire}'")) +} + +fn append_file( + archive: &mut Builder, + path: &Path, + relative_wire: &str, + before: &fs::Metadata, + executable: bool, +) -> Result { + let mut input = + File::open(path).with_context(|| format!("open snapshot file {}", path.display()))?; + let mut hashing = HashingReader::new(&mut input); + let archive_path = format!("{WORKSPACE_ARCHIVE_ROOT}/{relative_wire}"); + let mut header = safe_header( + before.len(), + if executable { 0o755 } else { 0o644 }, + EntryType::Regular, + ); + archive + .append_data(&mut header, &archive_path, &mut hashing) + .with_context(|| format!("append snapshot file '{relative_wire}'"))?; + let (read_size, sha256) = hashing.finish(); + if read_size != before.len() { + bail!( + "workspace file '{}' changed size while the snapshot was being created", + relative_wire + ); + } + let after = fs::symlink_metadata(path) + .with_context(|| format!("reinspect snapshot file {}", path.display()))?; + if !after.is_file() + || after.len() != before.len() + || after.modified().ok() != before.modified().ok() + { + bail!( + "workspace file '{}' changed while the snapshot was being created", + relative_wire + ); + } + Ok(sha256) +} + +fn append_bytes( + archive: &mut Builder, + path: &str, + bytes: &[u8], + executable: bool, +) -> Result<()> { + let mut header = safe_header( + bytes.len() as u64, + if executable { 0o755 } else { 0o600 }, + EntryType::Regular, + ); + archive + .append_data(&mut header, path, bytes) + .with_context(|| format!("append snapshot metadata '{path}'")) +} + +fn safe_header(size: u64, mode: u32, entry_type: EntryType) -> Header { + let mut header = Header::new_gnu(); + header.set_size(size); + header.set_mode(mode); + header.set_mtime(0); + header.set_uid(0); + header.set_gid(0); + header.set_entry_type(entry_type); + header +} + +fn portable_relative_path(path: &Path) -> Result { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(part) => { + let part = part.to_str().ok_or_else(|| { + anyhow!( + "workspace snapshot path is not portable UTF-8: {}", + path.display() + ) + })?; + if part.is_empty() || part == "." || part == ".." { + bail!("workspace snapshot path is unsafe: {}", path.display()); + } + parts.push(part); + } + _ => bail!( + "workspace snapshot path is not relative: {}", + path.display() + ), + } + } + Ok(parts.join("/")) +} + +fn ensure_path_below(root: &Path, path: &Path) -> Result<()> { + if path == root || !path.starts_with(root) { + bail!( + "workspace snapshot entry escapes target directory: {}", + path.display() + ); + } + Ok(()) +} + +fn contains_git_metadata(path: &Path) -> bool { + path.components().any(|component| { + matches!(component, Component::Normal(part) if part == std::ffi::OsStr::new(".git")) + }) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +pub fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("hash {}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(format!("{:x}", digest.finalize())) +} + +struct HashingReader { + inner: R, + digest: Sha256, + bytes: u64, +} + +impl HashingReader { + fn new(inner: R) -> Self { + Self { + inner, + digest: Sha256::new(), + bytes: 0, + } + } + + fn finish(self) -> (u64, String) { + (self.bytes, format!("{:x}", self.digest.finalize())) + } +} + +impl Read for HashingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let read = self.inner.read(buffer)?; + self.digest.update(&buffer[..read]); + self.bytes = self.bytes.saturating_add(read as u64); + Ok(read) + } +} + +#[cfg(unix)] +fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_metadata: &fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn set_private_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("set private permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn set_private_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_snapshot_file_permissions(path: &Path, executable: bool) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + path, + fs::Permissions::from_mode(if executable { 0o700 } else { 0o600 }), + ) + .with_context(|| format!("set snapshot permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn set_snapshot_file_permissions(_path: &Path, _executable: bool) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_private_directory_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("set private permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn set_private_directory_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<()> { + #[cfg(unix)] + { + File::open(path) + .with_context(|| format!("open directory {}", path.display()))? + .sync_all() + .with_context(|| format!("sync directory {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_snapshot_round_trips_hidden_and_ignored_files_without_git_metadata() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(source.join("empty")).expect("empty directory"); + fs::create_dir_all(source.join(".git/objects")).expect("git metadata"); + fs::write(source.join("visible.txt"), b"visible").expect("visible"); + fs::write(source.join(".env"), b"SECRET=test").expect("ignored-like file"); + fs::write(source.join(".git/config"), b"credential=never").expect("git config"); + let archive = temp.path().join("snapshot.tar.gz"); + + let metadata = create_exact_workspace_snapshot(&source, &archive).expect("create snapshot"); + assert_eq!(metadata.file_count, 2); + let destination = temp.path().join("destination"); + let manifest = + extract_workspace_snapshot(&archive, &destination, &metadata).expect("extract"); + + assert_eq!( + fs::read(destination.join("visible.txt")).expect("visible output"), + b"visible" + ); + assert_eq!( + fs::read(destination.join(".env")).expect("hidden output"), + b"SECRET=test" + ); + assert!(destination.join("empty").is_dir()); + assert!(!destination.join(".git").exists()); + assert!(manifest.includes_ignored_files); + assert!(manifest.excludes_git_metadata); + } + + #[test] + fn exact_snapshot_round_trips_paths_longer_than_a_legacy_tar_header() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + let long_directory = "directory-segment-".repeat(8); + let relative = Path::new(&long_directory).join("long-file-name.txt"); + fs::create_dir_all(source.join(&long_directory)).expect("long directory"); + fs::write(source.join(&relative), b"long path").expect("long path file"); + assert!( + format!("{WORKSPACE_ARCHIVE_ROOT}/{}", relative.to_string_lossy()).len() > 100, + "fixture must require a GNU long-name record" + ); + let archive = temp.path().join("snapshot.tar.gz"); + + let metadata = create_exact_workspace_snapshot(&source, &archive).expect("create snapshot"); + let destination = temp.path().join("destination"); + extract_workspace_snapshot(&archive, &destination, &metadata).expect("extract snapshot"); + + assert_eq!( + fs::read(destination.join(relative)).expect("long path output"), + b"long path" + ); + } + + #[cfg(unix)] + #[test] + fn symbolic_links_fail_instead_of_escaping_or_disappearing() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(temp.path().join("outside"), b"private").expect("outside"); + symlink(temp.path().join("outside"), source.join("link")).expect("link"); + let error = create_exact_workspace_snapshot(&source, &temp.path().join("snapshot.tar.gz")) + .expect_err("link must fail"); + assert!(error.to_string().contains("symbolic link")); + } + + #[test] + fn tampered_archive_is_rejected_before_extraction() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("file"), b"original").expect("file"); + let archive = temp.path().join("snapshot.tar.gz"); + let metadata = create_exact_workspace_snapshot(&source, &archive).expect("create snapshot"); + let mut bytes = fs::read(&archive).expect("archive"); + let last = bytes.len() - 1; + bytes[last] ^= 1; + fs::write(&archive, bytes).expect("tamper"); + + let error = extract_workspace_snapshot(&archive, &temp.path().join("out"), &metadata) + .expect_err("tampering must fail"); + assert!(error.to_string().contains("SHA-256 mismatch")); + assert!(!temp.path().join("out").exists()); + } + + #[test] + fn manifest_rejects_nested_git_metadata() { + let manifest = WorkspaceSnapshotManifest { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + mode: "exact".to_string(), + includes_ignored_files: true, + excludes_git_metadata: true, + file_count: 1, + directory_count: 0, + uncompressed_bytes: 1, + entries: vec![WorkspaceSnapshotEntry { + path: "nested/.git/config".to_string(), + kind: WorkspaceSnapshotEntryKind::File, + size: 1, + sha256: Some("0".repeat(64)), + executable: false, + }], + }; + let metadata = WorkspaceSnapshotMetadata { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + archive_size: 1, + archive_sha256: "0".repeat(64), + manifest_sha256: "0".repeat(64), + file_count: 1, + directory_count: 0, + uncompressed_bytes: 1, + }; + assert!(validate_manifest(&manifest, &metadata).is_err()); + } + + #[test] + fn manifest_summary_must_match_its_entries() { + let manifest = WorkspaceSnapshotManifest { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + mode: "exact".to_string(), + includes_ignored_files: true, + excludes_git_metadata: true, + file_count: 0, + directory_count: 0, + uncompressed_bytes: 0, + entries: vec![WorkspaceSnapshotEntry { + path: "file.txt".to_string(), + kind: WorkspaceSnapshotEntryKind::File, + size: 1, + sha256: Some("0".repeat(64)), + executable: false, + }], + }; + let metadata = WorkspaceSnapshotMetadata { + format_version: WORKSPACE_SNAPSHOT_FORMAT_VERSION, + archive_size: 1, + archive_sha256: "0".repeat(64), + manifest_sha256: "0".repeat(64), + file_count: 0, + directory_count: 0, + uncompressed_bytes: 0, + }; + assert!(validate_manifest(&manifest, &metadata).is_err()); + } +} diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 01c6cdbcd5..0acb78bac1 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -5,6 +5,8 @@ pub mod diagnostics; pub mod diff; +#[cfg(feature = "dispatch-workspace")] +pub mod dispatch_workspace; mod file_lock; pub mod filesystem; pub mod json_store; diff --git a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs index 98dc9f6b25..1a1f291d88 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs @@ -507,6 +507,16 @@ impl SSHConnectionManager { Err(unsupported()) } + pub async fn sftp_write_from_file( + &self, + _connection_id: &str, + _path: &str, + _local_path: &std::path::Path, + _max_bytes: u64, + ) -> anyhow::Result { + Err(unsupported()) + } + pub async fn sftp_mkdir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { Err(unsupported()) } diff --git a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs index 6b7aeed834..6437b1027d 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/dispatch_ssh.rs @@ -1,8 +1,9 @@ //! SSH transport for persistent BitFun dispatch jobs. //! //! The target-side runner is the `bitfun dispatch` CLI surface. This module is -//! deliberately only a submit/poll transport: the remote CLI owns jobs, -//! sessions, transcripts, process detachment, and cancellation semantics. +//! deliberately only a controller transport: the remote CLI owns jobs, +//! workspaces, sessions, transcripts, process detachment, supervision, and +//! cancellation semantics. //! //! Installing the CLI is a separate, explicit operation. `probe` never installs //! anything; `install_cli_start` downloads an official archive locally, verifies @@ -30,10 +31,12 @@ const INSTALL_STEM: &str = "install-cli"; const INSTALL_DONE_MARKER: &str = "BITFUN_DISPATCH_CLI_INSTALL_DONE"; const INSTALL_PREPARE_GRACE_SECONDS: u64 = 30; const COMMAND_TIMEOUT_MS: u64 = 30_000; +const WORKSPACE_COMMIT_POLL_INTERVAL: Duration = Duration::from_millis(750); +const WORKSPACE_COMMIT_WAIT: Duration = Duration::from_secs(15 * 60); const RELEASE_READ_TIMEOUT_SECONDS: u64 = 30; const MAX_ARCHIVE_BYTES: usize = 512 * 1024 * 1024; -const DISPATCH_PROTOCOL_VERSION: u64 = 1; -const REQUIRED_DISPATCH_CAPABILITIES: [&str; 7] = [ +const DISPATCH_PROTOCOL_VERSION: u64 = 2; +const REQUIRED_DISPATCH_CAPABILITIES: [&str; 12] = [ "persistent_jobs", "cursor_events", "detached_worker", @@ -41,6 +44,11 @@ const REQUIRED_DISPATCH_CAPABILITIES: [&str; 7] = [ "frontend_event_projection", "approval_auto", "approval_reject_and_report", + "approval_remote", + "append_message", + "event_log_completeness", + "workspace_snapshot_exact", + "workspace_snapshot_chunked", ]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -188,7 +196,7 @@ fn dispatch_protocol_is_compatible(protocol: &Value) -> bool { /// Validate the target-side protocol immediately before submission. /// /// `approval_policy = None` is used by installation probing and requires the -/// complete phase-one surface. Submission may validate only the selected +/// complete dispatch surface. Submission may validate only the selected /// unattended approval behavior in addition to the transport invariants. pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str>) -> Result<()> { if protocol.get("protocolVersion").and_then(Value::as_u64) != Some(DISPATCH_PROTOCOL_VERSION) { @@ -217,6 +225,14 @@ pub fn validate_dispatch_protocol(protocol: &Value, approval_policy: Option<&str "frontend_event_projection", "approval_reject_and_report", ], + Some("remote") => &[ + "persistent_jobs", + "cursor_events", + "detached_worker", + "workspace_serialization", + "frontend_event_projection", + "approval_remote", + ], Some(_) => return Err(anyhow!("unsupported dispatch approval policy")), None => &REQUIRED_DISPATCH_CAPABILITIES, }; @@ -495,6 +511,173 @@ pub async fn list( invoke_json(manager, connection_id, "list", request).await } +pub async fn answer( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "answer", request).await +} + +pub async fn append( + manager: &SSHConnectionManager, + connection_id: &str, + request: &Value, +) -> Result { + invoke_json(manager, connection_id, "append", request).await +} + +/// Stage and atomically materialize a controller-created workspace snapshot. +/// +/// The target CLI chooses the owner-only upload path. This adapter validates +/// that the returned path stays under the target's managed dispatch root before +/// allowing SFTP to write it. +pub async fn upload_workspace_snapshot( + manager: &SSHConnectionManager, + connection_id: &str, + begin_request: &Value, + archive_path: &std::path::Path, +) -> Result { + ensure_plain_ssh_target(manager, connection_id).await?; + let target = probe_remote_target(manager, connection_id).await?; + let cli_path = target.cli_path.as_deref().ok_or_else(|| { + anyhow!("BitFun CLI is not installed on the SSH target; confirm installation first") + })?; + let begin = invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + "__workspace_begin", + begin_request, + ) + .await?; + if begin + .get("committed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Ok(begin); + } + if begin.get("accepted").and_then(Value::as_bool) != Some(true) { + return Err(anyhow!( + "dispatch target did not accept the workspace upload" + )); + } + let upload_path = begin + .get("uploadPath") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("dispatch target returned no workspace upload path"))?; + validate_managed_workspace_upload_path(&target.home, upload_path)?; + let archive_size = begin_request + .pointer("/metadata/archiveSize") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("workspace upload request has no archiveSize"))?; + let local_size = std::fs::symlink_metadata(archive_path) + .with_context(|| format!("inspect workspace snapshot {}", archive_path.display()))? + .len(); + if local_size != archive_size { + return Err(anyhow!( + "workspace snapshot changed before SSH upload: expected {archive_size} bytes, found {local_size}" + )); + } + let retained_offset = begin + .get("offset") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("dispatch target returned no workspace upload offset"))?; + if retained_offset > archive_size { + return Err(anyhow!( + "dispatch target returned an invalid workspace upload offset" + )); + } + if retained_offset < archive_size { + let written = manager + .sftp_write_from_file(connection_id, upload_path, archive_path, archive_size) + .await + .context("upload workspace snapshot over SFTP")?; + if written != archive_size { + return Err(anyhow!( + "workspace snapshot SFTP upload ended at {written} of {archive_size} bytes" + )); + } + } + let job_id = begin_request + .get("jobId") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("workspace upload request has no jobId"))?; + let expected_digest = begin_request + .pointer("/metadata/archiveSha256") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("workspace upload request has no archiveSha256"))?; + let deadline = tokio::time::Instant::now() + WORKSPACE_COMMIT_WAIT; + loop { + let committed = invoke_json_at_path( + manager, + connection_id, + &target.home, + cli_path, + "__workspace_commit", + &serde_json::json!({ "jobId": job_id }), + ) + .await?; + if committed + .pointer("/metadata/archiveSha256") + .and_then(Value::as_str) + != Some(expected_digest) + { + return Err(anyhow!( + "dispatch target returned mismatched workspace snapshot metadata" + )); + } + if committed + .get("committed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + if committed + .get("workspacePath") + .and_then(Value::as_str) + .is_none_or(|path| path.trim().is_empty()) + { + return Err(anyhow!( + "dispatch target committed no materialized workspace path" + )); + } + return Ok(committed); + } + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!( + "dispatch target workspace materialization did not finish within 15 minutes" + )); + } + tokio::time::sleep(WORKSPACE_COMMIT_POLL_INTERVAL).await; + } +} + +fn validate_managed_workspace_upload_path(home: &str, upload_path: &str) -> Result<()> { + let prefix = format!( + "{}/.bitfun/dispatch/workspaces/", + home.trim_end_matches('/') + ); + let Some(relative) = upload_path.strip_prefix(&prefix) else { + return Err(anyhow!( + "dispatch target returned an upload path outside its managed workspace root" + )); + }; + let components = relative.split('/').collect::>(); + if components.len() != 2 + || components[0].is_empty() + || components[0] == "." + || components[0] == ".." + || components[1] != "workspace.tar.gz" + { + return Err(anyhow!( + "dispatch target returned an invalid managed workspace upload path" + )); + } + Ok(()) +} + async fn invoke_json( manager: &SSHConnectionManager, connection_id: &str, @@ -1499,7 +1682,7 @@ mod tests { } #[test] - fn release_target_accepts_phase_one_unix_architectures() { + fn release_target_accepts_supported_unix_architectures() { assert_eq!( release_target("Linux", "amd64").unwrap(), "x86_64-unknown-linux-gnu" @@ -1572,25 +1755,25 @@ mod tests { fn incompatible_dispatch_protocols_require_an_upgrade() { let capabilities = REQUIRED_DISPATCH_CAPABILITIES; let compatible = serde_json::json!({ - "protocolVersion": 1, + "protocolVersion": DISPATCH_PROTOCOL_VERSION, "capabilities": capabilities, }); assert!(dispatch_protocol_is_compatible(&compatible)); let old = serde_json::json!({ - "protocolVersion": 0, + "protocolVersion": DISPATCH_PROTOCOL_VERSION - 1, "capabilities": capabilities, }); assert!(!dispatch_protocol_is_compatible(&old)); let missing = serde_json::json!({ - "protocolVersion": 1, + "protocolVersion": DISPATCH_PROTOCOL_VERSION, "capabilities": ["persistent_jobs", "cursor_events"], }); assert!(!dispatch_protocol_is_compatible(&missing)); let reject_only = serde_json::json!({ - "protocolVersion": 1, + "protocolVersion": DISPATCH_PROTOCOL_VERSION, "capabilities": [ "persistent_jobs", "cursor_events", diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index dc3d8d0390..e62d25e81e 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -4667,6 +4667,83 @@ impl SSHConnectionManager { Ok(()) } + /// Stream one local regular file to a remote SFTP path without buffering + /// the complete file in memory. + pub async fn sftp_write_from_file( + &self, + connection_id: &str, + path: &str, + local_path: &std::path::Path, + max_bytes: u64, + ) -> anyhow::Result { + let local_metadata = tokio::fs::symlink_metadata(local_path) + .await + .map_err(|error| { + anyhow!( + "Failed to inspect local upload file '{}': {}", + local_path.display(), + error + ) + })?; + if local_metadata.file_type().is_symlink() || !local_metadata.is_file() { + return Err(anyhow!( + "Local SFTP upload source is not a regular file: {}", + local_path.display() + )); + } + if local_metadata.len() > max_bytes { + return Err(anyhow!( + "Local SFTP upload source exceeds the {} byte limit", + max_bytes + )); + } + + let path = self.resolve_sftp_path(connection_id, path).await?; + let sftp = self.get_sftp(connection_id).await?; + let mut remote = sftp + .create(&path) + .await + .map_err(|error| anyhow!("Failed to create remote file '{}': {}", path, error))?; + let mut local = tokio::fs::File::open(local_path).await.map_err(|error| { + anyhow!( + "Failed to open local upload file '{}': {}", + local_path.display(), + error + ) + })?; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buffer = vec![0_u8; 256 * 1024]; + let mut written = 0_u64; + loop { + let read = local.read(&mut buffer).await.map_err(|error| { + anyhow!( + "Failed to read local upload file '{}': {}", + local_path.display(), + error + ) + })?; + if read == 0 { + break; + } + written = written.saturating_add(read as u64); + if written > max_bytes || written > local_metadata.len() { + return Err(anyhow!("Local upload file changed while it was being sent")); + } + remote + .write_all(&buffer[..read]) + .await + .map_err(|error| anyhow!("Failed to write remote file '{}': {}", path, error))?; + } + if written != local_metadata.len() { + return Err(anyhow!("Local upload file changed while it was being sent")); + } + remote + .flush() + .await + .map_err(|error| anyhow!("Failed to flush remote file '{}': {}", path, error))?; + Ok(written) + } + /// Write a file via SFTP with chunked progress reporting. /// /// Writes `content` in `chunk_size`-byte chunks, invoking `on_progress` diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 78f1d11e0b..cb03638145 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -21,6 +21,34 @@ margin: 0 $size-gap-1 0 calc(#{$size-gap-1} + 4px); } + &__session-target-filter { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: $size-gap-1; + min-width: 0; + padding: 2px $size-gap-1 4px; + color: var(--color-text-muted); + font-size: var(--font-size-2xs); + + select { + min-width: 0; + height: 22px; + padding: 0 20px 0 6px; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: var(--element-bg-soft); + color: var(--color-text-secondary); + font: inherit; + text-overflow: ellipsis; + + &:focus-visible { + border-color: var(--color-accent-500); + outline: 1px solid var(--color-accent-500); + } + } + } + &__inline-action { display: flex; align-items: center; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 13a772d904..e6e67a8660 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -98,6 +98,13 @@ const resolveSessionModeType = (session: Session): SessionMode => { const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); +const dispatchFilterKey = (session: Session): string => { + const target = session.config.dispatchTarget; + if (target?.kind === 'ssh') return `ssh:${target.connectionId}`; + if (target?.kind === 'device') return `device:${target.deviceId}`; + return 'local'; +}; + const countTopLevelSessionsInScope = ( sessions: Iterable, workspacePath?: string, @@ -193,6 +200,7 @@ const SessionsSection: React.FC = ({ const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); const [expandLevel, setExpandLevel] = useState<0 | 1 | 2>(0); + const [dispatchTargetFilter, setDispatchTargetFilter] = useState('all'); // Level-2 ("show all") renders in pages of 200 rows so a huge session // history cannot mount thousands of un-virtualized rows at once. const [level2DisplayCount, setLevel2DisplayCount] = useState(SESSIONS_LEVEL_2_PAGE); @@ -261,11 +269,18 @@ const SessionsSection: React.FC = ({ const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; const trackedTurn = resolveTrackedTurn(session); const hasAskUser = hasPendingAskUserQuestion(trackedTurn); + const dispatchTarget = session.config.dispatchTarget; + const dispatchTargetSnapshot = dispatchTarget?.kind === 'ssh' + ? `ssh:${dispatchTarget.connectionId}:${dispatchTarget.workspacePath}:${dispatchTarget.displayName}` + : dispatchTarget?.kind === 'device' + ? `device:${dispatchTarget.deviceId}:${dispatchTarget.workspacePath}:${dispatchTarget.displayName}` + : 'local'; parts.push( `${session.sessionId}|${session.isTransient ? '1':'0'}|${session.sessionKind}|` + `${session.parentSessionId ?? ''}|${session.parentToolCallId ?? ''}|${session.subagentType ?? ''}|` + `${session.workspacePath ?? ''}|${session.mode ?? ''}|${session.needsUserAttention ? '1':'0'}|` + - `${session.hasUnreadCompletion ? '1':'0'}|${latestTurn?.status ?? ''}|${hasAskUser ? '1':'0'}|${trackedTurn?.id ?? ''}|${session.title ?? ''}` + `${session.hasUnreadCompletion ? '1':'0'}|${latestTurn?.status ?? ''}|${hasAskUser ? '1':'0'}|${trackedTurn?.id ?? ''}|` + + `${session.title ?? ''}|${dispatchTargetSnapshot}|${session.config.dispatchJobState ?? ''}` ); } return parts.join(';'); @@ -580,7 +595,7 @@ const SessionsSection: React.FC = ({ [flowChatState.sessions, workspacePath, remoteConnectionId, remoteSshHost] ); - const { topLevelSessions, childrenByParent } = useMemo(() => { + const { topLevelSessions: allTopLevelSessions, childrenByParent } = useMemo(() => { const childMap = new Map(); const parents: Session[] = []; @@ -607,6 +622,40 @@ const SessionsSection: React.FC = ({ }; }, [sessions]); + const dispatchTargetFilterOptions = useMemo(() => { + const options = new Map(); + for (const session of allTopLevelSessions) { + const key = dispatchFilterKey(session); + if (key === 'local') { + options.set(key, t('nav.sessions.filterLocal')); + continue; + } + const target = session.config.dispatchTarget; + if (target?.kind === 'ssh' || target?.kind === 'device') { + options.set(key, target.displayName); + } + } + return Array.from(options.entries()).map(([value, label]) => ({ value, label })); + }, [allTopLevelSessions, t]); + + useEffect(() => { + if ( + dispatchTargetFilter !== 'all' + && !dispatchTargetFilterOptions.some(option => option.value === dispatchTargetFilter) + ) { + setDispatchTargetFilter('all'); + } + }, [dispatchTargetFilter, dispatchTargetFilterOptions]); + + const topLevelSessions = useMemo( + () => dispatchTargetFilter === 'all' + ? allTopLevelSessions + : allTopLevelSessions.filter( + session => dispatchFilterKey(session) === dispatchTargetFilter, + ), + [allTopLevelSessions, dispatchTargetFilter], + ); + const sessionDisplayLimit = useMemo(() => { const total = topLevelSessions.length; if (expandLevel === 2) return Math.min(total, level2DisplayCount); @@ -615,13 +664,17 @@ const SessionsSection: React.FC = ({ return SESSIONS_LEVEL_0; }, [topLevelSessions.length, expandLevel, level2DisplayCount]); - const totalTopLevelSessionCount = getEffectiveTopLevelSessionCount( - metadataPageState.totalTopLevelCount, - metadataPageState.syncedTopLevelCount, - topLevelSessions.length, - metadataPageState.isLoading - ); - const hasMoreUnloadedSessions = topLevelSessions.length < totalTopLevelSessionCount; + const totalTopLevelSessionCount = dispatchTargetFilter === 'all' + ? getEffectiveTopLevelSessionCount( + metadataPageState.totalTopLevelCount, + metadataPageState.syncedTopLevelCount, + allTopLevelSessions.length, + metadataPageState.isLoading, + ) + : topLevelSessions.length; + const hasMoreUnloadedSessions = + dispatchTargetFilter === 'all' + && allTopLevelSessions.length < totalTopLevelSessionCount; const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); useEffect(() => { @@ -631,7 +684,7 @@ const SessionsSection: React.FC = ({ metadataPageState.isLoading || metadataPageState.totalTopLevelCount === null || metadataPageState.syncedTopLevelCount === null || - topLevelSessions.length === metadataPageState.syncedTopLevelCount + allTopLevelSessions.length === metadataPageState.syncedTopLevelCount ) { return; } @@ -643,7 +696,7 @@ const SessionsSection: React.FC = ({ metadataPageState.isLoading, metadataPageState.syncedTopLevelCount, metadataPageState.totalTopLevelCount, - topLevelSessions.length, + allTopLevelSessions.length, workspacePath, ]); @@ -1007,7 +1060,7 @@ const SessionsSection: React.FC = ({ totalTopLevelSessionCount, ]); - if (topLevelSessions.length === 0) { + if (allTopLevelSessions.length === 0) { if (metadataPageState.isLoading) { return (
@@ -1038,6 +1091,28 @@ const SessionsSection: React.FC = ({ return (
+ {dispatchTargetFilterOptions.length > 1 ? ( + + ) : null} + {topLevelSessions.length === 0 ? ( +
+ {t('nav.sessions.noSessionsForTarget')} +
+ ) : null} {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const relationship = resolveSessionRelationship(session); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss index 01a9438bd9..bc5deec635 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.scss @@ -23,6 +23,97 @@ align-items: center; } + &__delivery { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $size-gap-2; + min-width: 0; + margin: 0; + padding: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + + legend { + padding: 0 $size-gap-1; + font-size: var(--font-size-xs); + font-weight: 600; + } + + > button { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-2; + min-width: 0; + padding: $size-gap-2; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-primary); + text-align: left; + cursor: pointer; + + &:hover:not(:disabled), + &:focus-visible, + &[data-selected='true'] { + border-color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-accent-500) 9%, transparent); + outline: none; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + } + + strong { + font-size: var(--font-size-xs); + } + + small { + color: var(--color-text-muted); + font-size: var(--font-size-xxs); + line-height: 1.35; + } + } + } + + &__snapshot-warning { + display: flex; + flex-direction: column; + gap: $size-gap-2; + padding: $size-gap-3; + border: 1px solid var(--color-warning-border); + border-radius: $size-radius-base; + background: var(--color-warning-bg); + font-size: var(--font-size-xs); + + code { + overflow-wrap: anywhere; + color: var(--color-text-secondary); + } + + > span { + color: var(--color-text-secondary); + line-height: 1.45; + } + + label { + display: flex; + align-items: flex-start; + gap: $size-gap-2; + font-weight: 500; + line-height: 1.4; + cursor: pointer; + } + } + &__checks { display: grid; gap: $size-gap-1; @@ -120,7 +211,7 @@ &__approval { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: $size-gap-2; min-width: 0; margin: 0; @@ -198,6 +289,10 @@ &__approval { grid-template-columns: 1fr; } + + &__delivery { + grid-template-columns: 1fr; + } } } diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 11371d5cf0..0f211fb4a3 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -166,12 +166,16 @@ describe('DispatchInstallDialog installation lifecycle', () => { }); expect(mocks.installCliStart).toHaveBeenCalledTimes(1); expect(mocks.modalLifecycleProps).toEqual({ - closeOnOverlayClick: false, - showCloseButton: false, + closeOnOverlayClick: true, + showCloseButton: true, }); + const snapshotButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('dispatch.deliverySnapshot')); + expect(snapshotButton?.disabled).toBe(true); + expect(snapshotButton?.textContent).toContain('dispatch.deliverySnapshotUnavailable'); const cancelButton = Array.from(container.querySelectorAll('button')) .find(button => button.textContent === 'dispatch.cancel'); - expect(cancelButton?.disabled).toBe(true); + expect(cancelButton?.disabled).toBe(false); await act(async () => { mocks.modalOnClose?.(); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index 18522ce535..b6e5209ec7 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -8,7 +8,14 @@ import { } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; -import { Check, Loader2, RefreshCw, ShieldAlert, ShieldCheck } from 'lucide-react'; +import { + Check, + Loader2, + RefreshCw, + ShieldAlert, + ShieldCheck, + ShieldQuestion, +} from 'lucide-react'; import { dispatchApi } from './dispatchApi'; import type { DispatchApprovalPolicy, @@ -16,6 +23,7 @@ import type { DispatchSelection, DispatchSshProbe, DispatchTargetOption, + DispatchWorkspaceDeliveryRequest, } from './types'; import { BASE_DISPATCH_CAPABILITIES, @@ -36,12 +44,14 @@ interface ActiveInstall { function approvalCapability(policy: DispatchApprovalPolicy | null): string | null { if (policy === 'auto') return 'approval_auto'; if (policy === 'reject-and-report') return 'approval_reject_and_report'; + if (policy === 'remote') return 'approval_remote'; return null; } interface DispatchInstallDialogProps { open: boolean; target: DispatchTargetOption | null; + sourceWorkspacePath?: string; onClose: () => void; onReady: (selection: DispatchSelection) => void; } @@ -53,13 +63,17 @@ function errorMessage(error: unknown): string { export const DispatchInstallDialog: React.FC = ({ open, target, + sourceWorkspacePath, onClose, onReady, }) => { const { t } = useI18n('common'); const [workspacePath, setWorkspacePath] = useState(''); const [approvalPolicy, setApprovalPolicy] = useState(null); + const [deliveryKind, setDeliveryKind] = useState<'existing' | 'snapshot-exact'>('existing'); + const [sensitiveFilesConfirmed, setSensitiveFilesConfirmed] = useState(false); const [probe, setProbe] = useState(null); + const [probedWorkspaceInput, setProbedWorkspaceInput] = useState(null); const [probing, setProbing] = useState(false); const [installing, setInstalling] = useState(false); const [installStart, setInstallStart] = useState(null); @@ -71,25 +85,33 @@ export const DispatchInstallDialog: React.FC = ({ workspacePathRef.current = workspacePath; const connectionId = target?.connectionId?.trim() ?? ''; + const deviceId = target?.deviceId?.trim() ?? ''; + const targetId = target?.kind === 'device' ? deviceId : connectionId; + const deliveryKindRef = useRef(deliveryKind); + deliveryKindRef.current = deliveryKind; const runProbe = useCallback(async (pathOverride?: string) => { - if (!connectionId) return; - const path = (pathOverride ?? workspacePathRef.current).trim(); + if (!targetId || !target || target.kind === 'local') return; + const path = deliveryKindRef.current === 'existing' + ? (pathOverride ?? workspacePathRef.current).trim() + : ''; const generation = ++generationRef.current; setProbing(true); setError(null); try { - const result = await dispatchApi.probeTarget({ - kind: 'ssh', - connectionId, - workspacePath: path, - }); + const result = await dispatchApi.probeTarget( + target.kind === 'device' + ? { kind: 'device', deviceId: targetId, workspacePath: path } + : { kind: 'ssh', connectionId: targetId, workspacePath: path }, + ); if (generation === generationRef.current) { setProbe(result); + setProbedWorkspaceInput(path); } } catch (nextError) { if (generation === generationRef.current) { setProbe(null); + setProbedWorkspaceInput(null); setError(errorMessage(nextError)); } } finally { @@ -97,20 +119,23 @@ export const DispatchInstallDialog: React.FC = ({ setProbing(false); } } - }, [connectionId]); + }, [target, targetId]); useEffect(() => { - if (!open || !connectionId) return; + if (!open || !targetId) return; const initialPath = target?.defaultWorkspace?.trim() ?? ''; setWorkspacePath(initialPath); setApprovalPolicy(null); + setDeliveryKind('existing'); + setSensitiveFilesConfirmed(false); setProbe(null); + setProbedWorkspaceInput(null); setInstallStart(null); setInstallOutput(''); setInstalling(false); setError(null); void runProbe(initialPath); - }, [connectionId, open, runProbe, target?.defaultWorkspace]); + }, [open, runProbe, target?.defaultWorkspace, targetId]); const clearActiveInstall = useCallback((generation: number) => { if (activeInstallRef.current?.generation === generation) { @@ -133,9 +158,14 @@ export const DispatchInstallDialog: React.FC = ({ }, [cancelActiveInstall]); useEffect(() => { - if (!open || !connectionId) return; + if (!open || !targetId) return; return invalidateInstallLifecycle; - }, [connectionId, invalidateInstallLifecycle, open]); + }, [invalidateInstallLifecycle, open, targetId]); + + useEffect(() => { + if (!open || !targetId) return; + void runProbe(deliveryKind === 'existing' ? workspacePathRef.current : ''); + }, [deliveryKind, open, runProbe, targetId]); const pollInstallation = useCallback(async (generation: number) => { if (!connectionId) return; @@ -245,6 +275,9 @@ export const DispatchInstallDialog: React.FC = ({ const requiredCapabilities = [ ...BASE_DISPATCH_CAPABILITIES, ...(selectedApprovalCapability ? [selectedApprovalCapability] : []), + ...(deliveryKind === 'snapshot-exact' + ? ['workspace_snapshot_exact', 'workspace_snapshot_chunked'] + : []), ]; const missingCapabilities = protocol ? requiredCapabilities.filter(capability => !protocol.capabilities.includes(capability)) @@ -257,25 +290,50 @@ export const DispatchInstallDialog: React.FC = ({ !!protocol && !probe.protocolError && protocolCompatible; - const workspaceReady = isDispatchWorkspaceReady(workspacePath, workspace); + const workspaceReady = deliveryKind === 'snapshot-exact' + ? !!sourceWorkspacePath?.trim() && sensitiveFilesConfirmed + : isDispatchWorkspaceReady(workspacePath, workspace, probedWorkspaceInput ?? undefined); const modelReady = protocol?.modelConfigured === true; const ready = cliReady && workspaceReady && modelReady && approvalPolicy !== null; const confirmTarget = () => { - if (!target || !connectionId || !approvalPolicy || !ready) return; - const normalizedPath = workspacePath.trim(); + if ( + !target + || target.kind === 'local' + || !targetId + || !approvalPolicy + || !ready + ) return; + const normalizedPath = deliveryKind === 'existing' + ? workspace?.path?.trim() || workspacePath.trim() + : ''; + const workspaceDelivery: DispatchWorkspaceDeliveryRequest = + deliveryKind === 'snapshot-exact' + ? { + kind: 'snapshot-exact', + sourceWorkspacePath: sourceWorkspacePath!.trim(), + sensitiveFilesConfirmed: true, + } + : { kind: 'existing' }; + const request = target.kind === 'device' + ? { + kind: 'device' as const, + deviceId: targetId, + workspacePath: normalizedPath, + } + : { + kind: 'ssh' as const, + connectionId: targetId, + workspacePath: normalizedPath, + }; onReady({ - request: { - kind: 'ssh', - connectionId, - workspacePath: normalizedPath, - }, + request, target: { - kind: 'ssh', - connectionId, + ...request, workspacePath: normalizedPath, displayName: target.displayName, }, + workspaceDelivery, approvalPolicy, }); }; @@ -286,8 +344,8 @@ export const DispatchInstallDialog: React.FC = ({ onClose={close} title={t('dispatch.configureTitle', { target: target?.displayName ?? '' })} size="medium" - closeOnOverlayClick={!installing} - showCloseButton={!installing} + closeOnOverlayClick + showCloseButton testId="dispatch-install-dialog" >
@@ -295,31 +353,82 @@ export const DispatchInstallDialog: React.FC = ({ setError(null)} /> ) : null} -