diff --git a/crates/ahandd/src/lib.rs b/crates/ahandd/src/lib.rs index f9eaca7..22d8314 100644 --- a/crates/ahandd/src/lib.rs +++ b/crates/ahandd/src/lib.rs @@ -10,6 +10,7 @@ pub mod file_manager; pub mod outbox; pub mod plugin_runtime; pub mod registry; +pub mod sandbox; pub mod session; pub mod store; pub mod updater; diff --git a/crates/ahandd/src/public_api.rs b/crates/ahandd/src/public_api.rs index bb66d68..4e0b7a0 100644 --- a/crates/ahandd/src/public_api.rs +++ b/crates/ahandd/src/public_api.rs @@ -11,17 +11,16 @@ //! Only the `ahand-cloud` connection mode is supported here — the //! `openclaw-gateway` path remains CLI-only for now. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; -use tokio::sync::{broadcast, oneshot, watch}; +use tokio::sync::{Mutex as AsyncMutex, broadcast, oneshot, watch}; use tokio::task::JoinHandle; -pub use ahand_protocol::SessionMode; - -use ahand_protocol::{ApprovalRequest, Envelope, envelope}; +pub use ahand_protocol::{ApprovalRequest, SessionMode}; +use ahand_protocol::{Envelope, envelope}; use crate::ahand_client::{self, ClientReporter, ConnectOutcome}; use crate::app_tool_registry::AppToolRegistry; @@ -30,6 +29,14 @@ use crate::browser::BrowserManager; use crate::config::{BrowserConfig, Config, FilePolicyConfig, HubConfig}; use crate::device_identity::DeviceIdentity; use crate::registry::JobRegistry; +use crate::sandbox::{ + CommitResult, FileVersion, HostFileRef, NetworkPolicy, PermissionSnapshot, + RegisterVersionRequest, RegisteredExecEnvironment, RuntimeExecuteRequest, RuntimeExecuteResult, + RuntimeProviderConfig, SandboxExecRequest, SandboxExecResult, SandboxFile, + SandboxPermissionMode, SandboxResult, SandboxSessionConfig, file_lifecycle, path_policy, + registry::SandboxRegistry, + runner::{self, PlatformExecuteRequest, RuntimeSandboxPolicy}, +}; use crate::session::SessionManager; pub use crate::app_tool_registry::{AppToolDef, AppToolError, AppToolHandler}; @@ -201,6 +208,30 @@ impl PartialEq for DaemonStatus { } } +/// Cursor over daemon approval requests for embedding UIs. +pub struct ApprovalSubscription { + rx: broadcast::Receiver, +} + +impl ApprovalSubscription { + fn new(rx: broadcast::Receiver) -> Self { + Self { rx } + } + + pub async fn recv(&mut self) -> Option { + loop { + match self.rx.recv().await { + Ok(envelope) => match envelope.payload { + Some(envelope::Payload::ApprovalRequest(req)) => return Some(req), + _ => continue, + }, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + } +} + /// Handle returned by [`spawn`]. Drop-safe — `shutdown()` is the preferred /// cleanup path, but dropping the handle also cancels the inner task via /// the embedded `oneshot` sender going out of scope. @@ -213,47 +244,18 @@ pub struct DaemonHandle { approval_broadcast_tx: broadcast::Sender, approval_mgr: Arc, session_mgr: Arc, + sandbox_registry: Arc>, } impl std::fmt::Debug for DaemonHandle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DaemonHandle") .field("device_id", &self.device_id) - .field("status", &self.status_rx.borrow().clone()) + .field("status", &self.status()) .finish_non_exhaustive() } } -/// Stream of approval requests for in-process embedders (Tauri apps etc.). -/// -/// Wraps the daemon's internal `Envelope` broadcast and yields only -/// [`ApprovalRequest`] payloads. Lagged subscribers skip missed messages -/// (tokio broadcast semantics); `None` means the daemon shut down. -pub struct ApprovalSubscription { - rx: broadcast::Receiver, -} - -impl ApprovalSubscription { - /// Receive the next [`ApprovalRequest`]. - /// - /// Returns `None` when the daemon broadcast channel is closed (i.e. the - /// daemon has shut down). Automatically skips lagged-out envelopes and - /// non-approval-request payloads. - pub async fn recv(&mut self) -> Option { - loop { - match self.rx.recv().await { - Ok(env) => { - if let Some(envelope::Payload::ApprovalRequest(req)) = env.payload { - return Some(req); - } - } - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => return None, - } - } - } -} - static ACTIVE_DAEMONS: OnceLock>> = OnceLock::new(); #[derive(Debug)] @@ -370,12 +372,12 @@ impl DaemonHandle { /// /// The `job_id` field on each [`ApprovalRequest`] is already namespaced: /// - /// 1. **Real jobs** — raw `job_id` from the cloud `JobRequest`; tool is + /// 1. **Real jobs** - raw `job_id` from the cloud `JobRequest`; tool is /// whatever the cloud sent (e.g. `"bash"`, `"computer"`). - /// 2. **File requests** — `"file-req:{request_id}"`; tool is `"file"`. + /// 2. **File requests** - `"file-req:{request_id}"`; tool is `"file"`. /// The prefix prevents a `request_id` from evicting a same-named real /// job entry. - /// 3. **App-tool calls** — `"app-tool:{tool_call_id}"`; tool is + /// 3. **App-tool calls** - `"app-tool:{tool_call_id}"`; tool is /// `"app:{name}"`. The prefix prevents a cloud-chosen `tool_call_id` /// from colliding with a real job's pending entry. /// @@ -390,20 +392,18 @@ impl DaemonHandle { /// /// ## Subscribe-before-traffic and multi-subscriber semantics /// - /// This channel does **not** replay missed messages — subscribe before + /// This channel does **not** replay missed messages - subscribe before /// sending traffic (or before spawning the task that will send traffic). /// Every active subscriber independently receives every request. pub fn subscribe_approvals(&self) -> ApprovalSubscription { - ApprovalSubscription { - rx: self.approval_broadcast_tx.subscribe(), - } + ApprovalSubscription::new(self.approval_broadcast_tx.subscribe()) } /// Answer a pending approval from the embedding application. /// /// Mirrors the WS/IPC `ApprovalResponse` handling exactly: - /// - deny with non-empty `reason` → `resolve` + `record_refusal` - /// - approve or deny without reason → `resolve` only + /// - deny with non-empty `reason` -> `resolve` + `record_refusal` + /// - approve or deny without reason -> `resolve` only /// /// The refusal log is keyed by tool name today (`SessionManager::record_refusal` /// ignores the caller principal). The `"local"` principal passed internally @@ -429,6 +429,250 @@ impl DaemonHandle { ) .await } + + pub async fn create_sandbox_session(&self, config: SandboxSessionConfig) -> SandboxResult<()> { + self.sandbox_registry.lock().await.create_session(config) + } + + pub async fn update_sandbox_permission_mode( + &self, + session_id: &str, + mode: SandboxPermissionMode, + ) -> SandboxResult { + self.sandbox_registry + .lock() + .await + .update_permission(session_id, mode) + } + + pub async fn register_sandbox_runtime( + &self, + session_id: &str, + provider: RuntimeProviderConfig, + ) -> SandboxResult<()> { + let provider = canonicalize_runtime_provider(provider)?; + let mut registry = self.sandbox_registry.lock().await; + let session = registry.session_mut(session_id)?; + session.runtimes.insert(provider.name.clone(), provider); + Ok(()) + } + + pub async fn import_sandbox_file( + &self, + session_id: &str, + file_ref: HostFileRef, + ) -> SandboxResult { + let mut registry = self.sandbox_registry.lock().await; + file_lifecycle::import_file(&mut registry, session_id, file_ref) + } + + pub async fn execute_sandbox_command( + &self, + session_id: &str, + request: SandboxExecRequest, + ) -> SandboxResult { + let (workspace_root, network, exec_env): ( + PathBuf, + NetworkPolicy, + RegisteredExecEnvironment, + ) = { + let registry = self.sandbox_registry.lock().await; + let session = registry.session(session_id)?; + ( + session.workspace_root.clone(), + session.network, + session.exec_environment(), + ) + }; + let SandboxExecRequest { + command, + cwd, + env: request_env, + timeout: request_timeout, + } = request; + let (program, args) = command.split_first().ok_or_else(|| { + crate::sandbox::SandboxError::invalid_command("sandbox command must not be empty") + })?; + + std::fs::create_dir_all(&workspace_root).map_err(|e| { + crate::sandbox::SandboxError::unavailable(format!( + "failed to create sandbox workspace root: {e}" + )) + })?; + let cwd = match cwd { + Some(cwd) => { + path_policy::resolve_existing_sandbox_path(&workspace_root, &cwd.to_string_lossy())? + } + None => workspace_root.canonicalize().map_err(|e| { + crate::sandbox::SandboxError::invalid_sandbox_path(format!( + "failed to resolve sandbox workspace root: {e}" + )) + })?, + }; + let executable = runner::resolve_executable(program, &exec_env.path_entries)?; + let mut env = exec_env.env; + merge_path_entries(&mut env, &exec_env.path_entries); + env.extend(request_env); + let timeout = request_timeout.unwrap_or(exec_env.default_timeout); + let policy = RuntimeSandboxPolicy { + writable_root: workspace_root, + readonly_roots: exec_env.readonly_roots, + network, + }; + + runner::execute(PlatformExecuteRequest { + executable, + args: args.to_vec(), + cwd, + env, + timeout, + policy, + }) + .await + } + + pub async fn execute_sandbox_runtime( + &self, + session_id: &str, + request: RuntimeExecuteRequest, + ) -> SandboxResult { + let provider = { + let registry = self.sandbox_registry.lock().await; + let session = registry.session(session_id)?; + session + .runtimes + .get(&request.runtime) + .cloned() + .ok_or_else(|| { + crate::sandbox::SandboxError::runtime_not_registered(format!( + "sandbox runtime '{}' is not registered", + request.runtime + )) + })? + }; + + let mut env = provider.env.clone(); + env.extend(request.env); + let command = std::iter::once(provider.executable.to_string_lossy().to_string()) + .chain(request.args) + .collect::>(); + + self.execute_sandbox_command( + session_id, + SandboxExecRequest { + command, + cwd: request.cwd, + env, + timeout: request.timeout.or(Some(provider.default_timeout)), + }, + ) + .await + } + + pub async fn register_sandbox_file_version( + &self, + session_id: &str, + request: RegisterVersionRequest, + ) -> SandboxResult { + let mut registry = self.sandbox_registry.lock().await; + file_lifecycle::register_file_version(&mut registry, session_id, request) + } + + pub async fn list_sandbox_file_versions( + &self, + session_id: &str, + ) -> SandboxResult> { + let registry = self.sandbox_registry.lock().await; + file_lifecycle::list_file_versions(®istry, session_id) + } + + pub async fn commit_sandbox_file_version( + &self, + session_id: &str, + version_id: &str, + ) -> SandboxResult { + let mut registry = self.sandbox_registry.lock().await; + file_lifecycle::commit_file_version(&mut registry, session_id, version_id) + } + + pub async fn confirm_sandbox_file_version_overwrite( + &self, + session_id: &str, + version_id: &str, + ) -> SandboxResult { + let mut registry = self.sandbox_registry.lock().await; + file_lifecycle::confirm_file_version_overwrite(&mut registry, session_id, version_id) + } + + pub async fn save_sandbox_file_version_as( + &self, + session_id: &str, + version_id: &str, + target_path: &Path, + ) -> SandboxResult { + let mut registry = self.sandbox_registry.lock().await; + file_lifecycle::save_file_version_as( + &mut registry, + session_id, + version_id, + target_path.to_path_buf(), + ) + } +} + +fn canonicalize_runtime_provider( + mut provider: RuntimeProviderConfig, +) -> SandboxResult { + let executable_entry = if provider.executable.is_absolute() { + provider.executable.clone() + } else { + std::env::current_dir() + .map_err(|e| { + crate::sandbox::SandboxError::unavailable(format!( + "failed to resolve current directory for sandbox runtime executable: {e}" + )) + })? + .join(&provider.executable) + }; + executable_entry.canonicalize().map_err(|e| { + crate::sandbox::SandboxError::unavailable(format!( + "failed to resolve sandbox runtime executable '{}': {e}", + executable_entry.display() + )) + })?; + provider.executable = executable_entry; + provider.readonly_roots = provider + .readonly_roots + .into_iter() + .map(|root| { + root.canonicalize().map_err(|e| { + crate::sandbox::SandboxError::unavailable(format!( + "failed to resolve sandbox runtime readonly root '{}': {e}", + root.display() + )) + }) + }) + .collect::>>()?; + provider.readonly_roots.sort(); + provider.readonly_roots.dedup(); + Ok(provider) +} + +fn merge_path_entries(env: &mut HashMap, path_entries: &[PathBuf]) { + let separator = if cfg!(windows) { ";" } else { ":" }; + let prefix = path_entries + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>() + .join(separator); + if prefix.is_empty() { + return; + } + let path = match env.get("PATH") { + Some(existing) if !existing.is_empty() => format!("{prefix}{separator}{existing}"), + _ => prefix, + }; + env.insert("PATH".to_string(), path); } /// Spawn an `ahandd` instance wired against the cloud hub described by `config`. @@ -537,6 +781,7 @@ pub async fn spawn(config: DaemonConfig) -> anyhow::Result { approval_broadcast_tx: approval_broadcast_tx_for_handle, approval_mgr: approval_mgr_for_handle, session_mgr: session_mgr_for_handle, + sandbox_registry: Arc::new(AsyncMutex::new(SandboxRegistry::default())), }) } @@ -939,6 +1184,71 @@ mod tests { assert!(!err.to_string().is_empty()); } + #[tokio::test] + async fn execute_sandbox_command_rejects_empty_command() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let workspace_root = temp.path().join("sandbox"); + std::fs::create_dir_all(&workspace_root).unwrap(); + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = spawn(cfg).await.unwrap(); + handle + .create_sandbox_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root, + network: NetworkPolicy::Enabled, + }) + .await + .unwrap(); + + let err = handle + .execute_sandbox_command( + "session-1", + SandboxExecRequest { + command: vec![], + cwd: None, + env: HashMap::new(), + timeout: Some(Duration::from_secs(1)), + }, + ) + .await + .unwrap_err(); + + assert_eq!(err.code, "INVALID_COMMAND"); + handle.shutdown().await.unwrap(); + } + + #[cfg(unix)] + #[test] + fn canonicalize_runtime_provider_preserves_executable_entry_path() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("runtime").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let alias = bin.join("python"); + symlink("/bin/echo", &alias).unwrap(); + + let provider = canonicalize_runtime_provider(RuntimeProviderConfig { + name: "python".to_string(), + executable: alias.clone(), + readonly_roots: vec![bin.clone(), PathBuf::from("/bin")], + env: HashMap::new(), + default_timeout: Duration::from_secs(10), + }) + .unwrap(); + + assert_eq!(provider.executable, alias); + assert!( + provider + .readonly_roots + .contains(&bin.canonicalize().unwrap()) + ); + } + #[test] fn load_or_create_identity_is_idempotent() { let dir = std::env::temp_dir().join(format!( diff --git a/crates/ahandd/src/sandbox/file_lifecycle.rs b/crates/ahandd/src/sandbox/file_lifecycle.rs new file mode 100644 index 0000000..786ab2b --- /dev/null +++ b/crates/ahandd/src/sandbox/file_lifecycle.rs @@ -0,0 +1,485 @@ +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; + +use super::path_policy::resolve_existing_sandbox_path; +use super::registry::SandboxRegistry; +use super::types::{ + CommitResult, FileVersion, FileVersionStatus, HostFileRef, RegisterVersionRequest, + SandboxError, SandboxFile, SandboxPermissionMode, SandboxResult, +}; + +pub fn import_file( + registry: &mut SandboxRegistry, + session_id: &str, + file_ref: HostFileRef, +) -> SandboxResult { + let session = registry.session_mut(session_id)?; + let input_dir = session + .workspace_root + .join("input") + .join(&file_ref.file_ref_id); + fs::create_dir_all(&input_dir).map_err(|e| { + SandboxError::unavailable(format!("failed to create sandbox input dir: {e}")) + })?; + let sandbox_path = input_dir.join(safe_file_name(&file_ref.display_name)); + fs::copy(&file_ref.source_path, &sandbox_path).map_err(|e| { + SandboxError::unavailable(format!("failed to import host file into sandbox: {e}")) + })?; + let size = fs::metadata(&sandbox_path) + .map_err(|e| SandboxError::unavailable(format!("failed to stat imported file: {e}")))? + .len(); + let sandbox_file = SandboxFile { + sandbox_file_id: format!( + "sandbox-file-{}", + sha256_hex(file_ref.file_ref_id.as_bytes()) + ), + file_ref_id: file_ref.file_ref_id.clone(), + sandbox_path, + size, + }; + session + .host_file_refs + .insert(file_ref.file_ref_id.clone(), file_ref.clone()); + session + .imported_files + .insert(file_ref.file_ref_id, sandbox_file.clone()); + Ok(sandbox_file) +} + +pub fn register_file_version( + registry: &mut SandboxRegistry, + session_id: &str, + request: RegisterVersionRequest, +) -> SandboxResult { + let session = registry.session(session_id)?; + let sandbox_path = resolve_existing_sandbox_path( + &session.workspace_root, + &request.sandbox_path.to_string_lossy(), + )?; + let metadata = fs::metadata(&sandbox_path).map_err(|e| { + SandboxError::invalid_sandbox_path(format!("failed to stat sandbox file: {e}")) + })?; + if !metadata.is_file() { + return Err(SandboxError::invalid_sandbox_path( + "registered sandbox path must be a file", + )); + } + let hash = sha256_file(&sandbox_path)?; + + let version = FileVersion { + version_id: format!("version-{hash}"), + sandbox_path, + source_file_ref_id: request.source_file_ref_id, + size: metadata.len(), + hash, + status: FileVersionStatus::Candidate, + }; + + registry + .session_mut(session_id)? + .file_versions + .insert(version.version_id.clone(), version.clone()); + + Ok(version) +} + +pub fn list_file_versions( + registry: &SandboxRegistry, + session_id: &str, +) -> SandboxResult> { + Ok(registry + .session(session_id)? + .file_versions + .values() + .cloned() + .collect()) +} + +pub fn commit_file_version( + registry: &mut SandboxRegistry, + session_id: &str, + version_id: &str, +) -> SandboxResult { + commit_registered_version(registry, session_id, version_id, CommitTarget::Source, true) +} + +pub fn confirm_file_version_overwrite( + registry: &mut SandboxRegistry, + session_id: &str, + version_id: &str, +) -> SandboxResult { + commit_registered_version( + registry, + session_id, + version_id, + CommitTarget::Source, + false, + ) +} + +pub fn save_file_version_as( + registry: &mut SandboxRegistry, + session_id: &str, + version_id: &str, + target_path: PathBuf, +) -> SandboxResult { + if !target_path.is_absolute() { + return Err(SandboxError::invalid_sandbox_path( + "save-as target path must be absolute", + )); + } + + commit_registered_version( + registry, + session_id, + version_id, + CommitTarget::ExplicitPath(target_path), + false, + ) +} + +enum CommitTarget { + Source, + ExplicitPath(PathBuf), +} + +fn commit_registered_version( + registry: &mut SandboxRegistry, + session_id: &str, + version_id: &str, + target: CommitTarget, + require_full_permission: bool, +) -> SandboxResult { + let snapshot = registry.permission_snapshot(session_id)?; + if require_full_permission && snapshot.mode != SandboxPermissionMode::Full { + return Err(SandboxError::permission_denied( + "full permission is required to commit a sandbox file version", + )); + } + let (version, workspace_root, source_file_ref_id, target_path, supersede_source_versions) = { + let session = registry.session(session_id)?; + let version = session + .file_versions + .get(version_id) + .cloned() + .ok_or_else(|| { + SandboxError::unknown_version(format!("unknown version '{version_id}'")) + })?; + let source_file_ref_id = match target { + CommitTarget::Source => { + let source_file_ref_id = version.source_file_ref_id.clone().ok_or_else(|| { + SandboxError::unknown_file_ref(format!( + "version '{version_id}' has no source file reference" + )) + })?; + let source_path = session + .host_file_refs + .get(&source_file_ref_id) + .map(|file_ref| file_ref.source_path.clone()) + .ok_or_else(|| { + SandboxError::unknown_file_ref(format!( + "unknown source file reference '{source_file_ref_id}'" + )) + })?; + (source_file_ref_id, source_path, true) + } + CommitTarget::ExplicitPath(target_path) => ( + target_path.to_string_lossy().to_string(), + target_path, + false, + ), + }; + + ( + version, + session.workspace_root.clone(), + source_file_ref_id.0, + source_file_ref_id.1, + source_file_ref_id.2, + ) + }; + + let copy_result = copy_version_to_target(&workspace_root, &version, &target_path)?; + mark_version_committed( + registry, + session_id, + version_id, + supersede_source_versions + .then_some(version.source_file_ref_id.as_deref()) + .flatten(), + )?; + + Ok(CommitResult { + version_id: version_id.to_string(), + source_file_ref_id, + backup_id: copy_result.backup_id, + old_hash: copy_result.old_hash, + new_hash: copy_result.new_hash, + bytes_written: copy_result.bytes_written, + permission_mode: snapshot.mode, + permission_version: snapshot.version, + }) +} + +struct CopyResult { + backup_id: Option, + old_hash: Option, + new_hash: String, + bytes_written: u64, +} + +fn copy_version_to_target( + workspace_root: &Path, + version: &FileVersion, + target_path: &Path, +) -> SandboxResult { + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|e| { + SandboxError::unavailable(format!("failed to create target directory: {e}")) + })?; + } + + let (backup_id, old_hash) = if target_path.exists() { + ( + Some(create_backup( + workspace_root, + &version.version_id, + target_path, + )?), + Some(sha256_file(target_path)?), + ) + } else { + (None, None) + }; + + fs::copy(&version.sandbox_path, target_path).map_err(|e| { + SandboxError::unavailable(format!("failed to copy sandbox file to target: {e}")) + })?; + let bytes_written = fs::metadata(target_path) + .map_err(|e| SandboxError::unavailable(format!("failed to stat target file: {e}")))? + .len(); + let new_hash = sha256_file(target_path)?; + + Ok(CopyResult { + backup_id, + old_hash, + new_hash, + bytes_written, + }) +} + +fn create_backup( + workspace_root: &Path, + version_id: &str, + source_path: &Path, +) -> SandboxResult { + let backups_dir = workspace_root.join("backups"); + fs::create_dir_all(&backups_dir) + .map_err(|e| SandboxError::unavailable(format!("failed to create backups dir: {e}")))?; + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| SandboxError::unavailable(format!("failed to read system time: {e}")))? + .as_millis(); + let backup_id = format!( + "backup-{timestamp_ms}-{}", + sha256_hex(format!("{version_id}:{}", source_path.display()).as_bytes()) + ); + let file_name = safe_file_name( + source_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("file"), + ); + let backup_path = backups_dir.join(format!("{backup_id}-{file_name}")); + + fs::copy(source_path, backup_path) + .map_err(|e| SandboxError::unavailable(format!("failed to backup target file: {e}")))?; + + Ok(backup_id) +} + +fn mark_version_committed( + registry: &mut SandboxRegistry, + session_id: &str, + version_id: &str, + source_file_ref_id: Option<&str>, +) -> SandboxResult<()> { + let session = registry.session_mut(session_id)?; + for version in session.file_versions.values_mut() { + if version.version_id == version_id { + version.status = FileVersionStatus::Committed; + } else if source_file_ref_id.is_some() + && version.source_file_ref_id.as_deref() == source_file_ref_id + && version.status == FileVersionStatus::Candidate + { + version.status = FileVersionStatus::Superseded; + } + } + + Ok(()) +} + +fn safe_file_name(name: &str) -> String { + Path::new(name) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("input") + .to_string() +} + +fn sha256_file(path: &Path) -> SandboxResult { + let mut file = fs::File::open(path) + .map_err(|e| SandboxError::unavailable(format!("failed to open file for hashing: {e}")))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 8192]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| SandboxError::unavailable(format!("failed to hash file: {e}")))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + + Ok(format!("{:x}", hasher.finalize())) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::registry::SandboxRegistry; + use crate::sandbox::types::{ + FileVersionStatus, HostFileRef, NetworkPolicy, RegisterVersionRequest, + SandboxPermissionMode, SandboxSessionConfig, + }; + use std::fs; + + fn registry_with_session(root: &std::path::Path) -> SandboxRegistry { + let mut registry = SandboxRegistry::default(); + registry + .create_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root: root.to_path_buf(), + network: NetworkPolicy::Enabled, + }) + .unwrap(); + registry + } + + #[test] + fn import_file_copies_source_into_session_input() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + let source = temp.path().join("source.txt"); + fs::create_dir_all(&root).unwrap(); + fs::write(&source, "hello").unwrap(); + let mut registry = registry_with_session(&root); + + let file = import_file( + &mut registry, + "session-1", + HostFileRef { + file_ref_id: "file-ref-1".to_string(), + source_path: source.clone(), + display_name: "source.txt".to_string(), + size: 5, + mtime_ms: None, + conversation_id: None, + }, + ) + .unwrap(); + + assert!( + file.sandbox_path + .starts_with(root.canonicalize().unwrap().join("input")) + ); + assert_eq!(fs::read_to_string(file.sandbox_path).unwrap(), "hello"); + } + + #[test] + fn import_file_strips_path_components_from_display_name() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + let source = temp.path().join("source.txt"); + fs::create_dir_all(&root).unwrap(); + fs::write(&source, "hello").unwrap(); + let mut registry = registry_with_session(&root); + + let file = import_file( + &mut registry, + "session-1", + HostFileRef { + file_ref_id: "file-ref-1".to_string(), + source_path: source, + display_name: "../source.txt".to_string(), + size: 5, + mtime_ms: None, + conversation_id: None, + }, + ) + .unwrap(); + + assert!( + file.sandbox_path + .starts_with(root.canonicalize().unwrap().join("input")) + ); + assert_eq!(file.sandbox_path.file_name().unwrap(), "source.txt"); + } + + #[test] + fn register_file_version_returns_candidate_hash_and_size() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + fs::create_dir_all(root.join("workspace")).unwrap(); + fs::write(root.join("workspace/out.txt"), "updated").unwrap(); + let mut registry = registry_with_session(&root); + + let version = register_file_version( + &mut registry, + "session-1", + RegisterVersionRequest { + sandbox_path: std::path::PathBuf::from("workspace/out.txt"), + source_file_ref_id: Some("file-ref-1".to_string()), + }, + ) + .unwrap(); + + assert_eq!(version.size, 7); + assert_eq!(version.hash.len(), 64); + assert_eq!(version.version_id, format!("version-{}", version.hash)); + assert_eq!(version.status, FileVersionStatus::Candidate); + } + + #[test] + fn commit_requires_full_permission() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + fs::create_dir_all(root.join("workspace")).unwrap(); + fs::write(root.join("workspace/out.txt"), "updated").unwrap(); + let mut registry = registry_with_session(&root); + + let version = register_file_version( + &mut registry, + "session-1", + RegisterVersionRequest { + sandbox_path: std::path::PathBuf::from("workspace/out.txt"), + source_file_ref_id: Some("file-ref-1".to_string()), + }, + ) + .unwrap(); + let err = commit_file_version(&mut registry, "session-1", &version.version_id).unwrap_err(); + + assert_eq!(err.code, "PERMISSION_DENIED"); + } +} diff --git a/crates/ahandd/src/sandbox/mod.rs b/crates/ahandd/src/sandbox/mod.rs new file mode 100644 index 0000000..6adfac4 --- /dev/null +++ b/crates/ahandd/src/sandbox/mod.rs @@ -0,0 +1,13 @@ +pub mod file_lifecycle; +pub mod path_policy; +pub mod platform; +pub mod registry; +pub mod runner; +pub mod types; + +pub use types::{ + CommitResult, FileVersion, FileVersionStatus, HostFileRef, NetworkPolicy, PermissionSnapshot, + RegisterVersionRequest, RegisteredExecEnvironment, RuntimeExecuteRequest, RuntimeExecuteResult, + RuntimeProviderConfig, SandboxError, SandboxExecRequest, SandboxExecResult, SandboxFile, + SandboxPermissionMode, SandboxResult, SandboxSessionConfig, +}; diff --git a/crates/ahandd/src/sandbox/path_policy.rs b/crates/ahandd/src/sandbox/path_policy.rs new file mode 100644 index 0000000..af6cbaf --- /dev/null +++ b/crates/ahandd/src/sandbox/path_policy.rs @@ -0,0 +1,125 @@ +use std::path::{Component, Path, PathBuf}; + +use super::types::{SandboxError, SandboxResult}; + +pub fn resolve_existing_sandbox_path(root: &Path, relative_path: &str) -> SandboxResult { + let candidate = sandbox_candidate(root, relative_path)?; + let canonical_root = root.canonicalize().map_err(|e| { + SandboxError::invalid_sandbox_path(format!("failed to resolve sandbox root: {e}")) + })?; + let canonical_candidate = candidate.canonicalize().map_err(|e| { + SandboxError::invalid_sandbox_path(format!("failed to resolve sandbox path: {e}")) + })?; + + if !canonical_candidate.starts_with(&canonical_root) { + return Err(SandboxError::invalid_sandbox_path( + "sandbox path escapes session root", + )); + } + + Ok(canonical_candidate) +} + +pub fn resolve_new_sandbox_path(root: &Path, relative_path: &str) -> SandboxResult { + let candidate = sandbox_candidate(root, relative_path)?; + let parent = candidate.parent().ok_or_else(|| { + SandboxError::invalid_sandbox_path("sandbox path has no parent directory") + })?; + let relative_parent = parent.strip_prefix(root).unwrap_or(parent); + let canonical_parent = resolve_existing_sandbox_path(root, &relative_parent.to_string_lossy())?; + let file_name = candidate + .file_name() + .ok_or_else(|| SandboxError::invalid_sandbox_path("sandbox path has no file name"))?; + + Ok(canonical_parent.join(file_name)) +} + +fn sandbox_candidate(root: &Path, relative_path: &str) -> SandboxResult { + let rel = Path::new(relative_path); + if rel.is_absolute() { + return Err(SandboxError::invalid_sandbox_path( + "sandbox paths must be relative to the session root", + )); + } + for component in rel.components() { + if matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) { + return Err(SandboxError::invalid_sandbox_path( + "sandbox path contains disallowed traversal", + )); + } + } + + Ok(root.join(rel)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn sandbox_path_must_stay_inside_root() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + fs::create_dir_all(root.join("workspace")).unwrap(); + fs::write(root.join("workspace/out.txt"), "ok").unwrap(); + + let inside = resolve_existing_sandbox_path(&root, "workspace/out.txt").unwrap(); + let outside = resolve_existing_sandbox_path(&root, "../outside.txt").unwrap_err(); + + assert!(inside.ends_with("workspace/out.txt")); + assert_eq!(outside.code, "INVALID_SANDBOX_PATH"); + } + + #[cfg(unix)] + #[test] + fn symlink_escape_is_rejected() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + let outside = temp.path().join("outside"); + fs::create_dir_all(root.join("workspace")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("secret.txt"), "secret").unwrap(); + symlink(outside.join("secret.txt"), root.join("workspace/link.txt")).unwrap(); + + let err = resolve_existing_sandbox_path(&root, "workspace/link.txt").unwrap_err(); + + assert_eq!(err.code, "INVALID_SANDBOX_PATH"); + } + + #[test] + fn new_sandbox_path_uses_existing_parent_inside_root() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + fs::create_dir_all(root.join("workspace")).unwrap(); + + let resolved = resolve_new_sandbox_path(&root, "workspace/new.txt").unwrap(); + + assert_eq!( + resolved, + root.canonicalize().unwrap().join("workspace/new.txt") + ); + } + + #[cfg(unix)] + #[test] + fn new_sandbox_path_rejects_symlinked_parent_escape() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sandbox"); + let outside = temp.path().join("outside"); + fs::create_dir_all(root.join("workspace")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, root.join("workspace/link")).unwrap(); + + let err = resolve_new_sandbox_path(&root, "workspace/link/new.txt").unwrap_err(); + + assert_eq!(err.code, "INVALID_SANDBOX_PATH"); + } +} diff --git a/crates/ahandd/src/sandbox/platform/macos.rs b/crates/ahandd/src/sandbox/platform/macos.rs new file mode 100644 index 0000000..13434d8 --- /dev/null +++ b/crates/ahandd/src/sandbox/platform/macos.rs @@ -0,0 +1,245 @@ +use std::ffi::OsString; +use std::path::Path; +use std::process::Stdio; + +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio::time; + +use crate::sandbox::runner::{PlatformExecuteRequest, RuntimeSandboxPolicy}; +use crate::sandbox::types::{NetworkPolicy, RuntimeExecuteResult, SandboxError, SandboxResult}; + +const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; +const SYSTEM_READONLY_ROOTS: &[&str] = &[ + "/bin", + "/sbin", + "/usr/bin", + "/usr/lib", + "/usr/libexec", + "/usr/sbin", + "/usr/share", + "/System/Library/CoreServices", + "/System/Library/Extensions", + "/System/Library/Frameworks", + "/System/Library/PrivateFrameworks", + "/System/Library/SubFrameworks", + "/System/Volumes/Preboot/Cryptexes/OS", + "/Library/Apple", + "/Library/Preferences", +]; +const SYSTEM_EXECUTABLE_ROOTS: &[&str] = &[ + "/bin", + "/sbin", + "/usr/bin", + "/usr/lib", + "/usr/libexec", + "/usr/sbin", + "/System/Library/Extensions", + "/System/Library/Frameworks", + "/System/Library/PrivateFrameworks", + "/System/Library/SubFrameworks", + "/System/Volumes/Preboot/Cryptexes/OS", + "/Library/Apple", +]; + +pub async fn execute(request: PlatformExecuteRequest) -> SandboxResult { + let policy = render_policy(&request.policy); + let args = sandbox_exec_args(policy, &request.executable, &request.args); + let mut command = Command::new(SANDBOX_EXEC); + command + .args(args) + .current_dir(&request.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command.env_clear(); + for (key, value) in request.env { + command.env(key, value); + } + + let mut child = command.spawn().map_err(|e| { + SandboxError::unavailable(format!("failed to spawn sandboxed runtime: {e}")) + })?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| SandboxError::unavailable("failed to capture sandboxed runtime stdout"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| SandboxError::unavailable("failed to capture sandboxed runtime stderr"))?; + let stdout_task = tokio::spawn(async move { + let mut buf = Vec::new(); + let _ = stdout.read_to_end(&mut buf).await; + String::from_utf8_lossy(&buf).to_string() + }); + let stderr_task = tokio::spawn(async move { + let mut buf = Vec::new(); + let _ = stderr.read_to_end(&mut buf).await; + String::from_utf8_lossy(&buf).to_string() + }); + + let wait = time::timeout(request.timeout, child.wait()).await; + let timed_out = wait.is_err(); + if timed_out { + let _ = child.kill().await; + } + let exit_code = match wait { + Ok(Ok(status)) => Some(status.code().unwrap_or(-1)), + Ok(Err(e)) => { + return Err(SandboxError::unavailable(format!( + "failed waiting for sandboxed runtime: {e}" + ))); + } + Err(_) => None, + }; + + Ok(RuntimeExecuteResult { + stdout: stdout_task.await.unwrap_or_default(), + stderr: stderr_task.await.unwrap_or_default(), + exit_code, + timed_out, + }) +} + +fn sandbox_exec_args(policy: String, executable: &Path, args: &[String]) -> Vec { + let mut argv = vec![ + OsString::from("-p"), + OsString::from(policy), + OsString::from("--"), + executable.as_os_str().to_os_string(), + ]; + argv.extend(args.iter().map(OsString::from)); + argv +} + +pub fn render_policy(policy: &RuntimeSandboxPolicy) -> String { + let mut sbpl = String::from("(version 1)\n(deny default)\n"); + sbpl.push_str("(allow process-exec)\n"); + sbpl.push_str("(allow process-fork)\n"); + sbpl.push_str("(allow signal (target same-sandbox))\n"); + sbpl.push_str("(allow process-info* (target same-sandbox))\n"); + sbpl.push_str("(allow file-read-metadata)\n"); + sbpl.push_str("(allow file-read* (literal \"/\"))\n"); + sbpl.push_str("(allow sysctl-read)\n"); + for root in SYSTEM_READONLY_ROOTS { + sbpl.push_str(&format!("(allow file-read* (subpath \"{root}\"))\n")); + } + for root in SYSTEM_EXECUTABLE_ROOTS { + sbpl.push_str(&format!( + "(allow file-map-executable (subpath \"{root}\"))\n" + )); + } + for root in &policy.readonly_roots { + sbpl.push_str(&format!( + "(allow file-read* (subpath \"{}\"))\n", + escape_sbpl(&root.to_string_lossy()) + )); + } + sbpl.push_str(&format!( + "(allow file-read* (subpath \"{}\"))\n", + escape_sbpl(&policy.writable_root.to_string_lossy()) + )); + sbpl.push_str(&format!( + "(allow file-write* (subpath \"{}\"))\n", + escape_sbpl(&policy.writable_root.to_string_lossy()) + )); + if policy.network == NetworkPolicy::Enabled { + sbpl.push_str("(allow network*)\n"); + } + sbpl +} + +fn escape_sbpl(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::runner::{PlatformExecuteRequest, RuntimeSandboxPolicy}; + use crate::sandbox::types::NetworkPolicy; + use std::collections::HashMap; + use std::path::PathBuf; + use std::time::Duration; + + #[test] + fn rendered_policy_allows_writable_root_and_runtime_reads() { + let policy = RuntimeSandboxPolicy { + writable_root: PathBuf::from("/sessions/s1"), + readonly_roots: vec![PathBuf::from("/runtimes/python")], + network: NetworkPolicy::Enabled, + }; + + let sbpl = render_policy(&policy); + + assert!(sbpl.contains("(allow file-read*")); + assert!(sbpl.contains("/runtimes/python")); + assert!(sbpl.contains("(allow file-write*")); + assert!(sbpl.contains("/sessions/s1")); + assert!(sbpl.contains("(allow network*")); + assert!(sbpl.contains("(allow sysctl-read)")); + assert!(sbpl.contains("(allow file-read* (literal \"/\"))")); + assert!(!sbpl.contains("(subpath \"/etc\")")); + } + + #[test] + fn rendered_policy_allows_runtime_path_and_workspace_only_for_writes() { + let policy = RuntimeSandboxPolicy { + writable_root: PathBuf::from("/sessions/s1"), + readonly_roots: vec![PathBuf::from("/runtime/python")], + network: NetworkPolicy::Enabled, + }; + + let sbpl = render_policy(&policy); + + assert!(sbpl.contains("(allow file-read* (subpath \"/runtime/python\"))")); + assert!(sbpl.contains("(allow file-read* (subpath \"/sessions/s1\"))")); + assert!(sbpl.contains("(allow file-write* (subpath \"/sessions/s1\"))")); + assert!(!sbpl.contains("(allow file-write* (subpath \"/runtime/python\"))")); + assert!(sbpl.contains("(allow network*")); + } + + #[test] + fn sandbox_exec_argv_separates_policy_from_sandboxed_command() { + let argv = sandbox_exec_args( + "(version 1)".to_string(), + &PathBuf::from("/runtime/python/bin/python"), + &["-c".to_string(), "print('ok')".to_string()], + ); + let argv = argv + .iter() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!(argv[0], "-p"); + assert_eq!(argv[1], "(version 1)"); + assert_eq!(argv[2], "--"); + assert_eq!(argv[3], "/runtime/python/bin/python"); + assert_eq!(argv[4], "-c"); + assert_eq!(argv[5], "print('ok')"); + } + + #[tokio::test] + #[ignore] + async fn macos_runtime_denies_outside_read() { + let temp = tempfile::tempdir().unwrap(); + let result = execute(PlatformExecuteRequest { + executable: PathBuf::from("/bin/sh"), + args: vec!["-c".into(), "/bin/cat /etc/passwd".into()], + cwd: temp.path().to_path_buf(), + env: HashMap::new(), + timeout: Duration::from_secs(5), + policy: RuntimeSandboxPolicy { + writable_root: temp.path().to_path_buf(), + readonly_roots: vec![PathBuf::from("/bin")], + network: NetworkPolicy::Enabled, + }, + }) + .await + .unwrap(); + + assert_ne!(result.exit_code, Some(0)); + assert!(!result.stdout.contains("root:")); + } +} diff --git a/crates/ahandd/src/sandbox/platform/mod.rs b/crates/ahandd/src/sandbox/platform/mod.rs new file mode 100644 index 0000000..f3c946c --- /dev/null +++ b/crates/ahandd/src/sandbox/platform/mod.rs @@ -0,0 +1,21 @@ +use super::runner::PlatformExecuteRequest; +use super::types::{RuntimeExecuteResult, SandboxResult}; + +#[cfg(target_os = "macos")] +pub mod macos; +pub mod unsupported; +#[cfg(windows)] +pub mod windows; + +pub async fn execute(request: PlatformExecuteRequest) -> SandboxResult { + #[cfg(target_os = "macos")] + { + return macos::execute(request).await; + } + #[cfg(windows)] + { + return windows::execute(request).await; + } + #[allow(unreachable_code)] + unsupported::execute(request).await +} diff --git a/crates/ahandd/src/sandbox/platform/unsupported.rs b/crates/ahandd/src/sandbox/platform/unsupported.rs new file mode 100644 index 0000000..1bac72a --- /dev/null +++ b/crates/ahandd/src/sandbox/platform/unsupported.rs @@ -0,0 +1,8 @@ +use crate::sandbox::runner::PlatformExecuteRequest; +use crate::sandbox::types::{RuntimeExecuteResult, SandboxError, SandboxResult}; + +pub async fn execute(_request: PlatformExecuteRequest) -> SandboxResult { + Err(SandboxError::unavailable( + "aHand sandbox runtime execution is unavailable on this platform", + )) +} diff --git a/crates/ahandd/src/sandbox/platform/windows.rs b/crates/ahandd/src/sandbox/platform/windows.rs new file mode 100644 index 0000000..0c43892 --- /dev/null +++ b/crates/ahandd/src/sandbox/platform/windows.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; + +use crate::sandbox::runner::{PlatformExecuteRequest, RuntimeSandboxPolicy}; +use crate::sandbox::types::{RuntimeExecuteResult, SandboxError, SandboxResult}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowsRuntimePolicy { + pub writable_root: PathBuf, + pub readonly_roots: Vec, +} + +impl WindowsRuntimePolicy { + pub fn from_runtime_policy(policy: RuntimeSandboxPolicy) -> Self { + Self { + writable_root: policy.writable_root, + readonly_roots: policy.readonly_roots, + } + } +} + +pub async fn execute(request: PlatformExecuteRequest) -> SandboxResult { + let _policy = WindowsRuntimePolicy::from_runtime_policy(request.policy); + Err(SandboxError::unavailable( + "Windows restricted runtime execution requires the aHand Windows sandbox backend", + )) +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use crate::sandbox::runner::RuntimeSandboxPolicy; + use crate::sandbox::types::NetworkPolicy; + use std::path::PathBuf; + + #[test] + fn windows_policy_tracks_writable_root_and_readonly_roots() { + let policy = WindowsRuntimePolicy::from_runtime_policy(RuntimeSandboxPolicy { + writable_root: PathBuf::from(r"C:\sessions\s1"), + readonly_roots: vec![PathBuf::from(r"C:\runtimes\python")], + network: NetworkPolicy::Enabled, + }); + + assert_eq!(policy.writable_root, PathBuf::from(r"C:\sessions\s1")); + assert_eq!( + policy.readonly_roots, + vec![PathBuf::from(r"C:\runtimes\python")] + ); + } +} diff --git a/crates/ahandd/src/sandbox/registry.rs b/crates/ahandd/src/sandbox/registry.rs new file mode 100644 index 0000000..22f22b6 --- /dev/null +++ b/crates/ahandd/src/sandbox/registry.rs @@ -0,0 +1,290 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::time::Duration; + +use super::types::{ + FileVersion, HostFileRef, NetworkPolicy, PermissionSnapshot, RegisteredExecEnvironment, + RuntimeProviderConfig, SandboxError, SandboxFile, SandboxPermissionMode, SandboxResult, + SandboxSessionConfig, +}; + +#[derive(Debug, Clone)] +pub struct SandboxSessionState { + pub session_id: String, + pub workspace_root: PathBuf, + pub network: NetworkPolicy, + pub runtimes: BTreeMap, + pub host_file_refs: BTreeMap, + pub imported_files: BTreeMap, + pub file_versions: BTreeMap, + permission: PermissionSnapshot, +} + +impl SandboxSessionState { + pub fn from_config(config: SandboxSessionConfig) -> Self { + Self { + session_id: config.session_id, + workspace_root: config.workspace_root, + network: config.network, + runtimes: BTreeMap::new(), + host_file_refs: BTreeMap::new(), + imported_files: BTreeMap::new(), + file_versions: BTreeMap::new(), + permission: PermissionSnapshot { + mode: config.permission_mode, + version: 1, + }, + } + } + + pub fn permission_snapshot(&self) -> PermissionSnapshot { + self.permission.clone() + } + + pub fn update_permission(&mut self, mode: SandboxPermissionMode) -> PermissionSnapshot { + if self.permission.mode != mode { + self.permission = PermissionSnapshot { + mode, + version: self.permission.version + 1, + }; + } + self.permission.clone() + } + + pub fn exec_environment(&self) -> RegisteredExecEnvironment { + let mut path_entries = Vec::new(); + let mut readonly_roots = Vec::new(); + let mut env = HashMap::new(); + + for provider in self.runtimes.values() { + if let Some(parent) = provider.executable.parent() { + push_unique_path(&mut path_entries, parent.to_path_buf()); + } + for root in &provider.readonly_roots { + push_unique_path(&mut readonly_roots, root.clone()); + } + for (key, value) in &provider.env { + env.insert(key.clone(), value.clone()); + } + } + + path_entries.sort(); + readonly_roots.sort(); + + RegisteredExecEnvironment { + path_entries, + readonly_roots, + env, + default_timeout: Duration::from_secs(30), + } + } +} + +fn push_unique_path(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + +#[derive(Debug, Default)] +pub struct SandboxRegistry { + sessions: BTreeMap, +} + +impl SandboxRegistry { + pub fn create_session(&mut self, config: SandboxSessionConfig) -> SandboxResult<()> { + let workspace_root = config.workspace_root.canonicalize().map_err(|e| { + SandboxError::unavailable(format!("failed to resolve sandbox workspace root: {e}")) + })?; + let session_id = config.session_id.clone(); + let config = SandboxSessionConfig { + workspace_root, + ..config + }; + self.sessions + .insert(session_id, SandboxSessionState::from_config(config)); + Ok(()) + } + + pub fn session(&self, session_id: &str) -> SandboxResult<&SandboxSessionState> { + self.sessions.get(session_id).ok_or_else(|| { + SandboxError::unavailable(format!("sandbox session '{session_id}' does not exist")) + }) + } + + pub fn session_mut(&mut self, session_id: &str) -> SandboxResult<&mut SandboxSessionState> { + self.sessions.get_mut(session_id).ok_or_else(|| { + SandboxError::unavailable(format!("sandbox session '{session_id}' does not exist")) + }) + } + + pub fn permission_snapshot(&self, session_id: &str) -> SandboxResult { + Ok(self.session(session_id)?.permission_snapshot()) + } + + pub fn update_permission( + &mut self, + session_id: &str, + mode: SandboxPermissionMode, + ) -> SandboxResult { + Ok(self.session_mut(session_id)?.update_permission(mode)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::types::{NetworkPolicy, SandboxPermissionMode, SandboxSessionConfig}; + use std::fs; + use std::path::PathBuf; + + fn config(workspace_root: PathBuf) -> SandboxSessionConfig { + SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root, + network: NetworkPolicy::Enabled, + } + } + + #[test] + fn create_session_initializes_permission_version() { + let temp = tempfile::tempdir().unwrap(); + let mut registry = SandboxRegistry::default(); + + registry.create_session(config(temp.path().into())).unwrap(); + let snapshot = registry.permission_snapshot("session-1").unwrap(); + + assert_eq!(snapshot.mode, SandboxPermissionMode::Readonly); + assert_eq!(snapshot.version, 1); + } + + #[test] + fn create_session_canonicalizes_workspace_root() { + let temp = tempfile::tempdir().unwrap(); + let workspace_root = temp.path().join("workspace"); + fs::create_dir_all(&workspace_root).unwrap(); + let root_with_parent_component = workspace_root.join("..").join("workspace"); + let mut registry = SandboxRegistry::default(); + + registry + .create_session(config(root_with_parent_component)) + .unwrap(); + + assert_eq!( + registry.session("session-1").unwrap().workspace_root, + workspace_root.canonicalize().unwrap() + ); + } + + #[test] + fn permission_update_increments_only_on_change() { + let temp = tempfile::tempdir().unwrap(); + let mut registry = SandboxRegistry::default(); + registry.create_session(config(temp.path().into())).unwrap(); + + let same = registry + .update_permission("session-1", SandboxPermissionMode::Readonly) + .unwrap(); + let changed = registry + .update_permission("session-1", SandboxPermissionMode::Full) + .unwrap(); + + assert_eq!(same.version, 1); + assert_eq!(changed.version, 2); + assert_eq!(changed.mode, SandboxPermissionMode::Full); + } + + #[test] + fn exec_environment_aggregates_provider_paths_roots_and_env() { + let temp = tempfile::tempdir().unwrap(); + let python_root = temp.path().join("python"); + let node_root = temp.path().join("node"); + let python_bin = python_root.join("bin"); + let node_bin = node_root.join("bin"); + fs::create_dir_all(&python_bin).unwrap(); + fs::create_dir_all(&node_bin).unwrap(); + fs::write(python_bin.join("python"), "").unwrap(); + fs::write(node_bin.join("node"), "").unwrap(); + + let mut session = SandboxSessionState::from_config(config(temp.path().into())); + session.runtimes.insert( + "python".to_string(), + RuntimeProviderConfig { + name: "python".to_string(), + executable: python_bin.join("python").canonicalize().unwrap(), + readonly_roots: vec![python_root.canonicalize().unwrap()], + env: std::collections::HashMap::from([( + "PYTHONNOUSERSITE".to_string(), + "1".to_string(), + )]), + default_timeout: std::time::Duration::from_secs(11), + }, + ); + session.runtimes.insert( + "node".to_string(), + RuntimeProviderConfig { + name: "node".to_string(), + executable: node_bin.join("node").canonicalize().unwrap(), + readonly_roots: vec![node_root.canonicalize().unwrap()], + env: std::collections::HashMap::from([( + "NODE_PATH".to_string(), + node_root.join("node_modules").to_string_lossy().to_string(), + )]), + default_timeout: std::time::Duration::from_secs(17), + }, + ); + + let env = session.exec_environment(); + + assert_eq!( + env.path_entries, + vec![ + node_bin.canonicalize().unwrap(), + python_bin.canonicalize().unwrap() + ] + ); + assert_eq!( + env.readonly_roots, + vec![ + node_root.canonicalize().unwrap(), + python_root.canonicalize().unwrap() + ] + ); + assert_eq!(env.env["PYTHONNOUSERSITE"], "1"); + assert!(env.env["NODE_PATH"].contains("node_modules")); + assert_eq!(env.default_timeout, std::time::Duration::from_secs(30)); + } + + #[test] + fn exec_environment_deduplicates_provider_paths_and_roots() { + let temp = tempfile::tempdir().unwrap(); + let runtime_root = temp.path().join("runtime"); + let runtime_bin = runtime_root.join("bin"); + fs::create_dir_all(&runtime_bin).unwrap(); + fs::write(runtime_bin.join("python"), "").unwrap(); + fs::write(runtime_bin.join("node"), "").unwrap(); + + let mut session = SandboxSessionState::from_config(config(temp.path().into())); + for name in ["python", "node"] { + session.runtimes.insert( + name.to_string(), + RuntimeProviderConfig { + name: name.to_string(), + executable: runtime_bin.join(name).canonicalize().unwrap(), + readonly_roots: vec![runtime_root.canonicalize().unwrap()], + env: std::collections::HashMap::new(), + default_timeout: std::time::Duration::from_secs(30), + }, + ); + } + + let env = session.exec_environment(); + + assert_eq!(env.path_entries, vec![runtime_bin.canonicalize().unwrap()]); + assert_eq!( + env.readonly_roots, + vec![runtime_root.canonicalize().unwrap()] + ); + } +} diff --git a/crates/ahandd/src/sandbox/runner.rs b/crates/ahandd/src/sandbox/runner.rs new file mode 100644 index 0000000..4d69843 --- /dev/null +++ b/crates/ahandd/src/sandbox/runner.rs @@ -0,0 +1,209 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use super::platform; +use super::types::{ + NetworkPolicy, RuntimeExecuteResult, RuntimeProviderConfig, SandboxError, SandboxResult, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeSandboxPolicy { + pub writable_root: PathBuf, + pub readonly_roots: Vec, + pub network: NetworkPolicy, +} + +impl RuntimeSandboxPolicy { + pub fn new( + writable_root: PathBuf, + provider: RuntimeProviderConfig, + network: NetworkPolicy, + ) -> Self { + Self { + writable_root, + readonly_roots: provider.readonly_roots, + network, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformExecuteRequest { + pub executable: PathBuf, + pub args: Vec, + pub cwd: PathBuf, + pub env: HashMap, + pub timeout: Duration, + pub policy: RuntimeSandboxPolicy, +} + +pub async fn execute(request: PlatformExecuteRequest) -> SandboxResult { + platform::execute(request).await +} + +pub fn resolve_executable(program: &str, path_entries: &[PathBuf]) -> SandboxResult { + if program.trim().is_empty() { + return Err(SandboxError::invalid_command( + "command program must not be empty", + )); + } + + let program_path = PathBuf::from(program); + if program_path.is_absolute() { + let is_registered_entry_path = path_entries + .iter() + .any(|entry| program_path.starts_with(entry)); + let resolved = program_path.canonicalize().map_err(|e| { + SandboxError::command_not_found(format!( + "failed to resolve sandbox command '{}': {e}", + program + )) + })?; + if is_registered_entry_path || path_entries.iter().any(|entry| resolved.starts_with(entry)) + { + return Ok(resolved); + } + return Err(SandboxError::invalid_command(format!( + "absolute sandbox command '{}' is outside registered runtime PATH", + program + ))); + } + + if program.contains('/') || program.contains('\\') { + return Err(SandboxError::invalid_command(format!( + "relative command paths are not allowed: {program}" + ))); + } + + for entry in path_entries { + let candidate = entry.join(program); + if candidate.exists() { + return candidate.canonicalize().map_err(|e| { + SandboxError::command_not_found(format!( + "failed to resolve sandbox command '{}': {e}", + candidate.display() + )) + }); + } + } + + Err(SandboxError::command_not_found(format!( + "sandbox command '{program}' was not found in registered runtime PATH" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::types::{NetworkPolicy, RuntimeProviderConfig}; + use std::collections::HashMap; + use std::path::PathBuf; + use std::time::Duration; + + #[test] + fn runtime_policy_contains_session_write_root_and_readonly_roots() { + let provider = RuntimeProviderConfig { + name: "python".into(), + executable: PathBuf::from("/runtimes/python/bin/python"), + readonly_roots: vec![PathBuf::from("/runtimes/python")], + env: HashMap::new(), + default_timeout: Duration::from_secs(30), + }; + let policy = RuntimeSandboxPolicy::new( + PathBuf::from("/sessions/s1"), + provider, + NetworkPolicy::Enabled, + ); + + assert_eq!(policy.writable_root, PathBuf::from("/sessions/s1")); + assert_eq!( + policy.readonly_roots, + vec![PathBuf::from("/runtimes/python")] + ); + assert_eq!(policy.network, NetworkPolicy::Enabled); + } + + #[test] + fn resolve_executable_finds_bare_command_in_registered_path_entries() { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let python = bin.join("python"); + std::fs::write(&python, "").unwrap(); + + let resolved = resolve_executable("python", std::slice::from_ref(&bin)).unwrap(); + + assert_eq!(resolved, python.canonicalize().unwrap()); + } + + #[test] + fn resolve_executable_rejects_unknown_bare_command() { + let temp = tempfile::tempdir().unwrap(); + let err = resolve_executable("python", &[temp.path().to_path_buf()]).unwrap_err(); + + assert_eq!(err.code, "COMMAND_NOT_FOUND"); + } + + #[test] + fn resolve_executable_rejects_relative_program_paths() { + let err = resolve_executable("./python", &[PathBuf::from("/bin")]).unwrap_err(); + + assert_eq!(err.code, "INVALID_COMMAND"); + } + + #[test] + fn resolve_executable_rejects_absolute_program_outside_registered_path_entries() { + let temp = tempfile::tempdir().unwrap(); + let allowed = temp.path().join("allowed"); + let denied = temp.path().join("denied"); + std::fs::create_dir_all(&allowed).unwrap(); + std::fs::create_dir_all(&denied).unwrap(); + let denied_program = denied.join("python"); + std::fs::write(&denied_program, "").unwrap(); + + let err = resolve_executable(&denied_program.to_string_lossy(), &[allowed]).unwrap_err(); + + assert_eq!(err.code, "INVALID_COMMAND"); + } + + #[cfg(unix)] + #[test] + fn resolve_executable_allows_absolute_alias_under_registered_path_entry() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + let target_dir = temp.path().join("target"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(&target_dir).unwrap(); + let target_program = target_dir.join("python3"); + std::fs::write(&target_program, "").unwrap(); + let alias = bin.join("python"); + symlink(&target_program, &alias).unwrap(); + + let resolved = resolve_executable(&alias.to_string_lossy(), &[bin]).unwrap(); + + assert_eq!(resolved, target_program.canonicalize().unwrap()); + } + + #[tokio::test] + async fn unsupported_platform_fails_closed() { + let request = PlatformExecuteRequest { + executable: PathBuf::from("/bin/echo"), + args: vec!["hello".into()], + cwd: PathBuf::from("/tmp"), + env: HashMap::new(), + timeout: Duration::from_secs(1), + policy: RuntimeSandboxPolicy { + writable_root: PathBuf::from("/tmp"), + readonly_roots: vec![], + network: NetworkPolicy::Enabled, + }, + }; + + let err = platform::unsupported::execute(request).await.unwrap_err(); + + assert_eq!(err.code, "SANDBOX_UNAVAILABLE"); + } +} diff --git a/crates/ahandd/src/sandbox/types.rs b/crates/ahandd/src/sandbox/types.rs new file mode 100644 index 0000000..5994a7f --- /dev/null +++ b/crates/ahandd/src/sandbox/types.rs @@ -0,0 +1,298 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +pub const CODE_SANDBOX_UNAVAILABLE: &str = "SANDBOX_UNAVAILABLE"; +pub const CODE_PERMISSION_DENIED: &str = "PERMISSION_DENIED"; +pub const CODE_INVALID_SANDBOX_PATH: &str = "INVALID_SANDBOX_PATH"; +pub const CODE_UNKNOWN_FILE_REF: &str = "UNKNOWN_FILE_REF"; +pub const CODE_UNKNOWN_VERSION: &str = "UNKNOWN_VERSION"; +pub const CODE_RUNTIME_NOT_REGISTERED: &str = "RUNTIME_NOT_REGISTERED"; +pub const CODE_INVALID_COMMAND: &str = "INVALID_COMMAND"; +pub const CODE_COMMAND_NOT_FOUND: &str = "COMMAND_NOT_FOUND"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SandboxPermissionMode { + Readonly, + Copy, + Full, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NetworkPolicy { + Enabled, + Disabled, + ProxyOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PermissionSnapshot { + pub mode: SandboxPermissionMode, + pub version: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeProviderConfig { + pub name: String, + pub executable: PathBuf, + pub readonly_roots: Vec, + pub env: HashMap, + pub default_timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxSessionConfig { + pub session_id: String, + pub permission_mode: SandboxPermissionMode, + pub workspace_root: PathBuf, + pub network: NetworkPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostFileRef { + pub file_ref_id: String, + pub source_path: PathBuf, + pub display_name: String, + pub size: u64, + pub mtime_ms: Option, + pub conversation_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxFile { + pub sandbox_file_id: String, + pub file_ref_id: String, + pub sandbox_path: PathBuf, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeExecuteRequest { + pub runtime: String, + pub args: Vec, + pub cwd: Option, + pub env: HashMap, + pub timeout: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxExecRequest { + pub command: Vec, + pub cwd: Option, + pub env: HashMap, + pub timeout: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeExecuteResult { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, + pub timed_out: bool, +} + +pub type SandboxExecResult = RuntimeExecuteResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisteredExecEnvironment { + pub path_entries: Vec, + pub readonly_roots: Vec, + pub env: HashMap, + pub default_timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisterVersionRequest { + pub sandbox_path: PathBuf, + pub source_file_ref_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileVersion { + pub version_id: String, + pub sandbox_path: PathBuf, + pub source_file_ref_id: Option, + pub size: u64, + pub hash: String, + pub status: FileVersionStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FileVersionStatus { + Candidate, + Committed, + Rejected, + Superseded, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommitResult { + pub version_id: String, + pub source_file_ref_id: String, + pub backup_id: Option, + pub old_hash: Option, + pub new_hash: String, + pub bytes_written: u64, + pub permission_mode: SandboxPermissionMode, + pub permission_version: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SandboxError { + pub code: String, + pub message: String, +} + +pub type SandboxResult = Result; + +impl SandboxError { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } + + pub fn unavailable(message: impl Into) -> Self { + Self::new(CODE_SANDBOX_UNAVAILABLE, message) + } + + pub fn permission_denied(message: impl Into) -> Self { + Self::new(CODE_PERMISSION_DENIED, message) + } + + pub fn invalid_sandbox_path(message: impl Into) -> Self { + Self::new(CODE_INVALID_SANDBOX_PATH, message) + } + + pub fn invalid_command(message: impl Into) -> Self { + Self::new(CODE_INVALID_COMMAND, message) + } + + pub fn command_not_found(message: impl Into) -> Self { + Self::new(CODE_COMMAND_NOT_FOUND, message) + } + + pub fn runtime_not_registered(message: impl Into) -> Self { + Self::new(CODE_RUNTIME_NOT_REGISTERED, message) + } + + pub fn unknown_file_ref(message: impl Into) -> Self { + Self::new(CODE_UNKNOWN_FILE_REF, message) + } + + pub fn unknown_version(message: impl Into) -> Self { + Self::new(CODE_UNKNOWN_VERSION, message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + use std::time::Duration; + + #[test] + fn permission_and_network_modes_serialize_lowercase() { + assert_eq!( + serde_json::to_value(SandboxPermissionMode::Readonly).unwrap(), + json!("readonly") + ); + assert_eq!( + serde_json::to_value(SandboxPermissionMode::Copy).unwrap(), + json!("copy") + ); + assert_eq!( + serde_json::to_value(SandboxPermissionMode::Full).unwrap(), + json!("full") + ); + assert_eq!( + serde_json::to_value(NetworkPolicy::Enabled).unwrap(), + json!("enabled") + ); + } + + #[test] + fn runtime_provider_keeps_executable_roots_env_and_timeout() { + let provider = RuntimeProviderConfig { + name: "python".to_string(), + executable: PathBuf::from("/opt/coffice/python/bin/python"), + readonly_roots: vec![PathBuf::from("/opt/coffice/python")], + env: HashMap::from([( + "PYTHONPATH".to_string(), + "/opt/coffice/python/lib".to_string(), + )]), + default_timeout: Duration::from_secs(30), + }; + + assert_eq!(provider.name, "python"); + assert_eq!( + provider.executable, + PathBuf::from("/opt/coffice/python/bin/python") + ); + assert_eq!( + provider.readonly_roots, + vec![PathBuf::from("/opt/coffice/python")] + ); + assert_eq!(provider.env["PYTHONPATH"], "/opt/coffice/python/lib"); + assert_eq!(provider.default_timeout, Duration::from_secs(30)); + } + + #[test] + fn sandbox_exec_request_keeps_command_cwd_env_and_timeout() { + let request = SandboxExecRequest { + command: vec![ + "python".to_string(), + "-c".to_string(), + "print('ok')".to_string(), + ], + cwd: Some(PathBuf::from("workspace")), + env: HashMap::from([("EXAMPLE".to_string(), "1".to_string())]), + timeout: Some(Duration::from_secs(7)), + }; + + assert_eq!(request.command[0], "python"); + assert_eq!(request.cwd, Some(PathBuf::from("workspace"))); + assert_eq!(request.env["EXAMPLE"], "1"); + assert_eq!(request.timeout, Some(Duration::from_secs(7))); + } + + #[test] + fn registered_exec_environment_preserves_path_roots_env_and_timeout() { + let env = RegisteredExecEnvironment { + path_entries: vec![PathBuf::from("/runtime/python/bin")], + readonly_roots: vec![PathBuf::from("/runtime/python")], + env: HashMap::from([("PYTHONNOUSERSITE".to_string(), "1".to_string())]), + default_timeout: Duration::from_secs(30), + }; + + assert_eq!(env.path_entries, vec![PathBuf::from("/runtime/python/bin")]); + assert_eq!(env.readonly_roots, vec![PathBuf::from("/runtime/python")]); + assert_eq!(env.env["PYTHONNOUSERSITE"], "1"); + assert_eq!(env.default_timeout, Duration::from_secs(30)); + } + + #[test] + fn command_error_constructors_preserve_codes() { + let invalid = SandboxError::invalid_command("command must not be empty"); + let missing = SandboxError::command_not_found("python was not found"); + + assert_eq!(invalid.code, "INVALID_COMMAND"); + assert_eq!(missing.code, "COMMAND_NOT_FOUND"); + } + + #[test] + fn sandbox_error_preserves_code_and_message() { + let err = SandboxError::permission_denied("full permission is required"); + + assert_eq!(err.code, "PERMISSION_DENIED"); + assert_eq!(err.message, "full permission is required"); + } +} diff --git a/crates/ahandd/tests/sandbox_api.rs b/crates/ahandd/tests/sandbox_api.rs new file mode 100644 index 0000000..4094b37 --- /dev/null +++ b/crates/ahandd/tests/sandbox_api.rs @@ -0,0 +1,301 @@ +#[cfg(target_os = "macos")] +use std::collections::HashMap; +use std::{path::PathBuf, time::Duration}; + +#[cfg(target_os = "macos")] +use ahandd::sandbox::{RuntimeExecuteRequest, RuntimeProviderConfig}; +use ahandd::{ + AppToolDef, AppToolHandler, DaemonConfig, + sandbox::{ + HostFileRef, NetworkPolicy, RegisterVersionRequest, SandboxPermissionMode, + SandboxSessionConfig, + }, +}; + +#[tokio::test] +async fn daemon_handle_exposes_sandbox_permission_updates() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let sandbox_root = temp.path().join("sandbox"); + std::fs::create_dir_all(&sandbox_root).unwrap(); + + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + + handle + .create_sandbox_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root: sandbox_root, + network: NetworkPolicy::Enabled, + }) + .await + .unwrap(); + let snapshot = handle + .update_sandbox_permission_mode("session-1", SandboxPermissionMode::Full) + .await + .unwrap(); + + assert_eq!(snapshot.mode, SandboxPermissionMode::Full); + assert_eq!(snapshot.version, 2); + + handle.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn daemon_handle_registers_app_tool_handlers() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + let handler: AppToolHandler = std::sync::Arc::new(|args| { + Box::pin(async move { Ok(serde_json::json!({ "received": args })) }) + }); + + handle + .register_app_tool( + AppToolDef { + name: "import_file".to_string(), + description: "Import a Coffice file pointer into the sandbox".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "fileRefId": { "type": "string" } + } + }), + requires_approval: false, + }, + handler.clone(), + ) + .await + .unwrap(); + let err = handle + .register_app_tool( + AppToolDef { + name: " ".to_string(), + description: "invalid".to_string(), + input_schema: serde_json::json!({ "type": "object" }), + requires_approval: false, + }, + handler, + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("invalid tool name")); + + handle.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn daemon_handle_exposes_approval_subscription_and_response() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + let _subscription = handle.subscribe_approvals(); + + assert!( + !handle + .respond_approval("missing-job", false, "not approved") + .await + ); + + handle.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn daemon_handle_persists_and_user_commits_candidate_versions() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let sandbox_root = temp.path().join("sandbox"); + let source = temp.path().join("source.txt"); + std::fs::create_dir_all(sandbox_root.join("workspace")).unwrap(); + std::fs::write(&source, "original").unwrap(); + + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + handle + .create_sandbox_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root: sandbox_root.clone(), + network: NetworkPolicy::Enabled, + }) + .await + .unwrap(); + handle + .import_sandbox_file( + "session-1", + HostFileRef { + file_ref_id: "file-ref-1".to_string(), + source_path: source.clone(), + display_name: "source.txt".to_string(), + size: 8, + mtime_ms: None, + conversation_id: None, + }, + ) + .await + .unwrap(); + std::fs::write(sandbox_root.join("workspace/out.txt"), "updated").unwrap(); + + let version = handle + .register_sandbox_file_version( + "session-1", + RegisterVersionRequest { + sandbox_path: PathBuf::from("workspace/out.txt"), + source_file_ref_id: Some("file-ref-1".to_string()), + }, + ) + .await + .unwrap(); + let versions = handle + .list_sandbox_file_versions("session-1") + .await + .unwrap(); + let agent_err = handle + .commit_sandbox_file_version("session-1", &version.version_id) + .await + .unwrap_err(); + + assert_eq!(versions, vec![version.clone()]); + assert_eq!(agent_err.code, "PERMISSION_DENIED"); + + let result = handle + .confirm_sandbox_file_version_overwrite("session-1", &version.version_id) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(&source).unwrap(), "updated"); + assert_eq!(result.version_id, version.version_id); + assert_eq!(result.source_file_ref_id, "file-ref-1"); + assert!(result.backup_id.is_some()); + assert_eq!(result.bytes_written, 7); + assert_eq!(result.permission_mode, SandboxPermissionMode::Readonly); + + let versions = handle + .list_sandbox_file_versions("session-1") + .await + .unwrap(); + assert_eq!( + versions[0].status, + ahandd::sandbox::FileVersionStatus::Committed + ); + + handle.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn daemon_handle_saves_candidate_version_as_user_selected_file() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let sandbox_root = temp.path().join("sandbox"); + let target = temp.path().join("exports").join("out.txt"); + std::fs::create_dir_all(sandbox_root.join("workspace")).unwrap(); + std::fs::write(sandbox_root.join("workspace/out.txt"), "copy").unwrap(); + + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + handle + .create_sandbox_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Copy, + workspace_root: sandbox_root, + network: NetworkPolicy::Enabled, + }) + .await + .unwrap(); + let version = handle + .register_sandbox_file_version( + "session-1", + RegisterVersionRequest { + sandbox_path: PathBuf::from("workspace/out.txt"), + source_file_ref_id: None, + }, + ) + .await + .unwrap(); + + let result = handle + .save_sandbox_file_version_as("session-1", &version.version_id, &target) + .await + .unwrap(); + + assert_eq!(std::fs::read_to_string(&target).unwrap(), "copy"); + assert_eq!(result.version_id, version.version_id); + assert_eq!(result.source_file_ref_id, target.to_string_lossy()); + assert_eq!(result.backup_id, None); + assert_eq!(result.old_hash, None); + assert_eq!(result.bytes_written, 4); + assert_eq!(result.permission_mode, SandboxPermissionMode::Copy); + + handle.shutdown().await.unwrap(); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn daemon_handle_executes_registered_runtime_inside_sandbox() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let sandbox_root = temp.path().join("sandbox"); + std::fs::create_dir_all(&sandbox_root).unwrap(); + + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + handle + .create_sandbox_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root: sandbox_root, + network: NetworkPolicy::Enabled, + }) + .await + .unwrap(); + handle + .register_sandbox_runtime( + "session-1", + RuntimeProviderConfig { + name: "echo".to_string(), + executable: PathBuf::from("/bin/echo"), + readonly_roots: vec![PathBuf::from("/bin")], + env: HashMap::new(), + default_timeout: Duration::from_secs(5), + }, + ) + .await + .unwrap(); + + let result = handle + .execute_sandbox_runtime( + "session-1", + RuntimeExecuteRequest { + runtime: "echo".to_string(), + args: vec!["hello".to_string()], + cwd: None, + env: HashMap::new(), + timeout: None, + }, + ) + .await + .unwrap(); + + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout, "hello\n"); + assert_eq!(result.stderr, ""); + assert!(!result.timed_out); + + handle.shutdown().await.unwrap(); +} diff --git a/crates/ahandd/tests/sandbox_smoke.rs b/crates/ahandd/tests/sandbox_smoke.rs new file mode 100644 index 0000000..1516580 --- /dev/null +++ b/crates/ahandd/tests/sandbox_smoke.rs @@ -0,0 +1,204 @@ +#![cfg(target_os = "macos")] + +use std::collections::HashMap; +#[cfg(target_os = "macos")] +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use ahandd::{ + DaemonConfig, + sandbox::{ + HostFileRef, NetworkPolicy, RegisterVersionRequest, RuntimeProviderConfig, + SandboxExecRequest, SandboxPermissionMode, SandboxSessionConfig, + }, +}; + +fn command_stdout(program: &str, args: &[&str]) -> String { + let output = Command::new(program).args(args).output().unwrap(); + assert!( + output.status.success(), + "{program} {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +fn python_executable() -> PathBuf { + PathBuf::from(command_stdout( + "python3", + &["-c", "import sys; print(sys.executable)"], + )) +} + +fn node_executable() -> PathBuf { + PathBuf::from(command_stdout("node", &["-p", "process.execPath"])) +} + +fn runtime_root(executable: &Path) -> PathBuf { + let canonical = executable + .canonicalize() + .unwrap_or_else(|_| executable.to_path_buf()); + if canonical.starts_with("/opt/homebrew") { + return PathBuf::from("/opt/homebrew"); + } + canonical + .parent() + .and_then(Path::parent) + .unwrap_or_else(|| Path::new("/usr")) + .to_path_buf() +} + +#[cfg(target_os = "macos")] +fn executable_shim(bin: &Path, command_name: &str, target: &Path) -> PathBuf { + std::fs::create_dir_all(bin).unwrap(); + let shim = bin.join(command_name); + symlink(target, &shim).unwrap(); + shim +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn coffice_sandbox_smoke_import_run_register_and_user_commit() { + let temp = tempfile::tempdir().unwrap(); + let identity_dir = temp.path().join("identity"); + let sandbox_root = temp.path().join("sandbox"); + let python_runtime_bin = temp.path().join("runtime").join("python").join("bin"); + let source = temp.path().join("source.txt"); + std::fs::create_dir_all(sandbox_root.join("workspace")).unwrap(); + std::fs::write(&source, "original").unwrap(); + + let cfg = DaemonConfig::builder("ws://127.0.0.1:9/ws", "test-token", &identity_dir) + .heartbeat_interval(Duration::from_millis(50)) + .build(); + let handle = ahandd::spawn(cfg).await.unwrap(); + handle + .create_sandbox_session(SandboxSessionConfig { + session_id: "session-1".to_string(), + permission_mode: SandboxPermissionMode::Readonly, + workspace_root: sandbox_root.clone(), + network: NetworkPolicy::Enabled, + }) + .await + .unwrap(); + + let system_python = python_executable(); + let python = executable_shim(&python_runtime_bin, "python", &system_python); + let node = node_executable(); + let mut python_env = HashMap::new(); + python_env.insert("PYTHONNOUSERSITE".to_string(), "1".to_string()); + handle + .register_sandbox_runtime( + "session-1", + RuntimeProviderConfig { + name: "python".to_string(), + executable: python.clone(), + readonly_roots: vec![runtime_root(&python), runtime_root(&system_python)], + env: python_env, + default_timeout: Duration::from_secs(10), + }, + ) + .await + .unwrap(); + handle + .register_sandbox_runtime( + "session-1", + RuntimeProviderConfig { + name: "node".to_string(), + executable: node.clone(), + readonly_roots: vec![runtime_root(&node)], + env: HashMap::new(), + default_timeout: Duration::from_secs(10), + }, + ) + .await + .unwrap(); + + let imported = handle + .import_sandbox_file( + "session-1", + HostFileRef { + file_ref_id: "file-ref-1".to_string(), + source_path: source.clone(), + display_name: "source.txt".to_string(), + size: 8, + mtime_ms: None, + conversation_id: Some("conversation-1".to_string()), + }, + ) + .await + .unwrap(); + + let read_imported = handle + .execute_sandbox_command( + "session-1", + SandboxExecRequest { + command: vec![ + "python".to_string(), + "-c".to_string(), + format!( + "from pathlib import Path; print(Path({:?}).read_text())", + imported.sandbox_path.to_string_lossy() + ), + ], + cwd: None, + env: HashMap::new(), + timeout: Some(Duration::from_secs(10)), + }, + ) + .await + .unwrap(); + assert_eq!(read_imported.exit_code, Some(0), "{}", read_imported.stderr); + assert_eq!(read_imported.stdout.trim(), "original"); + + let write_output = handle + .execute_sandbox_command( + "session-1", + SandboxExecRequest { + command: vec![ + "node".to_string(), + "-e".to_string(), + "require('fs').writeFileSync('workspace/out.txt', 'changed')".to_string(), + ], + cwd: None, + env: HashMap::new(), + timeout: Some(Duration::from_secs(10)), + }, + ) + .await + .unwrap(); + assert_eq!(write_output.exit_code, Some(0), "{}", write_output.stderr); + + let version = handle + .register_sandbox_file_version( + "session-1", + RegisterVersionRequest { + sandbox_path: PathBuf::from("workspace/out.txt"), + source_file_ref_id: Some("file-ref-1".to_string()), + }, + ) + .await + .unwrap(); + assert_eq!( + version.status, + ahandd::sandbox::FileVersionStatus::Candidate + ); + assert_eq!(std::fs::read_to_string(&source).unwrap(), "original"); + + let agent_commit = handle + .commit_sandbox_file_version("session-1", &version.version_id) + .await + .unwrap_err(); + assert_eq!(agent_commit.code, "PERMISSION_DENIED"); + assert_eq!(std::fs::read_to_string(&source).unwrap(), "original"); + + let user_commit = handle + .confirm_sandbox_file_version_overwrite("session-1", &version.version_id) + .await + .unwrap(); + assert_eq!(user_commit.bytes_written, 7); + assert_eq!(std::fs::read_to_string(&source).unwrap(), "changed"); + + handle.shutdown().await.unwrap(); +} diff --git a/docs/superpowers/plans/2026-06-21-qisi-ahand-cn-deployment.md b/docs/superpowers/plans/2026-06-21-qisi-ahand-cn-deployment.md new file mode 100644 index 0000000..4742590 --- /dev/null +++ b/docs/superpowers/plans/2026-06-21-qisi-ahand-cn-deployment.md @@ -0,0 +1,1106 @@ +# Qisi aHand CN Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a qisi/Aliyun deployment path so hub-related CI updates deploy to the CN aHand stack in addition to the existing global AWS/t9 deployment. + +**Architecture:** Keep the current AWS/t9 deployment untouched. Add an independent `deploy/qisi/` Compose deployment and a branch-based GitHub Actions workflow that builds the existing `deploy/hub/Dockerfile` targets, pushes to the qisi registry, copies image tags to qisi/qisi-dev, and runs a rollback-capable host deploy script. + +**Tech Stack:** Docker, Docker Compose, Caddy, GitHub Actions, qisi zot registry, SSH/SCP, Bash, existing Rust `ahand-hub`, existing Next.js `ahand-hub-dashboard`. + +--- + +## File Structure + +- Create `deploy/qisi/compose.yml`: shared Compose topology for one CN environment. +- Create `deploy/qisi/env/dev.env.example`: non-secret qisi-dev dev settings. +- Create `deploy/qisi/env/staging.env.example`: non-secret qisi-dev staging settings. +- Create `deploy/qisi/env/production.env.example`: non-secret qisi production settings. +- Create `deploy/qisi/env/secrets.env.example`: required secret keys plus commented optional OSS/S3 keys. +- Create `deploy/qisi/caddy/dev.Caddyfile`: dev public routes to loopback ports. +- Create `deploy/qisi/caddy/staging.Caddyfile`: staging public routes to loopback ports. +- Create `deploy/qisi/caddy/production.Caddyfile`: production public routes to loopback ports. +- Create `deploy/qisi/scripts/deploy.sh`: host-side locked deploy, image promotion, healthcheck, rollback. +- Create `deploy/qisi/scripts/healthcheck.sh`: host-side hub and dashboard health checks. +- Create `.github/workflows/qisi-deploy.yml`: CI image build, registry push, SSH sync, host deploy. + +The existing `deploy/hub/Dockerfile` is reused. Do not create qisi-specific Dockerfiles in this implementation. + +--- + +### Task 1: Add Qisi Runtime Files + +**Files:** +- Create: `deploy/qisi/compose.yml` +- Create: `deploy/qisi/env/dev.env.example` +- Create: `deploy/qisi/env/staging.env.example` +- Create: `deploy/qisi/env/production.env.example` +- Create: `deploy/qisi/env/secrets.env.example` +- Create: `deploy/qisi/caddy/dev.Caddyfile` +- Create: `deploy/qisi/caddy/staging.Caddyfile` +- Create: `deploy/qisi/caddy/production.Caddyfile` +- Create: `deploy/qisi/scripts/deploy.sh` +- Create: `deploy/qisi/scripts/healthcheck.sh` + +- [ ] **Step 1: Create directories** + +Run: + +```bash +mkdir -p deploy/qisi/env deploy/qisi/caddy deploy/qisi/scripts +``` + +Expected: directories exist and `git status --short deploy/qisi` shows untracked `deploy/qisi/`. + +- [ ] **Step 2: Create Compose file** + +Create `deploy/qisi/compose.yml` with this content: + +```yaml +name: ahand-hub-${DEPLOY_ENV} + +services: + hub: + image: ${AHAND_HUB_IMAGE} + container_name: ahand-hub-${DEPLOY_ENV}-hub + restart: unless-stopped + env_file: + - .env + - .env.secrets + - .env.images + environment: + AHAND_HUB_BIND_ADDR: ${AHAND_HUB_BIND_ADDR:-0.0.0.0:1515} + AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS: ${AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS} + AHAND_HUB_LOG_FORMAT: ${AHAND_HUB_LOG_FORMAT:-json} + AHAND_HUB_LOG_LEVEL: ${AHAND_HUB_LOG_LEVEL:-info} + AHAND_HUB_AUDIT_RETENTION_DAYS: ${AHAND_HUB_AUDIT_RETENTION_DAYS:-90} + AHAND_HUB_AUDIT_FALLBACK_PATH: ${AHAND_HUB_AUDIT_FALLBACK_PATH:-/var/lib/ahand-hub/audit-fallback.jsonl} + AHAND_HUB_WEBHOOK_MAX_RETRIES: ${AHAND_HUB_WEBHOOK_MAX_RETRIES:-8} + AHAND_HUB_WEBHOOK_TIMEOUT_MS: ${AHAND_HUB_WEBHOOK_TIMEOUT_MS:-5000} + GIT_SHA: ${GIT_SHA:-unknown} + SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} + SENTRY_RELEASE: ${SENTRY_RELEASE:-unknown} + ports: + - "127.0.0.1:${AHAND_HUB_HOST_PORT}:1515" + volumes: + - hub-audit-data:/var/lib/ahand-hub + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:1515/api/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + dashboard: + image: ${AHAND_HUB_DASHBOARD_IMAGE} + container_name: ahand-hub-${DEPLOY_ENV}-dashboard + restart: unless-stopped + depends_on: + hub: + condition: service_healthy + env_file: + - .env + - .env.images + environment: + AHAND_HUB_BASE_URL: http://hub:1515 + NODE_ENV: production + PORT: "1516" + SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} + SENTRY_RELEASE: ${SENTRY_RELEASE:-unknown} + ports: + - "127.0.0.1:${AHAND_HUB_DASHBOARD_HOST_PORT}:1516" + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:1516/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + +volumes: + hub-audit-data: +``` + +- [ ] **Step 3: Create dev env example** + +Create `deploy/qisi/env/dev.env.example` with this content: + +```dotenv +DEPLOY_ENV=dev +APP_ENV=development +NODE_ENV=production +SENTRY_ENVIRONMENT=dev + +PUBLIC_HUB_DOMAIN=ahand-hub.dev.coffice.qisiai.top +PUBLIC_DASHBOARD_DOMAIN=admin.ahand.dev.coffice.qisiai.top +AHAND_HUB_PUBLIC_URL=https://ahand-hub.dev.coffice.qisiai.top +AHAND_HUB_DASHBOARD_PUBLIC_URL=https://admin.ahand.dev.coffice.qisiai.top + +AHAND_HUB_HOST_PORT=5815 +AHAND_HUB_DASHBOARD_HOST_PORT=5816 + +AHAND_HUB_BIND_ADDR=0.0.0.0:1515 +AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=https://admin.ahand.dev.coffice.qisiai.top +AHAND_HUB_LOG_FORMAT=json +AHAND_HUB_LOG_LEVEL=info +AHAND_HUB_AUDIT_RETENTION_DAYS=90 +AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl +AHAND_HUB_WEBHOOK_MAX_RETRIES=8 +AHAND_HUB_WEBHOOK_TIMEOUT_MS=5000 +``` + +- [ ] **Step 4: Create staging env example** + +Create `deploy/qisi/env/staging.env.example` with this content: + +```dotenv +DEPLOY_ENV=staging +APP_ENV=staging +NODE_ENV=production +SENTRY_ENVIRONMENT=staging + +PUBLIC_HUB_DOMAIN=ahand-hub.staging.coffice.qisiai.top +PUBLIC_DASHBOARD_DOMAIN=admin.ahand.staging.coffice.qisiai.top +AHAND_HUB_PUBLIC_URL=https://ahand-hub.staging.coffice.qisiai.top +AHAND_HUB_DASHBOARD_PUBLIC_URL=https://admin.ahand.staging.coffice.qisiai.top + +AHAND_HUB_HOST_PORT=4815 +AHAND_HUB_DASHBOARD_HOST_PORT=4816 + +AHAND_HUB_BIND_ADDR=0.0.0.0:1515 +AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=https://admin.ahand.staging.coffice.qisiai.top +AHAND_HUB_LOG_FORMAT=json +AHAND_HUB_LOG_LEVEL=info +AHAND_HUB_AUDIT_RETENTION_DAYS=90 +AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl +AHAND_HUB_WEBHOOK_MAX_RETRIES=8 +AHAND_HUB_WEBHOOK_TIMEOUT_MS=5000 +``` + +- [ ] **Step 5: Create production env example** + +Create `deploy/qisi/env/production.env.example` with this content: + +```dotenv +DEPLOY_ENV=production +APP_ENV=production +NODE_ENV=production +SENTRY_ENVIRONMENT=production + +PUBLIC_HUB_DOMAIN=ahand-hub.coffice.qisiai.top +PUBLIC_DASHBOARD_DOMAIN=admin.ahand.coffice.qisiai.top +AHAND_HUB_PUBLIC_URL=https://ahand-hub.coffice.qisiai.top +AHAND_HUB_DASHBOARD_PUBLIC_URL=https://admin.ahand.coffice.qisiai.top + +AHAND_HUB_HOST_PORT=3815 +AHAND_HUB_DASHBOARD_HOST_PORT=3816 + +AHAND_HUB_BIND_ADDR=0.0.0.0:1515 +AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=https://admin.ahand.coffice.qisiai.top +AHAND_HUB_LOG_FORMAT=json +AHAND_HUB_LOG_LEVEL=info +AHAND_HUB_AUDIT_RETENTION_DAYS=90 +AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl +AHAND_HUB_WEBHOOK_MAX_RETRIES=8 +AHAND_HUB_WEBHOOK_TIMEOUT_MS=5000 +``` + +- [ ] **Step 6: Create secrets env example** + +Create `deploy/qisi/env/secrets.env.example` with this content: + +```dotenv +AHAND_HUB_SERVICE_TOKEN= +AHAND_HUB_DASHBOARD_PASSWORD= +AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN= +AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID= +AHAND_HUB_JWT_SECRET= +AHAND_HUB_DATABASE_URL= +AHAND_HUB_REDIS_URL= + +# Optional webhook integration. If AHAND_HUB_WEBHOOK_URL is set, +# AHAND_HUB_WEBHOOK_SECRET must also be set. +AHAND_HUB_WEBHOOK_URL= +AHAND_HUB_WEBHOOK_SECRET= + +# Optional Sentry DSN for the hub service. +SENTRY_DSN= + +# Optional Aliyun OSS through the S3-compatible hub path. Leave these commented +# until OSS is ready. An empty AHAND_HUB_S3_BUCKET value still enables S3 config. +# AHAND_HUB_S3_BUCKET= +# AHAND_HUB_S3_REGION=cn-shanghai +# AHAND_HUB_S3_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com +# AHAND_HUB_S3_THRESHOLD_BYTES=1048576 +# AHAND_HUB_S3_URL_EXPIRATION_SECS=3600 +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +``` + +- [ ] **Step 7: Create Caddy snippets** + +Create `deploy/qisi/caddy/dev.Caddyfile` with this content: + +```caddyfile +ahand-hub.dev.coffice.qisiai.top { + reverse_proxy 127.0.0.1:5815 +} + +admin.ahand.dev.coffice.qisiai.top { + reverse_proxy 127.0.0.1:5816 +} +``` + +Create `deploy/qisi/caddy/staging.Caddyfile` with this content: + +```caddyfile +ahand-hub.staging.coffice.qisiai.top { + reverse_proxy 127.0.0.1:4815 +} + +admin.ahand.staging.coffice.qisiai.top { + reverse_proxy 127.0.0.1:4816 +} +``` + +Create `deploy/qisi/caddy/production.Caddyfile` with this content: + +```caddyfile +ahand-hub.coffice.qisiai.top { + reverse_proxy 127.0.0.1:3815 +} + +admin.ahand.coffice.qisiai.top { + reverse_proxy 127.0.0.1:3816 +} +``` + +- [ ] **Step 8: Create healthcheck script** + +Create `deploy/qisi/scripts/healthcheck.sh` with this content: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ ! -f .env ]]; then + echo "missing required file: .env" >&2 + exit 1 +fi + +set -a +source .env +set +a + +: "${AHAND_HUB_HOST_PORT:?AHAND_HUB_HOST_PORT is required in .env}" +: "${AHAND_HUB_DASHBOARD_HOST_PORT:?AHAND_HUB_DASHBOARD_HOST_PORT is required in .env}" + +check_url() { + local name="$1" + local url="$2" + local deadline=$((SECONDS + 120)) + + while (( SECONDS < deadline )); do + if curl -fsS "$url" >/dev/null; then + echo "$name healthy: $url" + return 0 + fi + sleep 3 + done + + echo "$name did not become healthy: $url" >&2 + return 1 +} + +check_url "ahand-hub" "http://127.0.0.1:${AHAND_HUB_HOST_PORT}/api/health" +check_url "ahand-hub-dashboard" "http://127.0.0.1:${AHAND_HUB_DASHBOARD_HOST_PORT}/login" +``` + +- [ ] **Step 9: Create deploy script** + +Create `deploy/qisi/scripts/deploy.sh` with this content: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +NEXT_IMAGES="${1:-.env.images.next}" +ACTIVE_IMAGES=".env.images" +COMPOSE_ARGS=(--env-file .env --env-file .env.secrets --env-file .env.images -f compose.yml) +CANDIDATE_COMPOSE="" +LOCK_DIR="" +PROMOTED=0 +HAD_PREVIOUS=0 +BACKUP_PATH="" + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "missing required file: $path" >&2 + exit 1 + fi +} + +absolute_path() { + local path="$1" + local dir + dir="$(cd "$(dirname "$path")" && pwd)" + echo "$dir/$(basename "$path")" +} + +cleanup() { + if [[ -n "$CANDIDATE_COMPOSE" && -f "$CANDIDATE_COMPOSE" ]]; then + rm -f "$CANDIDATE_COMPOSE" + fi + if [[ -n "$LOCK_DIR" && -d "$LOCK_DIR" ]]; then + rmdir "$LOCK_DIR" 2>/dev/null || true + fi +} + +rollback_on_failure() { + local status=$? + trap - ERR + + if (( PROMOTED )); then + echo "deploy failed after promoting $ACTIVE_IMAGES" >&2 + if (( HAD_PREVIOUS )); then + echo "restoring previous $ACTIVE_IMAGES from $BACKUP_PATH" >&2 + cp "$BACKUP_PATH" "$ACTIVE_IMAGES" + if ! docker compose "${COMPOSE_ARGS[@]}" up -d --remove-orphans; then + echo "rollback compose up failed; inspect the host before retrying" >&2 + fi + else + echo "no previous $ACTIVE_IMAGES existed; promoted file left in place for inspection" >&2 + fi + fi + + exit "$status" +} + +acquire_lock() { + mkdir -p .deploy-locks + + if command -v flock >/dev/null 2>&1; then + exec 9>".deploy-locks/${DEPLOY_ENV}.lock" + if ! flock -n 9; then + echo "another deploy is already running for $DEPLOY_ENV" >&2 + exit 1 + fi + return + fi + + LOCK_DIR=".deploy-locks/${DEPLOY_ENV}.lockdir" + if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "another deploy is already running for $DEPLOY_ENV" >&2 + exit 1 + fi +} + +write_candidate_compose() { + local env_path + local secrets_path + local images_path + + env_path="$(absolute_path .env)" + secrets_path="$(absolute_path .env.secrets)" + images_path="$(absolute_path "$NEXT_IMAGES")" + CANDIDATE_COMPOSE="$(mktemp "$ROOT_DIR/.compose.candidate.XXXXXX.yml")" + + awk \ + -v env_path="$env_path" \ + -v secrets_path="$secrets_path" \ + -v images_path="$images_path" \ + '{ + if ($0 ~ /^[[:space:]]+- \.env$/) { + sub(/\.env$/, env_path) + } else if ($0 ~ /^[[:space:]]+- \.env\.secrets$/) { + sub(/\.env\.secrets$/, secrets_path) + } else if ($0 ~ /^[[:space:]]+- \.env\.images$/) { + sub(/\.env\.images$/, images_path) + } + print + }' compose.yml >"$CANDIDATE_COMPOSE" +} + +require_file compose.yml +require_file .env +require_file .env.secrets +require_file "$NEXT_IMAGES" + +set -a +source .env +set +a + +: "${DEPLOY_ENV:?DEPLOY_ENV is required in .env}" + +trap cleanup EXIT +acquire_lock +write_candidate_compose + +CANDIDATE_COMPOSE_ARGS=(--env-file .env --env-file .env.secrets --env-file "$NEXT_IMAGES" -f "$CANDIDATE_COMPOSE") + +docker compose "${CANDIDATE_COMPOSE_ARGS[@]}" config >/dev/null +if [[ "${SKIP_PULL:-0}" == "1" ]]; then + echo "SKIP_PULL=1: using images already present on this Docker host" +else + docker compose "${CANDIDATE_COMPOSE_ARGS[@]}" pull +fi + +mkdir -p .deploy-history +if [[ -f "$ACTIVE_IMAGES" ]]; then + HAD_PREVIOUS=1 + BACKUP_PATH=".deploy-history/$(date -u +%Y%m%dT%H%M%SZ)-$$.env.images" + cp "$ACTIVE_IMAGES" "$BACKUP_PATH" +fi + +cp "$NEXT_IMAGES" "$ACTIVE_IMAGES" +PROMOTED=1 +trap rollback_on_failure ERR + +docker compose "${COMPOSE_ARGS[@]}" up -d --remove-orphans + +bash scripts/healthcheck.sh +``` + +- [ ] **Step 10: Make scripts executable** + +Run: + +```bash +chmod +x deploy/qisi/scripts/deploy.sh deploy/qisi/scripts/healthcheck.sh +``` + +Expected: `ls -l deploy/qisi/scripts/*.sh` shows executable bits. + +- [ ] **Step 11: Validate shell scripts** + +Run: + +```bash +bash -n deploy/qisi/scripts/deploy.sh +bash -n deploy/qisi/scripts/healthcheck.sh +``` + +Expected: both commands exit 0 with no output. + +- [ ] **Step 12: Validate Compose config with temp env files** + +Run: + +```bash +tmpdir="$(mktemp -d)" +cp deploy/qisi/compose.yml "$tmpdir/compose.yml" +cp deploy/qisi/env/dev.env.example "$tmpdir/.env" +cp deploy/qisi/env/secrets.env.example "$tmpdir/.env.secrets" +cat > "$tmpdir/.env.images" <<'EOF' +AHAND_HUB_IMAGE=registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub:dev-test +AHAND_HUB_DASHBOARD_IMAGE=registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub-dashboard:dev-test +GIT_SHA=test +SENTRY_RELEASE=test +EOF +docker compose \ + --env-file "$tmpdir/.env" \ + --env-file "$tmpdir/.env.secrets" \ + --env-file "$tmpdir/.env.images" \ + -f "$tmpdir/compose.yml" \ + config >/dev/null +rm -rf "$tmpdir" +``` + +Expected: `docker compose config` exits 0. It may warn about unset optional blank values only if the env example is edited incorrectly; the checked-in examples should avoid warnings. + +- [ ] **Step 13: Validate Caddy snippets if Caddy is installed** + +Run: + +```bash +if command -v caddy >/dev/null 2>&1; then + caddy adapt --config deploy/qisi/caddy/dev.Caddyfile >/dev/null + caddy adapt --config deploy/qisi/caddy/staging.Caddyfile >/dev/null + caddy adapt --config deploy/qisi/caddy/production.Caddyfile >/dev/null +else + echo "caddy not installed locally; snippets will be validated on qisi/qisi-dev" +fi +``` + +Expected: exit 0. If Caddy is absent, the command prints the skip line and exits 0. + +- [ ] **Step 14: Commit runtime files** + +Run: + +```bash +git add deploy/qisi/compose.yml \ + deploy/qisi/env/dev.env.example \ + deploy/qisi/env/staging.env.example \ + deploy/qisi/env/production.env.example \ + deploy/qisi/env/secrets.env.example \ + deploy/qisi/caddy/dev.Caddyfile \ + deploy/qisi/caddy/staging.Caddyfile \ + deploy/qisi/caddy/production.Caddyfile \ + deploy/qisi/scripts/deploy.sh \ + deploy/qisi/scripts/healthcheck.sh +git commit -m "deploy: add qisi ahand compose runtime" +``` + +Expected: commit succeeds and includes only `deploy/qisi/**`. + +--- + +### Task 2: Add Qisi Deploy Workflow + +**Files:** +- Create: `.github/workflows/qisi-deploy.yml` + +- [ ] **Step 1: Create workflow file** + +Create `.github/workflows/qisi-deploy.yml` with this content: + +```yaml +name: Qisi aHand Deploy + +on: + push: + branches: [dev, staging, main] + paths: + - "apps/hub-dashboard/**" + - "crates/ahand-hub/**" + - "crates/ahand-hub-core/**" + - "crates/ahand-hub-store/**" + - "crates/ahand-protocol/**" + - "proto/**" + - "Cargo.lock" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "deploy/hub/Dockerfile" + - "deploy/qisi/**" + - ".github/workflows/qisi-deploy.yml" + workflow_dispatch: + +concurrency: + group: qisi-ahand-deploy-${{ github.ref_name }} + cancel-in-progress: false + +env: + REGISTRY: registry.image.coffice.qisiai.top + IMAGE_NAMESPACE: coffice/ahand + +jobs: + resolve-target: + runs-on: ubuntu-latest + outputs: + env_name: ${{ steps.target.outputs.env_name }} + deploy_dir: ${{ steps.target.outputs.deploy_dir }} + steps: + - id: target + shell: bash + run: | + case "${GITHUB_REF_NAME}" in + dev) + echo "env_name=dev" >> "$GITHUB_OUTPUT" + echo "deploy_dir=/opt/ahand-hub/dev" >> "$GITHUB_OUTPUT" + ;; + staging) + echo "env_name=staging" >> "$GITHUB_OUTPUT" + echo "deploy_dir=/opt/ahand-hub/staging" >> "$GITHUB_OUTPUT" + ;; + main) + echo "env_name=production" >> "$GITHUB_OUTPUT" + echo "deploy_dir=/opt/ahand-hub/production" >> "$GITHUB_OUTPUT" + ;; + *) + echo "unsupported branch: ${GITHUB_REF_NAME}" >&2 + exit 1 + ;; + esac + + build-images: + needs: resolve-target + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - image: ahand-hub + target: hub + - image: ahand-hub-dashboard + target: dashboard + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.QISI_REGISTRY_USERNAME }} + password: ${{ secrets.QISI_REGISTRY_PASSWORD }} + + - uses: docker/build-push-action@v7 + with: + context: . + file: deploy/hub/Dockerfile + target: ${{ matrix.target }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}:${{ needs.resolve-target.outputs.env_name }}-${{ github.sha }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/${{ matrix.image }}:${{ needs.resolve-target.outputs.env_name }} + cache-from: type=gha,scope=qisi-ahand-${{ matrix.image }} + cache-to: type=gha,scope=qisi-ahand-${{ matrix.image }},mode=max + + deploy: + needs: [resolve-target, build-images] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Resolve SSH host + shell: bash + run: | + case "${GITHUB_REF_NAME}" in + dev|staging) + echo "SSH_HOST=${{ secrets.QISI_DEV_SSH_HOST }}" >> "$GITHUB_ENV" + ;; + main) + echo "SSH_HOST=${{ secrets.QISI_SSH_HOST }}" >> "$GITHUB_ENV" + ;; + *) + echo "unsupported branch: ${GITHUB_REF_NAME}" >&2 + exit 1 + ;; + esac + + - name: Configure SSH + shell: bash + run: | + install -m 700 -d ~/.ssh + printf '%s\n' "${{ secrets.QISI_SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + printf '%s\n' "${{ secrets.QISI_KNOWN_HOSTS }}" > ~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Write image env + shell: bash + run: | + cat > .env.images.next </dev/null 2>&1; then + actionlint .github/workflows/qisi-deploy.yml +else + echo "actionlint not installed; YAML parser check is the local baseline" +fi +``` + +Expected: exit 0. If actionlint is absent, the command prints the skip line and exits 0. + +- [ ] **Step 4: Verify path filters include global hub deploy paths plus qisi files** + +Run: + +```bash +rg -n '"crates/ahand-hub/\*\*"|"crates/ahand-hub-core/\*\*"|"crates/ahand-hub-store/\*\*"|"crates/ahand-protocol/\*\*"|"proto/\*\*"|"deploy/hub/Dockerfile"|"deploy/qisi/\*\*"' .github/workflows/qisi-deploy.yml +``` + +Expected: output includes every listed path. This confirms qisi deploys are automatically triggered for the same hub-related update surface as the global deploy path, plus qisi deployment asset changes. + +- [ ] **Step 5: Commit workflow** + +Run: + +```bash +git add .github/workflows/qisi-deploy.yml +git commit -m "ci: deploy ahand hub to qisi" +``` + +Expected: commit succeeds and includes only `.github/workflows/qisi-deploy.yml`. + +--- + +### Task 3: Run Local Build And Config Verification + +**Files:** +- Verify: `deploy/qisi/compose.yml` +- Verify: `deploy/hub/Dockerfile` +- Verify: `.github/workflows/qisi-deploy.yml` + +- [ ] **Step 1: Validate qisi Compose config against dev env** + +Run: + +```bash +tmpdir="$(mktemp -d)" +cp deploy/qisi/compose.yml "$tmpdir/compose.yml" +cp deploy/qisi/env/dev.env.example "$tmpdir/.env" +cp deploy/qisi/env/secrets.env.example "$tmpdir/.env.secrets" +cat > "$tmpdir/.env.images" <<'EOF' +AHAND_HUB_IMAGE=registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub:dev-local +AHAND_HUB_DASHBOARD_IMAGE=registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub-dashboard:dev-local +GIT_SHA=local +SENTRY_RELEASE=local +EOF +docker compose \ + --env-file "$tmpdir/.env" \ + --env-file "$tmpdir/.env.secrets" \ + --env-file "$tmpdir/.env.images" \ + -f "$tmpdir/compose.yml" \ + config >/tmp/ahand-qisi-compose.rendered.yml +rm -rf "$tmpdir" +``` + +Expected: command exits 0 and `/tmp/ahand-qisi-compose.rendered.yml` exists. + +- [ ] **Step 2: Confirm dashboard rendered config does not receive `.env.secrets`** + +Run: + +```bash +ruby -ryaml -e ' + cfg = YAML.load_file("/tmp/ahand-qisi-compose.rendered.yml") + dashboard = cfg.fetch("services").fetch("dashboard") + entries = Array(dashboard.fetch("env_file", [])).map { |entry| + entry.is_a?(Hash) ? entry.fetch("path") : entry.to_s + } + if entries.any? { |entry| entry.include?(".env.secrets") } + abort "dashboard env_file includes .env.secrets" + end + puts "dashboard env_file excludes .env.secrets" +' +``` + +Expected: prints `dashboard env_file excludes .env.secrets` and exits 0. + +- [ ] **Step 3: Build the hub image target** + +Run: + +```bash +docker build --target hub -f deploy/hub/Dockerfile -t ahand-hub:qisi-smoke . +``` + +Expected: image builds successfully. This can take several minutes. + +- [ ] **Step 4: Build the dashboard image target** + +Run: + +```bash +docker build --target dashboard -f deploy/hub/Dockerfile -t ahand-hub-dashboard:qisi-smoke . +``` + +Expected: image builds successfully. This can take several minutes. + +- [ ] **Step 5: Re-run existing hub CI checks that are cheap locally** + +Run: + +```bash +cargo fmt -p ahand-protocol -p ahand-hub-core -p ahand-hub-store -p ahand-hub --check +pnpm --filter @ahand/hub-dashboard lint +``` + +Expected: both commands exit 0. If dependencies are missing, run `pnpm install --frozen-lockfile` first and retry the dashboard lint. + +- [ ] **Step 6: Commit verification note if no code changed** + +Do not create a commit in this step. Record the command outputs in the final implementation summary. If any verification command required a source change, create a separate focused commit for that fix after re-running the failing command. + +--- + +### Task 4: Bootstrap Hosts And Validate Remote Prerequisites + +**Files:** +- Read: `deploy/qisi/env/dev.env.example` +- Read: `deploy/qisi/env/staging.env.example` +- Read: `deploy/qisi/env/production.env.example` +- Read: `deploy/qisi/env/secrets.env.example` +- Read: `deploy/qisi/caddy/dev.Caddyfile` +- Read: `deploy/qisi/caddy/staging.Caddyfile` +- Read: `deploy/qisi/caddy/production.Caddyfile` + +- [ ] **Step 1: Verify qisi-dev prerequisites** + +Run: + +```bash +ssh qisi-dev 'docker --version && docker compose version && caddy version' +``` + +Expected: command exits 0 and prints Docker, Docker Compose, and Caddy versions. + +- [ ] **Step 2: Verify qisi prerequisites** + +Run: + +```bash +ssh qisi 'docker --version && docker compose version && caddy version' +``` + +Expected: command exits 0 and prints Docker, Docker Compose, and Caddy versions. + +- [ ] **Step 3: Create qisi-dev directories** + +Run: + +```bash +ssh qisi-dev 'mkdir -p /opt/ahand-hub/dev/scripts /opt/ahand-hub/staging/scripts' +``` + +Expected: command exits 0. + +- [ ] **Step 4: Create qisi production directory** + +Run: + +```bash +ssh qisi 'mkdir -p /opt/ahand-hub/production/scripts' +``` + +Expected: command exits 0. + +- [ ] **Step 5: Install non-secret env files if missing** + +Run: + +```bash +scp deploy/qisi/env/dev.env.example qisi-dev:/opt/ahand-hub/dev/.env +scp deploy/qisi/env/staging.env.example qisi-dev:/opt/ahand-hub/staging/.env +scp deploy/qisi/env/production.env.example qisi:/opt/ahand-hub/production/.env +``` + +Expected: commands exit 0. If a host already has `.env`, compare first with the exact file, for example `ssh qisi-dev 'cat /opt/ahand-hub/dev/.env'`, and preserve any operator changes. + +- [ ] **Step 6: Create host-local secrets files** + +Do not copy real secret values into git. On each host, create `.env.secrets` from `deploy/qisi/env/secrets.env.example` and fill the required values: + +```bash +scp deploy/qisi/env/secrets.env.example qisi-dev:/opt/ahand-hub/dev/.env.secrets +scp deploy/qisi/env/secrets.env.example qisi-dev:/opt/ahand-hub/staging/.env.secrets +scp deploy/qisi/env/secrets.env.example qisi:/opt/ahand-hub/production/.env.secrets +``` + +Expected: files exist on the hosts. Before the first deploy, an operator must replace the blank values for `AHAND_HUB_SERVICE_TOKEN`, `AHAND_HUB_DASHBOARD_PASSWORD`, `AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN`, `AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID`, `AHAND_HUB_JWT_SECRET`, `AHAND_HUB_DATABASE_URL`, and `AHAND_HUB_REDIS_URL` with real environment-specific values. + +- [ ] **Step 7: Validate Caddy snippets on qisi-dev** + +Run: + +```bash +scp deploy/qisi/caddy/dev.Caddyfile qisi-dev:/tmp/ahand-dev.Caddyfile +scp deploy/qisi/caddy/staging.Caddyfile qisi-dev:/tmp/ahand-staging.Caddyfile +ssh qisi-dev 'caddy adapt --config /tmp/ahand-dev.Caddyfile >/dev/null && caddy adapt --config /tmp/ahand-staging.Caddyfile >/dev/null' +``` + +Expected: command exits 0. Then merge the two snippets into `/etc/caddy/Caddyfile`, run `caddy validate --config /etc/caddy/Caddyfile`, and reload Caddy with the host's existing reload command. + +- [ ] **Step 8: Validate Caddy snippet on qisi** + +Run: + +```bash +scp deploy/qisi/caddy/production.Caddyfile qisi:/tmp/ahand-production.Caddyfile +ssh qisi 'caddy adapt --config /tmp/ahand-production.Caddyfile >/dev/null' +``` + +Expected: command exits 0. Then merge the snippet into `/etc/caddy/Caddyfile`, run `caddy validate --config /etc/caddy/Caddyfile`, and reload Caddy with the host's existing reload command. + +- [ ] **Step 9: Confirm no source commit is required for host bootstrap** + +Run: + +```bash +git status --short +``` + +Expected: no new source changes from host bootstrap. Host-local `.env` and `.env.secrets` files are not committed. + +--- + +### Task 5: First Deploy And Runtime Verification + +**Files:** +- Verify: `.github/workflows/qisi-deploy.yml` +- Verify: `deploy/qisi/scripts/deploy.sh` +- Verify: `deploy/qisi/scripts/healthcheck.sh` + +- [ ] **Step 1: Trigger dev deploy** + +Push a hub-related change to `dev` or manually run `Qisi aHand Deploy` on `dev` from GitHub Actions. + +Expected: workflow runs `resolve-target`, both `build-images` matrix jobs, and `deploy` successfully. The target directory is `/opt/ahand-hub/dev`. + +- [ ] **Step 2: Verify dev containers on qisi-dev** + +Run: + +```bash +ssh qisi-dev 'docker compose --env-file /opt/ahand-hub/dev/.env --env-file /opt/ahand-hub/dev/.env.secrets --env-file /opt/ahand-hub/dev/.env.images -f /opt/ahand-hub/dev/compose.yml ps' +``` + +Expected: `ahand-hub-dev-hub` and `ahand-hub-dev-dashboard` are running and healthy. + +- [ ] **Step 3: Verify dev public URLs** + +Run: + +```bash +curl -fsS https://ahand-hub.dev.coffice.qisiai.top/api/health +curl -fsS https://admin.ahand.dev.coffice.qisiai.top/login >/tmp/ahand-dev-login.html +``` + +Expected: health URL returns JSON and dashboard login HTML is saved. + +- [ ] **Step 4: Trigger staging deploy** + +Push a hub-related change to `staging` or manually run `Qisi aHand Deploy` on `staging` from GitHub Actions. + +Expected: workflow succeeds and target directory is `/opt/ahand-hub/staging`. + +- [ ] **Step 5: Verify staging public URLs** + +Run: + +```bash +curl -fsS https://ahand-hub.staging.coffice.qisiai.top/api/health +curl -fsS https://admin.ahand.staging.coffice.qisiai.top/login >/tmp/ahand-staging-login.html +``` + +Expected: health URL returns JSON and dashboard login HTML is saved. + +- [ ] **Step 6: Trigger production deploy** + +After dev and staging succeed, merge the qisi deployment branch to `main` or manually run `Qisi aHand Deploy` on `main`. + +Expected: workflow succeeds and target directory is `/opt/ahand-hub/production`. + +- [ ] **Step 7: Verify production public URLs** + +Run: + +```bash +curl -fsS https://ahand-hub.coffice.qisiai.top/api/health +curl -fsS https://admin.ahand.coffice.qisiai.top/login >/tmp/ahand-production-login.html +``` + +Expected: health URL returns JSON and dashboard login HTML is saved. + +- [ ] **Step 8: Verify rollback file history exists** + +Run: + +```bash +ssh qisi-dev 'find /opt/ahand-hub/dev/.deploy-history -maxdepth 1 -type f -name "*.env.images" -print | tail -5 || true' +ssh qisi 'find /opt/ahand-hub/production/.deploy-history -maxdepth 1 -type f -name "*.env.images" -print | tail -5 || true' +``` + +Expected: after at least one redeploy per environment, history files exist. On the first deploy for a new environment, no history file is expected because no previous `.env.images` existed. + +--- + +### Task 6: Final Repository Verification + +**Files:** +- Verify: all files changed by Tasks 1 and 2 +- Verify: `docs/superpowers/specs/2026-06-21-qisi-ahand-cn-deployment-design.md` + +- [ ] **Step 1: Check changed file scope** + +Run: + +```bash +git status --short +git log --oneline --max-count=6 +``` + +Expected: only intentional qisi deployment files are modified or committed. Unrelated existing untracked files such as `.vscode/` remain untouched. + +- [ ] **Step 2: Search for forbidden placeholders and accidental secrets** + +Run: + +```bash +rg -n "T[B]D|T[O]DO|F[I]XME|CHANGE[_]ME|password|secret-token|jwt-secret|postgres://[^\\s]*:[^\\s]*@|redis://[^\\s]*:[^\\s]*@" deploy/qisi .github/workflows/qisi-deploy.yml +``` + +Expected: no output. If the command finds `PASSWORD` or `SECRET` in variable names only, inspect the exact output and ensure no real value is committed. + +- [ ] **Step 3: Confirm optional S3 vars are commented in secrets example** + +Run: + +```bash +rg -n "^AHAND_HUB_S3_|^AWS_ACCESS_KEY_ID=|^AWS_SECRET_ACCESS_KEY=" deploy/qisi/env/secrets.env.example +``` + +Expected: no output. The optional OSS/S3 keys must remain commented until enabled on the host. + +- [ ] **Step 4: Confirm global AWS workflow remains unchanged** + +Run: + +```bash +git diff -- .github/workflows/deploy-hub.yml deploy/hub/deploy.sh deploy/hub/task-definition.template.json +``` + +Expected: no output. The qisi deployment is additive and does not alter the global AWS/t9 deployment path. + +- [ ] **Step 5: Final commit if any verification-only fixes were needed** + +If Task 6 required changes, commit only those fixes: + +```bash +git add deploy/qisi .github/workflows/qisi-deploy.yml +git commit -m "fix: harden qisi ahand deployment checks" +``` + +Expected: commit succeeds only when there are real fixes. If there are no changes, do not create an empty commit. diff --git a/docs/superpowers/specs/2026-06-21-qisi-ahand-cn-deployment-design.md b/docs/superpowers/specs/2026-06-21-qisi-ahand-cn-deployment-design.md new file mode 100644 index 0000000..5e0d589 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-qisi-ahand-cn-deployment-design.md @@ -0,0 +1,481 @@ +# Qisi aHand CN Deployment Design + +Date: 2026-06-21 + +## Scope + +Deploy a China-specific aHand hub stack onto Aliyun hosts reachable as `qisi` +and `qisi-dev`, following the current Agent PI qisi deployment pattern. + +This design covers: + +- Docker image publishing to the qisi registry. +- Branch-based GitHub Actions rollout over SSH. +- CI-triggered CN rollout whenever the global hub deployment workflow would + update the AWS/t9 environment for the same branch and hub-related changes. +- Per-environment Docker Compose projects. +- Caddy routing, public domains, and loopback-bound host ports. +- Environment file layout, secret handling, health checks, and rollback. + +This design does not replace or modify the existing AWS/t9 deployment. The +existing `.github/workflows/deploy-hub.yml`, ECS task definition, and AWS +infrastructure remain the global deployment path. The CN workflow is additive: +hub updates should keep deploying to global and should also deploy to qisi. + +It also does not provision Aliyun RDS, Redis, OSS, DNS, or Sentry resources. +Those resources are operator-managed and are injected through host-local +environment files. + +## Reference Pattern + +The implementation should mirror the active Agent PI qisi deployment: + +- Each environment owns a directory under `/opt`. +- Docker Compose reads three env files: + - `.env` for non-secret runtime settings. + - `.env.secrets` for credentials and tokens. + - `.env.images` for the currently deployed immutable image tags. +- CI writes `.env.images.next`, copies it to the host, and invokes a host-local + `scripts/deploy.sh`. +- The host deploy script validates Compose, pulls candidate images, backs up the + active `.env.images`, promotes the candidate, starts containers, runs health + checks, and restores the previous image file on failure. +- Caddy proxies public domains to loopback-bound host ports. + +## Deployment Topology + +Production runs on `qisi`. + +- Git branch: `main` +- Environment name: `production` +- Host: `qisi` +- Remote directory: `/opt/ahand-hub/production` + +Development and staging run on `qisi-dev`. + +- Git branch: `dev` +- Environment name: `dev` +- Host: `qisi-dev` +- Remote directory: `/opt/ahand-hub/dev` + +- Git branch: `staging` +- Environment name: `staging` +- Host: `qisi-dev` +- Remote directory: `/opt/ahand-hub/staging` + +Each environment runs two containers in its own Docker Compose project: + +- `hub`, running the Rust `ahand-hub` service. +- `dashboard`, running the Next.js `ahand-hub-dashboard` service. + +Postgres and Redis are not containerized. Production should use production +Aliyun-managed data stores. Dev and staging should use dev-side Aliyun-managed +data stores, isolated by separate databases and Redis logical DB indexes or +separate instances. + +## Public Domains + +Production: + +- Hub: `ahand-hub.coffice.qisiai.top` +- Dashboard: `admin.ahand.coffice.qisiai.top` + +Staging: + +- Hub: `ahand-hub.staging.coffice.qisiai.top` +- Dashboard: `admin.ahand.staging.coffice.qisiai.top` + +Development: + +- Hub: `ahand-hub.dev.coffice.qisiai.top` +- Dashboard: `admin.ahand.dev.coffice.qisiai.top` + +Public DNS should point production domains to `qisi` and dev/staging domains to +`qisi-dev`. Private DNS may provide split-horizon records inside the Aliyun VPC, +but containers should not depend on public DNS for same-environment calls. + +## Caddy Routing + +Each host owns Caddy routing for the environments it serves. + +Production on `qisi`: + +- `ahand-hub.coffice.qisiai.top` reverse proxies to `127.0.0.1:3815`. +- `admin.ahand.coffice.qisiai.top` reverse proxies to `127.0.0.1:3816`. + +Dev/staging on `qisi-dev`: + +- `ahand-hub.dev.coffice.qisiai.top` reverse proxies to `127.0.0.1:5815`. +- `admin.ahand.dev.coffice.qisiai.top` reverse proxies to `127.0.0.1:5816`. +- `ahand-hub.staging.coffice.qisiai.top` reverse proxies to `127.0.0.1:4815`. +- `admin.ahand.staging.coffice.qisiai.top` reverse proxies to + `127.0.0.1:4816`. + +Compose should bind application ports to loopback only. No aHand container port +should listen directly on a public interface. + +## Host Ports + +Use host ports that do not collide with the existing Agent PI deployments: + +| Environment | Hub Host Port | Dashboard Host Port | +| ----------- | ------------- | ------------------- | +| production | 3815 | 3816 | +| staging | 4815 | 4816 | +| dev | 5815 | 5816 | + +Container ports stay stable: + +- Hub container: `1515` +- Dashboard container: `1516` + +## Images + +Reuse the existing `deploy/hub/Dockerfile` and its two targets: + +- `hub` +- `dashboard` + +No qisi-specific Dockerfiles are needed unless future Aliyun builds require +China-only base-image mirrors. + +Image names: + +- `registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub` +- `registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub-dashboard` + +Each build publishes both immutable and floating tags: + +- Immutable: `-` +- Floating: `dev`, `staging`, or `production` + +Examples: + +- `registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub:dev-` +- `registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub-dashboard:production` + +Deploys should run from immutable tags written into `.env.images.next`. +Floating tags are for operator inspection and emergency manual pulls only. + +## Remote Files + +Each environment directory contains host-local files: + +- `compose.yml` +- `.env` +- `.env.secrets` +- `.env.images` +- `.env.images.next` during deploy +- `.deploy-history/` +- `.deploy-locks/` +- `scripts/deploy.sh` +- `scripts/healthcheck.sh` + +Repository-tracked deployment files: + +- `deploy/qisi/compose.yml` +- `deploy/qisi/env/dev.env.example` +- `deploy/qisi/env/staging.env.example` +- `deploy/qisi/env/production.env.example` +- `deploy/qisi/env/secrets.env.example` +- `deploy/qisi/caddy/dev.Caddyfile` +- `deploy/qisi/caddy/staging.Caddyfile` +- `deploy/qisi/caddy/production.Caddyfile` +- `deploy/qisi/scripts/deploy.sh` +- `deploy/qisi/scripts/healthcheck.sh` +- `.github/workflows/qisi-deploy.yml` + +No secret values should be committed. + +## Compose Runtime + +The shared Compose file should be parameterized by `.env` and `.env.images`. + +Compose project name: + +```yaml +name: ahand-hub-${DEPLOY_ENV} +``` + +Service `hub`: + +- Container name: `ahand-hub-${DEPLOY_ENV}-hub` +- Image variable: `AHAND_HUB_IMAGE` +- Restart policy: `unless-stopped` +- Env files: `.env`, `.env.secrets`, `.env.images` +- Host port: `127.0.0.1:${AHAND_HUB_HOST_PORT}:1515` +- Volume: `hub-audit-data:/var/lib/ahand-hub` +- Health check: `curl -fsS http://127.0.0.1:1515/api/health` + +Service `dashboard`: + +- Container name: `ahand-hub-${DEPLOY_ENV}-dashboard` +- Image variable: `AHAND_HUB_DASHBOARD_IMAGE` +- Restart policy: `unless-stopped` +- Depends on healthy `hub` +- Env files: `.env`, `.env.images` +- Host port: `127.0.0.1:${AHAND_HUB_DASHBOARD_HOST_PORT}:1516` +- Runtime hub URL: `AHAND_HUB_BASE_URL=http://hub:1515` +- Health check: `curl -fsS http://127.0.0.1:1516/login` + +## Environment Files + +`.env` contains non-secret values. + +Common keys: + +```dotenv +DEPLOY_ENV=dev +APP_ENV=development +NODE_ENV=production + +PUBLIC_HUB_DOMAIN=ahand-hub.dev.coffice.qisiai.top +PUBLIC_DASHBOARD_DOMAIN=admin.ahand.dev.coffice.qisiai.top +AHAND_HUB_PUBLIC_URL=https://ahand-hub.dev.coffice.qisiai.top +AHAND_HUB_DASHBOARD_PUBLIC_URL=https://admin.ahand.dev.coffice.qisiai.top + +AHAND_HUB_HOST_PORT=5815 +AHAND_HUB_DASHBOARD_HOST_PORT=5816 + +AHAND_HUB_BIND_ADDR=0.0.0.0:1515 +AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=https://admin.ahand.dev.coffice.qisiai.top +AHAND_HUB_LOG_FORMAT=json +AHAND_HUB_LOG_LEVEL=info +AHAND_HUB_AUDIT_RETENTION_DAYS=90 +AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl +AHAND_HUB_WEBHOOK_MAX_RETRIES=8 +AHAND_HUB_WEBHOOK_TIMEOUT_MS=5000 +``` + +Environment-specific `.env` examples should set: + +- `DEPLOY_ENV` +- `APP_ENV` +- public domains and URLs +- host ports +- dashboard allowed origins + +`.env.secrets` contains sensitive values. + +Required keys: + +```dotenv +AHAND_HUB_SERVICE_TOKEN= +AHAND_HUB_DASHBOARD_PASSWORD= +AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN= +AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID= +AHAND_HUB_JWT_SECRET= +AHAND_HUB_DATABASE_URL= +AHAND_HUB_REDIS_URL= +``` + +Optional keys: + +```dotenv +AHAND_HUB_WEBHOOK_URL= +AHAND_HUB_WEBHOOK_SECRET= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +SENTRY_DSN= +``` + +For Aliyun OSS compatibility, `AHAND_HUB_S3_ENDPOINT` should be set when using +an OSS S3-compatible endpoint. S3/OSS can be left unset for a first deployment; +large file transfer endpoints will then return the existing `S3_DISABLED` +application response. The S3/OSS variables must be omitted or commented out +until enabled, because `AHAND_HUB_S3_BUCKET=` with an empty value still causes +the hub to treat S3 as configured. + +Commented S3/OSS template: + +```dotenv +# AHAND_HUB_S3_BUCKET= +# AHAND_HUB_S3_REGION=cn-shanghai +# AHAND_HUB_S3_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com +# AHAND_HUB_S3_THRESHOLD_BYTES=1048576 +# AHAND_HUB_S3_URL_EXPIRATION_SECS=3600 +``` + +`.env.images` is written by CI and contains active image tags: + +```dotenv +AHAND_HUB_IMAGE=registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub:dev- +AHAND_HUB_DASHBOARD_IMAGE=registry.image.coffice.qisiai.top/coffice/ahand/ahand-hub-dashboard:dev- +GIT_SHA= +SENTRY_RELEASE= +``` + +## GitHub Actions Workflow + +Add `.github/workflows/qisi-deploy.yml`. + +Triggers: + +- Push to `dev` +- Push to `staging` +- Push to `main` +- Manual `workflow_dispatch` + +The `dev` and `main` push triggers must include the same hub-related path set as +`.github/workflows/deploy-hub.yml`, plus qisi deployment files. This keeps CN +deploys synchronized with global hub deploys while avoiding a qisi rollout for +unrelated docs or client-only changes. `staging` uses the same path set for a +pre-production qisi validation lane. + +Required path set: + +- `apps/hub-dashboard/**` +- `crates/ahand-hub/**` +- `crates/ahand-hub-core/**` +- `crates/ahand-hub-store/**` +- `crates/ahand-protocol/**` +- `proto/**` +- `Cargo.lock` +- `package.json` +- `pnpm-lock.yaml` +- `pnpm-workspace.yaml` +- `turbo.json` +- `deploy/hub/Dockerfile` +- `deploy/qisi/**` +- `.github/workflows/qisi-deploy.yml` + +Concurrency: + +```yaml +group: qisi-ahand-deploy-${{ github.ref_name }} +cancel-in-progress: false +``` + +Branch mapping: + +| Branch | Env | SSH Host Secret | Deploy Dir | +| --------- | ---------- | -------------------- | ----------------------------- | +| `dev` | `dev` | `QISI_DEV_SSH_HOST` | `/opt/ahand-hub/dev` | +| `staging` | `staging` | `QISI_DEV_SSH_HOST` | `/opt/ahand-hub/staging` | +| `main` | `production` | `QISI_SSH_HOST` | `/opt/ahand-hub/production` | + +Build matrix: + +- Image `ahand-hub`, Dockerfile `deploy/hub/Dockerfile`, target `hub`. +- Image `ahand-hub-dashboard`, Dockerfile `deploy/hub/Dockerfile`, target + `dashboard`. + +The workflow should: + +1. Resolve target environment and deploy directory. +2. Log in to `registry.image.coffice.qisiai.top`. +3. Build and push both images with immutable and floating tags. +4. Configure SSH from GitHub secrets. +5. Write `.env.images.next` with immutable image tags. +6. Copy `compose.yml`, `scripts/deploy.sh`, `scripts/healthcheck.sh`, and + `.env.images.next` to the target directory. +7. Run `cd && bash scripts/deploy.sh .env.images.next`. + +Required GitHub secrets: + +- `QISI_REGISTRY_USERNAME` +- `QISI_REGISTRY_PASSWORD` +- `QISI_SSH_PRIVATE_KEY` +- `QISI_SSH_USER` +- `QISI_SSH_HOST` +- `QISI_DEV_SSH_HOST` +- `QISI_KNOWN_HOSTS` + +Optional GitHub secrets for future Sentry release automation: + +- `QISI_SENTRY_AUTH_TOKEN` + +Sentry release automation is intentionally not required for the first CN +deployment. Runtime `SENTRY_DSN` can be supplied through `.env.secrets`. + +## Host Deploy Script + +`deploy/qisi/scripts/deploy.sh` should follow the Agent PI deploy script shape: + +1. Require `compose.yml`, `.env`, `.env.secrets`, and candidate image env. +2. Source `.env` and require `DEPLOY_ENV`. +3. Acquire an environment-specific lock under `.deploy-locks`. +4. Render a candidate Compose file that points env-file entries to absolute + paths, using candidate `.env.images.next`. +5. Run `docker compose config` for the candidate. +6. Pull candidate images unless `SKIP_PULL=1`. +7. Back up active `.env.images` to `.deploy-history/-.env.images`. +8. Copy the candidate image env to `.env.images`. +9. Run `docker compose --env-file .env --env-file .env.secrets --env-file .env.images -f compose.yml up -d --remove-orphans`. +10. Run `scripts/healthcheck.sh`. +11. If any post-promotion step fails, restore the backed-up `.env.images` and + run Compose again. + +## Health Checks + +`deploy/qisi/scripts/healthcheck.sh` should source `.env` and check: + +- Hub: `http://127.0.0.1:${AHAND_HUB_HOST_PORT}/api/health` +- Dashboard: `http://127.0.0.1:${AHAND_HUB_DASHBOARD_HOST_PORT}/login` + +Each check should retry for up to 120 seconds and fail loudly with the checked +URL if the service does not become healthy. + +## Rollback + +Rollback is image-level and host-local: + +1. Pick a prior `.deploy-history/*.env.images` file. +2. Copy it over `.env.images`. +3. Run `docker compose --env-file .env --env-file .env.secrets --env-file .env.images -f compose.yml up -d --remove-orphans`. +4. Run `bash scripts/healthcheck.sh`. + +The deploy script handles automatic rollback when a new rollout fails after +promotion. + +## Bootstrap Tasks + +Before the first deploy, operators must: + +1. Create directories: + - `qisi-dev:/opt/ahand-hub/dev` + - `qisi-dev:/opt/ahand-hub/staging` + - `qisi:/opt/ahand-hub/production` +2. Copy the matching env example to `.env` in each directory. +3. Create `.env.secrets` in each directory from `secrets.env.example`. +4. Ensure qisi/qisi-dev can pull from `registry.image.coffice.qisiai.top`. +5. Add or merge the Caddy snippets into each host's Caddy config. +6. Run `caddy validate --config /etc/caddy/Caddyfile`. +7. Reload Caddy after validation. + +## Testing Strategy + +Local/repository checks: + +- `docker compose --env-file .env --env-file .env.secrets --env-file .env.images -f deploy/qisi/compose.yml config` +- `docker build --target hub -f deploy/hub/Dockerfile -t ahand-hub:qisi-smoke .` +- `docker build --target dashboard -f deploy/hub/Dockerfile -t ahand-hub-dashboard:qisi-smoke .` +- Shell syntax check for `deploy/qisi/scripts/deploy.sh` and + `deploy/qisi/scripts/healthcheck.sh`. +- Existing hub CI should continue to cover Rust hub and dashboard behavior. + +Host checks: + +- `ssh qisi-dev 'docker --version && docker compose version && caddy version'` +- `ssh qisi 'docker --version && docker compose version && caddy version'` +- First deploy to `dev`, then staging, then production. +- After deploy, validate: + - `https://ahand-hub.dev.coffice.qisiai.top/api/health` + - `https://admin.ahand.dev.coffice.qisiai.top/login` + +## Risks And Decisions + +- The CN deployment intentionally does not share AWS/t9 resources. Database, + Redis, and optional OSS/S3 values must be supplied per environment. +- Reusing the existing Dockerfile keeps image behavior aligned with AWS but may + need base-image mirror work if GitHub Actions or Docker Hub access becomes + unreliable. +- Dashboard and hub are split-origin publicly, so + `AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS` must explicitly list the dashboard + origin for each environment. +- Webhook integration with the CN team9 gateway is optional at bootstrap. If + `AHAND_HUB_WEBHOOK_URL` is set, `AHAND_HUB_WEBHOOK_SECRET` must also be set, + matching the existing hub config validation. +- S3/OSS file transfer can be enabled later by filling `AHAND_HUB_S3_*` and AWS + credential-compatible env vars. It is not required for the initial hub and + dashboard deployment.