From 98d8e9427a2a8a23da03c0b50d2634891361fff0 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Wed, 7 Oct 2026 23:10:43 -0700 Subject: [PATCH 1/2] feat(acp): add shared debug attachment resolver Summary: - validate exact workspace, runtime envelope, profile, and session bindings - attach through the retained ACP owner and expose transcript/unavailable modes - document the owner seam and add exact-path/no-replacement regression tests Rationale: - IDE and terminal debug consumers need one harness-neutral binding boundary - owner loss must not silently launch a competing ACP session Tests: - cargo fmt --all - cargo check-system-duckdb - cargo clippy with system DuckDB and all targets - cargo test-system-duckdb --- crates/opensymphony-acp/src/attachment.rs | 341 ++++++++++++++++++++++ crates/opensymphony-acp/src/host.rs | 7 +- crates/opensymphony-acp/src/lib.rs | 5 + docs/architecture.md | 10 + docs/testing-and-operations.md | 7 + docs/workspace-and-lifecycle.md | 8 + 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 crates/opensymphony-acp/src/attachment.rs diff --git a/crates/opensymphony-acp/src/attachment.rs b/crates/opensymphony-acp/src/attachment.rs new file mode 100644 index 000000000..b85c7ca37 --- /dev/null +++ b/crates/opensymphony-acp/src/attachment.rs @@ -0,0 +1,341 @@ +//! 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, 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, + 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 + } +} + +#[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 envelope = run + .runtime_envelope + .as_ref() + .ok_or(AttachmentError::BindingMismatch( + "runtime envelope is missing", + ))?; + validate_binding( + &issue, + &run, + &conversation, + state.identity.clone(), + state.session_id.as_deref(), + envelope, + )?; + 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) => { + let snapshot = owner + .inspect() + .await + .map_err(|error| AttachmentError::BindingMismatch(host_error_detail(error)))?; + Ok(DebugAttachment { + target, + mode: AttachmentMode::Live, + snapshot, + owner: Some(owner), + }) + } + Err(HostError::Unavailable) => { + let snapshot = super::inspect_recorded_session(manager, workspace) + .await + .map_err(|error| AttachmentError::BindingMismatch(host_error_detail(error)))?; + let mode = if snapshot.state.recovery == AcpRecovery::TranscriptOnly { + AttachmentMode::TranscriptInspection + } else { + AttachmentMode::Unavailable + }; + Ok(DebugAttachment { + target, + mode, + snapshot, + owner: None, + }) + } + Err(error) => Err(AttachmentError::BindingMismatch(host_error_detail(error))), + } +} + +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(()) +} + +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 a5498a321..a52edc8db 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -132,7 +132,12 @@ pub async fn inspect_recorded_session( if state.identity.workspace_path != workspace.workspace_path() { return Err(HostError::IdentityMismatch); } - state.recovery = AcpRecovery::TranscriptOnly; + // 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 must remain an unavailable context. + if state.session_id.is_some() { + 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 72459795c..033edb2f1 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 823f11ec3..f41dd7668 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -538,6 +538,16 @@ 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, runtime envelope, persisted ACP profile, run/attempt 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. If the owner is gone, +the resolver returns transcript inspection only when a native session ID and +recorded state exist. A launch without a native session is reported as +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 fa7153c16..ebc134f52 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -664,6 +664,13 @@ 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 and the no-runtime-binding failure path. 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 c2229bc75..cdd66be62 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -958,6 +958,14 @@ 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, checkout generation, repository binding, runtime envelope, profile, +attempt and opaque native session ID before returning a target. A live attach +borrows the retained owner and its status/event/command interfaces. An owner +loss produces either transcript inspection or unavailable context; it never +starts a replacement session. + 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 From 198d74fbb39364e319c5f177da02542bafab6d33 Mon Sep 17 00:00:00 2001 From: Leonardo Gonzalez Date: Wed, 7 Oct 2026 23:48:54 -0700 Subject: [PATCH 2/2] fix(acp): harden debug attachment binding validation Summary: - Validate terminal and parent runtime envelopes against current workspace state. - Check persisted ACP route/profile and complete live-owner identity. - Preserve restoration, reset, transcript, and unavailable attachment modes. - Document the expanded attachment validation and regression coverage. Rationale: - Prevent stale or cross-issue owners from being exposed through debug attach. - Keep parent sessions attachable without launching competing runtimes. - Preserve capability-gated recovery outcomes after owner loss. Tests: - cargo test-system-duckdb --lib attachment::tests -- --nocapture - cargo test-system-duckdb --test acp_session_host -- --nocapture - cargo test-system-duckdb --test debug -- --nocapture - cargo clippy --no-default-features --features duckdb-prebuilt --all-targets -- -D warnings - cargo test-system-duckdb (1480 passed on rerun) --- crates/opensymphony-acp/src/attachment.rs | 188 +++++++++++++++++----- crates/opensymphony-acp/src/host.rs | 16 +- docs/architecture.md | 13 +- docs/testing-and-operations.md | 13 +- docs/workspace-and-lifecycle.md | 15 +- 5 files changed, 182 insertions(+), 63 deletions(-) diff --git a/crates/opensymphony-acp/src/attachment.rs b/crates/opensymphony-acp/src/attachment.rs index b85c7ca37..6248a4166 100644 --- a/crates/opensymphony-acp/src/attachment.rs +++ b/crates/opensymphony-acp/src/attachment.rs @@ -7,8 +7,8 @@ use super::{HostError, SessionHandle, SessionHost, SessionSnapshot}; use crate::opensymphony_workspace::{ - AcpRecovery, AcpSessionIdentity, ConversationManifest, RunManifest, WorkspaceHandle, - WorkspaceManager, + AcpRecovery, AcpSessionIdentity, ConversationManifest, IssueDescriptor, ParentRuntimeEnvelope, + RunManifest, WorkspaceHandle, WorkspaceManager, }; use std::path::{Path, PathBuf}; @@ -38,6 +38,8 @@ pub struct AttachmentTarget { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AttachmentMode { Live, + CapabilityGatedRestoration { recovery: AcpRecovery }, + FreshContextReset, TranscriptInspection, Unavailable, } @@ -59,6 +61,10 @@ impl DebugAttachment { 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)] @@ -127,20 +133,37 @@ pub async fn resolve( .acp .as_ref() .ok_or(AttachmentError::BindingMismatch("harness is not ACP"))?; - let envelope = run - .runtime_envelope - .as_ref() - .ok_or(AttachmentError::BindingMismatch( + 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", - ))?; - validate_binding( - &issue, - &run, - &conversation, - state.identity.clone(), - state.session_id.as_deref(), - envelope, - )?; + )); + } if let Some(requested) = request.profile_id.as_deref() && requested != state.identity.profile_id { @@ -165,38 +188,60 @@ pub async fn resolve( .lookup(target.owner_id.clone(), target.generation) .await { - Ok(owner) => { - let snapshot = owner - .inspect() - .await - .map_err(|error| AttachmentError::BindingMismatch(host_error_detail(error)))?; - Ok(DebugAttachment { - target, - mode: AttachmentMode::Live, - snapshot, - owner: Some(owner), - }) - } - Err(HostError::Unavailable) => { - let snapshot = super::inspect_recorded_session(manager, workspace) - .await - .map_err(|error| AttachmentError::BindingMismatch(host_error_detail(error)))?; - let mode = if snapshot.state.recovery == AcpRecovery::TranscriptOnly { - AttachmentMode::TranscriptInspection - } else { - AttachmentMode::Unavailable - }; - Ok(DebugAttachment { - target, - mode, - snapshot, - owner: None, - }) - } + 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() @@ -244,6 +289,63 @@ fn validate_binding( 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?; diff --git a/crates/opensymphony-acp/src/host.rs b/crates/opensymphony-acp/src/host.rs index a52edc8db..5f195727e 100644 --- a/crates/opensymphony-acp/src/host.rs +++ b/crates/opensymphony-acp/src/host.rs @@ -132,10 +132,18 @@ pub async fn inspect_recorded_session( if state.identity.workspace_path != workspace.workspace_path() { return Err(HostError::IdentityMismatch); } - // 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 must remain an unavailable context. - if state.session_id.is_some() { + // 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 { diff --git a/docs/architecture.md b/docs/architecture.md index f41dd7668..fd69640eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -539,12 +539,13 @@ 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, runtime envelope, persisted ACP profile, run/attempt 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. If the owner is gone, -the resolver returns transcript inspection only when a native session ID and -recorded state exist. A launch without a native session is reported as +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. diff --git a/docs/testing-and-operations.md b/docs/testing-and-operations.md index ebc134f52..c4b8f693a 100644 --- a/docs/testing-and-operations.md +++ b/docs/testing-and-operations.md @@ -666,11 +666,14 @@ 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 and the no-runtime-binding failure path. 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. +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 cdd66be62..6ae47eecf 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -960,11 +960,16 @@ 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, checkout generation, repository binding, runtime envelope, profile, -attempt and opaque native session ID before returning a target. A live attach -borrows the retained owner and its status/event/command interfaces. An owner -loss produces either transcript inspection or unavailable context; it never -starts a replacement session. +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