diff --git a/crates/opensymphony-acp/src/attachment.rs b/crates/opensymphony-acp/src/attachment.rs new file mode 100644 index 00000000..6248a416 --- /dev/null +++ b/crates/opensymphony-acp/src/attachment.rs @@ -0,0 +1,443 @@ +//! Harness-neutral debug attachment resolution for host-owned ACP sessions. +//! +//! This module deliberately stops at the adapter boundary. Callers receive an +//! opaque target and, when the owner is still present, the existing +//! `SessionHandle`. They do not need to know the downstream protocol session +//! type or start another agent process. + +use super::{HostError, SessionHandle, SessionHost, SessionSnapshot}; +use crate::opensymphony_workspace::{ + AcpRecovery, AcpSessionIdentity, ConversationManifest, IssueDescriptor, ParentRuntimeEnvelope, + RunManifest, WorkspaceHandle, WorkspaceManager, +}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default)] +pub struct AttachmentRequest { + /// The exact path supplied by an IDE or terminal client. + pub cwd: Option, + /// A profile selected by the caller. `None` means use the persisted + /// profile and never redirect an existing session to the current default. + pub profile_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachmentTarget { + pub harness: String, + pub profile_id: String, + /// Opaque to the attachment core. ACP adapters may use UUIDs or another + /// agent-defined identifier without leaking that representation here. + pub native_session_id: Option, + pub owner_id: String, + pub generation: u64, + pub run_id: String, + pub attempt: u32, + pub workspace_path: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttachmentMode { + Live, + CapabilityGatedRestoration { recovery: AcpRecovery }, + FreshContextReset, + TranscriptInspection, + Unavailable, +} + +#[derive(Debug)] +pub struct DebugAttachment { + pub target: AttachmentTarget, + pub mode: AttachmentMode, + pub snapshot: SessionSnapshot, + pub owner: Option, +} + +impl DebugAttachment { + pub fn is_live(&self) -> bool { + self.mode == AttachmentMode::Live && self.owner.is_some() + } + + /// A transcript is evidence only. It cannot be used to submit a prompt. + pub fn is_transcript_only(&self) -> bool { + self.mode == AttachmentMode::TranscriptInspection + } + + pub fn is_restoration(&self) -> bool { + matches!(self.mode, AttachmentMode::CapabilityGatedRestoration { .. }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AttachmentError { + #[error("attachment cwd must be the exact issue workspace, not {provided}")] + WrongWorkspace { provided: PathBuf }, + #[error("issue workspace manifest is missing or does not identify this workspace")] + InvalidWorkspace, + #[error("run manifest is missing or does not match the issue workspace")] + InvalidRun, + #[error("conversation manifest is missing or does not match the issue workspace")] + InvalidConversation, + #[error("persisted ACP binding does not match the current runtime envelope: {0}")] + BindingMismatch(&'static str), + #[error("profile `{requested}` does not match the persisted ACP profile `{persisted}`")] + ProfileMismatch { + requested: String, + persisted: String, + }, + #[error("workspace validation failed: {0}")] + Workspace(#[from] crate::opensymphony_workspace::WorkspaceError), + #[error("failed to resolve attachment path: {0}")] + Io(#[from] std::io::Error), +} + +/// Resolve an IDE/terminal request against the host-owned ACP session. +/// +/// The resolver validates all durable identities before looking up the owner. +/// An owner loss produces a transcript or unavailable status and never starts +/// a replacement conversation. +pub async fn resolve( + manager: &WorkspaceManager, + host: &SessionHost, + workspace: &WorkspaceHandle, + request: AttachmentRequest, +) -> Result { + if let Some(cwd) = request.cwd.as_deref() + && !same_canonical_path(cwd, workspace.workspace_path()).await? + { + return Err(AttachmentError::WrongWorkspace { + provided: cwd.to_path_buf(), + }); + } + + let issue = manager + .load_issue_manifest(workspace) + .await? + .filter(|manifest| { + manifest.issue_id == workspace.issue_id() + && manifest.identifier == workspace.identifier() + && manifest.sanitized_workspace_key == workspace.workspace_key() + && manifest.workspace_path == workspace.workspace_path() + }) + .ok_or(AttachmentError::InvalidWorkspace)?; + let run = manager + .load_run_manifest(workspace) + .await? + .filter(|manifest| run_matches_workspace(manifest, workspace)) + .ok_or(AttachmentError::InvalidRun)?; + let conversation = manager + .load_conversation_manifest(workspace) + .await? + .filter(|manifest| conversation_matches_workspace(manifest, workspace)) + .ok_or(AttachmentError::InvalidConversation)?; + let state = conversation + .acp + .as_ref() + .ok_or(AttachmentError::BindingMismatch("harness is not ACP"))?; + let session_id = state.session_id.as_deref(); + validate_route(&run, state)?; + if let Some(envelope) = run.runtime_envelope.as_ref() { + validate_binding( + &issue, + &run, + &conversation, + state.identity.clone(), + session_id, + envelope, + )?; + manager.verify_runtime_envelope(workspace, envelope).await?; + } else if let Some(envelope) = run.parent_runtime_envelope.as_ref() { + validate_parent_binding(&run, &conversation, state, envelope, session_id)?; + let parent_issue = IssueDescriptor { + issue_id: issue.issue_id.clone(), + identifier: issue.identifier.clone(), + title: issue.title.clone(), + current_state: issue.current_state.clone(), + last_seen_tracker_refresh_at: issue.last_seen_tracker_refresh_at, + repository_binding: issue.repository_binding.clone(), + }; + let parent = manager + .open_parent_execution_root_at(&parent_issue, workspace.workspace_path()) + .await?; + manager.verify_parent_runtime_envelope(&parent, envelope)?; + } else { + return Err(AttachmentError::BindingMismatch( + "runtime envelope is missing", + )); + } + if let Some(requested) = request.profile_id.as_deref() + && requested != state.identity.profile_id + { + return Err(AttachmentError::ProfileMismatch { + requested: requested.to_owned(), + persisted: state.identity.profile_id.clone(), + }); + } + + let target = AttachmentTarget { + harness: state.harness.clone(), + profile_id: state.identity.profile_id.clone(), + native_session_id: state.session_id.clone(), + owner_id: state.owner_id.clone(), + generation: state.identity.generation, + run_id: state.identity.run_id.clone(), + attempt: state.identity.attempt, + workspace_path: workspace.workspace_path().to_path_buf(), + }; + + match host + .lookup(target.owner_id.clone(), target.generation) + .await + { + Ok(owner) => match owner.inspect().await { + Ok(snapshot) => { + validate_owner_snapshot( + &snapshot, + &state.identity, + state.session_id.as_deref(), + &state.owner_id, + )?; + Ok(DebugAttachment { + target, + mode: AttachmentMode::Live, + snapshot, + owner: Some(owner), + }) + } + Err(HostError::Unavailable) => recorded_attachment(manager, workspace, target).await, + Err(error) => Err(AttachmentError::BindingMismatch(host_error_detail(error))), + }, + Err(HostError::Unavailable) => recorded_attachment(manager, workspace, target).await, + Err(error) => Err(AttachmentError::BindingMismatch(host_error_detail(error))), + } +} + +async fn recorded_attachment( + manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + target: AttachmentTarget, +) -> Result { + let snapshot = super::inspect_recorded_session(manager, workspace) + .await + .map_err(|error| AttachmentError::BindingMismatch(host_error_detail(error)))?; + let mode = if snapshot.state.session_id.is_none() { + AttachmentMode::Unavailable + } else { + match snapshot.state.recovery { + AcpRecovery::RestoredLoad | AcpRecovery::RestoredResume => { + AttachmentMode::CapabilityGatedRestoration { + recovery: snapshot.state.recovery, + } + } + AcpRecovery::Fresh => AttachmentMode::FreshContextReset, + AcpRecovery::TranscriptOnly | AcpRecovery::LiveAttach => { + AttachmentMode::TranscriptInspection + } + } + }; + Ok(DebugAttachment { + target, + mode, + snapshot, + owner: None, + }) +} + +fn run_matches_workspace(manifest: &RunManifest, workspace: &WorkspaceHandle) -> bool { + manifest.issue_id == workspace.issue_id() + && manifest.identifier == workspace.identifier() + && manifest.sanitized_workspace_key == workspace.workspace_key() + && manifest.workspace_path == workspace.workspace_path() +} + +fn conversation_matches_workspace( + manifest: &ConversationManifest, + workspace: &WorkspaceHandle, +) -> bool { + manifest.issue_id == workspace.issue_id() && manifest.identifier == workspace.identifier() +} + +fn validate_binding( + issue: &crate::opensymphony_workspace::IssueManifest, + run: &RunManifest, + conversation: &ConversationManifest, + identity: AcpSessionIdentity, + session_id: Option<&str>, + envelope: &crate::opensymphony_workspace::TerminalRuntimeEnvelope, +) -> Result<(), AttachmentError> { + if issue + .repository_binding + .as_ref() + .and_then(|binding| binding.resolved_binding()) + != Some(&envelope.repository_binding) + { + return Err(AttachmentError::BindingMismatch("repository")); + } + if envelope.harness != "acp" + || envelope.checkout_path != run.workspace_path + || envelope.run_id != run.run_id + || envelope.attempt != run.attempt + || identity.workspace_path != run.workspace_path + || identity.run_id != run.run_id + || identity.attempt != run.attempt + || identity.checkout_generation.as_deref() != Some(envelope.checkout_generation.as_str()) + || conversation.conversation_id != session_id.unwrap_or_default() + || envelope.conversation_binding.as_deref() != session_id + || envelope.acp_session.as_ref() != Some(&identity) + { + return Err(AttachmentError::BindingMismatch("runtime/session")); + } + Ok(()) +} + +fn validate_parent_binding( + run: &RunManifest, + conversation: &ConversationManifest, + state: &crate::opensymphony_workspace::AcpSessionState, + envelope: &ParentRuntimeEnvelope, + session_id: Option<&str>, +) -> Result<(), AttachmentError> { + if envelope.harness != "acp" + || envelope.workspace_path != run.workspace_path + || envelope.run_id != run.run_id + || envelope.attempt != run.attempt + || state.harness != "acp" + || state.identity.workspace_path != run.workspace_path + || state.identity.run_id != run.run_id + || state.identity.attempt != run.attempt + || state.identity.checkout_generation.is_some() + || conversation.conversation_id != session_id.unwrap_or_default() + || envelope.conversation_binding.as_deref() != session_id + || conversation.parent_runtime_envelope.as_ref() != Some(envelope) + { + return Err(AttachmentError::BindingMismatch("parent runtime/session")); + } + Ok(()) +} + +fn validate_route( + run: &RunManifest, + state: &crate::opensymphony_workspace::AcpSessionState, +) -> Result<(), AttachmentError> { + let route = run + .acp_route + .as_ref() + .ok_or(AttachmentError::BindingMismatch("ACP route is missing"))?; + if route.harness_kind != "acp" + || route.harness_profile.as_deref() != Some(state.identity.profile_id.as_str()) + { + return Err(AttachmentError::BindingMismatch("ACP route/profile")); + } + Ok(()) +} + +fn validate_owner_snapshot( + snapshot: &SessionSnapshot, + identity: &AcpSessionIdentity, + session_id: Option<&str>, + owner_id: &str, +) -> Result<(), AttachmentError> { + if snapshot.state.harness != "acp" + || snapshot.state.owner_id != owner_id + || snapshot.state.identity != *identity + || snapshot.state.session_id.as_deref() != session_id + { + return Err(AttachmentError::BindingMismatch("live owner identity")); + } + Ok(()) +} + +async fn same_canonical_path(left: &Path, right: &Path) -> Result { + let left = tokio::fs::canonicalize(left).await?; + let right = tokio::fs::canonicalize(right).await?; + Ok(left == right) +} + +fn host_error_detail(error: HostError) -> &'static str { + match error { + HostError::Unavailable => "owner unavailable", + HostError::IdentityMismatch => "owner identity", + HostError::Busy => "owner busy", + HostError::ResourceLimit => "owner resource limit", + HostError::Persistence => "owner persistence", + HostError::AlreadyOwned => "owner already owned", + HostError::UncertainSubmission => "uncertain submission", + HostError::UncertainLaunch => "uncertain launch", + HostError::NativeManifest => "native manifest", + HostError::PersistenceUnsupported => "persistence unsupported", + HostError::Client(_) => "owner client", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opensymphony_workspace::{ + CleanupConfig, HookConfig, IssueDescriptor, WorkspaceManagerConfig, + }; + + async fn workspace(root: &Path) -> (WorkspaceManager, WorkspaceHandle) { + let manager = WorkspaceManager::new(WorkspaceManagerConfig { + root: root.to_path_buf(), + hooks: HookConfig::default(), + cleanup: CleanupConfig::default(), + }) + .expect("workspace manager"); + let handle = manager + .ensure(&IssueDescriptor { + issue_id: "issue-455".into(), + identifier: "COE-455".into(), + title: "debug attachment".into(), + current_state: "In Progress".into(), + last_seen_tracker_refresh_at: None, + repository_binding: None, + }) + .await + .expect("workspace") + .handle; + (manager, handle) + } + + #[tokio::test] + async fn rejects_parent_and_nested_paths_before_manifest_lookup() { + let root = tempfile::tempdir().expect("temp root"); + let (manager, handle) = workspace(root.path()).await; + let host = SessionHost::new(Default::default()).expect("host"); + + for cwd in [ + root.path().to_path_buf(), + handle.workspace_path().join(".opensymphony"), + ] { + let error = resolve( + &manager, + &host, + &handle, + AttachmentRequest { + cwd: Some(cwd), + ..Default::default() + }, + ) + .await + .expect_err("non-exact cwd must be rejected"); + assert!(matches!(error, AttachmentError::WrongWorkspace { .. })); + } + } + + #[tokio::test] + async fn exact_workspace_without_runtime_binding_cannot_start_replacement() { + let root = tempfile::tempdir().expect("temp root"); + let (manager, handle) = workspace(root.path()).await; + let host = SessionHost::new(Default::default()).expect("host"); + let error = resolve( + &manager, + &host, + &handle, + AttachmentRequest { + cwd: Some(handle.workspace_path().to_path_buf()), + ..Default::default() + }, + ) + .await + .expect_err("missing run binding must be rejected"); + assert!(matches!(error, AttachmentError::InvalidRun)); + } +} diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs index a5498a32..5f195727 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -132,7 +132,20 @@ pub async fn inspect_recorded_session( if state.identity.workspace_path != workspace.workspace_path() { return Err(HostError::IdentityMismatch); } - state.recovery = AcpRecovery::TranscriptOnly; + // Preserve negotiated restoration/reset outcomes. Otherwise a recorded + // native session can be inspected after its owner exits. A launch that + // never received a native session ID has no transcript to inspect and + // remains an unavailable context. + if manifest.reset_reason.is_some() { + state.recovery = AcpRecovery::Fresh; + } else if state.session_id.is_some() + && !matches!( + state.recovery, + AcpRecovery::RestoredLoad | AcpRecovery::RestoredResume + ) + { + state.recovery = AcpRecovery::TranscriptOnly; + } Ok(SessionSnapshot { state, live: false, diff --git a/crates/opensymphony-acp/src/lib.rs b/crates/opensymphony-acp/src/lib.rs index 72459795..033edb2f 100644 --- a/crates/opensymphony-acp/src/lib.rs +++ b/crates/opensymphony-acp/src/lib.rs @@ -42,6 +42,7 @@ use tokio_util::{ use tracing::instrument::WithSubscriber; mod atomic_file; +mod attachment; mod durable; mod extensions; mod host; @@ -49,6 +50,10 @@ mod operator; mod projection; mod services; mod session_config; +pub use attachment::{ + AttachmentError, AttachmentMode, AttachmentRequest, AttachmentTarget, DebugAttachment, + resolve as resolve_debug_attachment, +}; pub use extensions::OperationCapability; pub use host::*; pub use operator::{ diff --git a/docs/architecture.md b/docs/architecture.md index 823f11ec..fd69640e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -538,6 +538,17 @@ conversation manifest, with additive ACP identity in its runtime envelope. and ordered source events for a separate debug process. It does not grant IDE writer control; scheduler holds and writer transfer belong to OSYM-907. +The shared debug attachment resolver validates the exact issue workspace, issue +and run manifests, persisted ACP route/profile, run/attempt, current checkout +or parent execution envelope, and opaque native session binding before it looks +up an owner. A live result borrows the existing `SessionHandle`, so a separate +debug process uses the owner's control and event seam without launching another +agent. Owner snapshots are compared with the complete persisted identity before +control or events are exposed. If the owner is gone, the resolver distinguishes +capability-gated restoration, fresh-context reset, transcript inspection, and +unavailable context. The resolver does not create a debug manifest or session +database. + Handlers are installed before initialization. Permission callbacks receive the protocol cancellation outcome; unknown requests receive method-not-found and unknown notifications receive no response. Updates are processed before the diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index fa7153c1..c4b8f693 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -664,6 +664,16 @@ cancel responses, private large numeric peer RPC IDs with generated public binding tokens, disabled Cursor methods, and form-only capability advertisement. The production scheduler/gateway fixture routes a permission and a choice form through HTTP action receipts to the blocked peer. + +Debug attachment core regression tests also cover parent/store/nested-path +rejection, exact workspace binding, terminal and parent envelope verification, +persisted route/profile mismatches, complete owner identity comparison, the +no-runtime-binding failure path, owner-loss races, and the distinct +restoration/reset/transcript/unavailable modes. The host control-plane tests +verify authenticated commands and ordered events reach the same retained owner, +so a separate attachment cannot create a competing runtime. Multi-profile live +qualification remains an integration test against the configured ACP profiles +and their negotiated capabilities. The extension peer verifies a registered outbound request has the host-bound session ID and permitted `_meta`, returns a structured result, and rejects unregistered methods, stale runs, and unexpected argument keys. The peer sends diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index c2229bc7..6ae47eec 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -958,6 +958,19 @@ Recovery projects those descriptors for inspection, while a new invocation still requires the live owner, current run, exact profile registration, and peer capability check; a durable descriptor alone grants no execution right. +Debug attachment uses the same persisted records and owner registry. It accepts +only the canonical issue workspace path and checks the issue manifest, run +identity, persisted ACP route/profile, current terminal or parent runtime +envelope, checkout generation, repository binding, profile, attempt and opaque +native session ID before returning a target. The workspace manager re-verifies +the current terminal checkout or parent execution root against that envelope. +A live attach borrows the retained owner and its status/event/command +interfaces, after comparing the returned owner's complete identity with the +validated target. An owner loss produces capability-gated restoration, +fresh-context reset, transcript inspection, or unavailable context as recorded; +it never starts a replacement session. A lookup/inspection race follows the +same recorded-state fallback. + The manifest is atomically replaced and synced, including its parent directory, before a prompt can reach the transport. Cancellation after the synced `submitted` marker is rechecked before transport submission; when no prompt was