1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215pub fn spawn_model() -> Option<String> {
217 SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218 .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222 static SPAWN_NICK_MODEL: Option<String>;
225 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228 static RESUME_PROVIDER_SESSION: Option<String>;
230 static SPAWN_ORIGIN: Option<String>;
232 static SPAWN_PARENT: Option<String>;
233}
234
235fn spawn_selection(
237 options: &gate4agent_node_protocol::SpawnSessionOptions,
238) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
239 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
240 match (&options.model, &options.effort) {
241 (None, None) => Ok(None),
242 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
243 (Some(model), effort) => {
244 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
245 if let Some(e) = effort {
246 sel = sel.with_value("effort", e.clone());
247 }
248 Ok(Some(sel))
249 }
250 }
251}
252
253mod browser_bridge;
254
255use gate4agent_node_core::drive_loop_profile;
256
257#[path = "bundle_delivery.rs"]
258mod bundle_delivery;
259
260#[path = "context_pack_store.rs"]
261mod context_pack_store;
262
263#[cfg(windows)]
264pub use crate::platform::DEFAULT_NODE_ENDPOINT;
265
266const NODE_EVENT_HISTORY_MAX: usize = 4_096;
267const NODE_BROADCAST_CAPACITY: usize = 1_024;
268const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
269const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
275const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
282const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
283const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
284const MAX_PREAUTH_CONNECTIONS: usize = 32;
285const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
286const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
287const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
288const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
289const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
290const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
296const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
304const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
312const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
319const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
324const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
325const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
326const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
327const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
328const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
329const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
331const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
332const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
333const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
334const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
335const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
336const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
339const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
346const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
349const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
350const GIT_STATUS_MAX_ENTRIES: usize = 128;
351const GIT_COMMIT_MAX_ENTRIES: usize = 12;
352const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
353const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
354const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
355const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
356const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
357const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
358const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
359const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
360const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
361const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
362 "provider-session-identity-allocation-failed";
363const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
364const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
365const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
366const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
367const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
368const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
369const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
370 "durable-state-path-semantics-unsupported";
371
372fn opaque_windows_path(value: String) -> OpaqueHostPath {
373 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
374}
375
376fn agent_progress_from_provider_snapshot(
377 address: SessionAddress,
378 provider: &ProviderSnapshot,
379) -> Option<SessionAgentProgress> {
380 let mut truncated = false;
381 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
382 let mut active_tool_labels = Vec::with_capacity(
383 provider
384 .active_tools
385 .len()
386 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
387 );
388 for tool in &provider.active_tools {
389 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
390 truncated = true;
391 break;
392 }
393 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
394 active_tool_labels.push(label);
395 }
396 }
397 let attention = provider
398 .interactions
399 .iter()
400 .find(|interaction| {
401 matches!(
402 interaction.status,
403 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
404 )
405 })
406 .map(|interaction| AgentProgressAttentionV1 {
407 kind: match interaction.interaction_kind {
408 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
409 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
410 },
411 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
412 });
413 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
414 let usage = (provider.completed_turns > 0
415 || provider.usage.input_tokens > 0
416 || provider.usage.output_tokens > 0
417 || provider.usage.cache_read_tokens > 0
418 || provider.usage.cache_write_tokens > 0
419 || provider.usage.reasoning_tokens > 0)
420 .then_some(AgentProgressUsageV1 {
421 input_tokens: provider.usage.input_tokens,
422 output_tokens: provider.usage.output_tokens,
423 cache_read_tokens: provider.usage.cache_read_tokens,
424 cache_write_tokens: provider.usage.cache_write_tokens,
425 reasoning_tokens: provider.usage.reasoning_tokens,
426 });
427 let progress = AgentProgressV1 {
428 provider_sequence: provider.sequence,
429 activity: provider.activity,
430 completed_turns: provider.completed_turns,
431 usage,
432 current: AgentProgressCurrentV1::from(provider.activity),
433 active_tool_labels,
434 active_tool_count,
435 attention,
436 subagent_count,
437 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
438 gap_count: provider.gap_count,
439 stale: provider.stale,
440 truncated,
441 };
442 let entry = SessionAgentProgress { address, progress };
443 serde_json::to_vec(&entry)
444 .ok()
445 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
446 .map(|_| entry)
447}
448
449fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
450 match u32::try_from(count) {
451 Ok(count) => count,
452 Err(_) => {
453 *truncated = true;
454 u32::MAX
455 }
456 }
457}
458
459fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
460 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
461 return None;
462 }
463 Some(match event {
464 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
465 ProviderEvent::SessionIdentityObserved { .. } => {
466 AgentProgressEventKindV1::SessionIdentityObserved
467 }
468 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
469 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
470 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
471 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
472 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
473 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
474 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
475 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
476 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
477 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
478 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
479 ProviderEvent::InteractionRequested { .. } => {
480 AgentProgressEventKindV1::InteractionRequested
481 }
482 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
483 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
484 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
485 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
486 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
487 ProviderEvent::UnrecognizedNotification { .. } => {
488 AgentProgressEventKindV1::UnrecognizedNotification
489 }
490 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
491 ProviderEvent::UserMessage { .. }
502 | ProviderEvent::Plan { .. }
503 | ProviderEvent::AvailableCommandsUpdated { .. }
504 | ProviderEvent::ModeChanged { .. }
505 | ProviderEvent::SessionInfoUpdated { .. }
506 | ProviderEvent::UsageUpdated { .. }
507 | ProviderEvent::ConfigOptionsUpdated { .. } => {
508 AgentProgressEventKindV1::UnrecognizedNotification
509 }
510 })
511}
512
513fn provider_family_is_live(family: AdapterFamily) -> bool {
526 match family {
527 AdapterFamily::PtySemantic
528 | AdapterFamily::Hook
529 | AdapterFamily::ManagedHook
530 | AdapterFamily::Pipe
531 | AdapterFamily::OneShot
532 | AdapterFamily::Acp => true,
533 AdapterFamily::History
534 | AdapterFamily::Resume
535 | AdapterFamily::SessionOptions
536 | AdapterFamily::CapabilityProbe => false,
537 }
538}
539
540fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
541 match by {
542 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
543 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
544 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
545 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
546 }
547}
548
549fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
561 match authority {
562 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
563 BlockAuthorityV1::HostPolicy => "denied by host policy",
564 BlockAuthorityV1::HostDeadline => {
565 "denied by host policy after the operator deadline expired"
566 }
567 BlockAuthorityV1::Operator => "declined by the operator",
568 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
569 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
570 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
571 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
572 BlockAuthorityV1::UserRejected => "rejected by the user",
573 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
574 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
575 }
576}
577
578fn host_request_denied_block(
581 method: &str,
582 by: ProviderHostDecisionAuthority,
583 reason: Option<&str>,
584) -> (String, BlockAuthorityV1, String) {
585 let authority = block_authority(by);
586 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
587 let (reason_text, _) =
591 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
592 (tool_class_label(method), authority, reason_text)
593}
594
595fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
614 match kind? {
615 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
616 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
617 _ => None,
618 }
619}
620
621fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
633 let trimmed = text.trim();
634 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
635 return (trimmed.to_owned(), None);
636 };
637 let tail = after_reason.trim_start();
638 let Some((reason, help)) = tail.split_once(". ") else {
639 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
643 return (reason.to_owned(), None);
644 };
645 let help = help.trim();
646 if help.is_empty() {
647 (reason.trim().to_owned(), None)
648 } else {
649 (reason.trim().to_owned(), Some(help.to_owned()))
650 }
651}
652
653fn tool_class_from_denial_help(help: Option<&str>) -> String {
663 help.and_then(|text| text.split("add a ").nth(1))
664 .and_then(|tail| tail.split(' ').next())
665 .filter(|word| !word.is_empty())
666 .map(str::to_owned)
667 .unwrap_or_else(|| "Tool".to_owned())
668}
669
670fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
678 fn names_quota(s: &str) -> bool {
679 let lower = s.to_ascii_lowercase();
680 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
681 }
682 vendor_code.is_some_and(names_quota) || names_quota(message)
683}
684
685fn provider_session_end_block(
701 stop_reason: &ProviderStopReason,
702) -> Option<(BlockAuthorityV1, String, String)> {
703 match stop_reason {
704 ProviderStopReason::Refusal => Some((
705 BlockAuthorityV1::ProviderRefusal,
706 "refusal".to_owned(),
707 "the provider ended the turn with stopReason: refusal".to_owned(),
708 )),
709 ProviderStopReason::ProviderError { message, vendor_code, .. }
710 if provider_error_is_quota(vendor_code.as_deref(), message) =>
711 {
712 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
713 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
714 }
715 _ => None,
716 }
717}
718
719fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
760 let ControlEventKind::ProviderEvent {
761 sequence: provider_sequence,
762 source,
763 source_sequence,
764 event: provider_event,
765 } = &event.event
766 else {
767 return None;
768 };
769 if !provider_family_is_live(source.family) {
770 return None;
771 }
772 let kind = match provider_event {
773 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
774 text: text.clone(),
775 is_delta: *is_delta,
776 },
777 ProviderEvent::Thinking { text } => {
778 AgentStreamChunkKindV1::Thinking { text: text.clone() }
779 }
780 ProviderEvent::InteractionRequested {
781 interaction_kind,
782 tool_name,
783 title,
784 prompt,
785 options,
786 ..
787 } => AgentStreamChunkKindV1::InteractionPrompt {
788 correlation_id: interaction_correlation(
789 event.instance_id,
790 event.generation,
791 *provider_sequence,
792 ),
793 interaction_kind: *interaction_kind,
794 tool_name: tool_name.clone(),
795 title: title.clone(),
804 prompt: prompt.clone(),
805 options: options
806 .iter()
807 .map(|option| AgentStreamInteractionOptionV1 {
808 option_id: option.option_id.clone(),
809 name: option.name.clone(),
810 kind: option.kind.clone(),
811 })
812 .collect(),
813 },
814 ProviderEvent::ConfigOptionsUpdated { options } => {
815 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
816 }
817 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
828 current: Some(mode_id.clone()),
829 available: available
830 .iter()
831 .map(|mode| AgentStreamNamedIdV1 {
832 id: mode.id.clone(),
833 name: mode.name.clone(),
834 description: mode.description.clone(),
835 })
836 .collect(),
837 },
838 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
842 let ProviderHostRequestDecision::Denied { by } = decision else {
843 return None;
844 };
845 let (tool_class, authority, reason_text) =
846 host_request_denied_block(method, *by, reason.as_deref());
847 AgentStreamChunkKindV1::Blocked {
848 correlation_id: None,
849 tool_class,
850 authority,
851 reason_kind: None,
852 reason: reason_text,
853 help: None,
854 }
855 }
856 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
860 let (authority, reason_kind) =
861 provider_tool_block_authority(non_execution_kind.as_deref())?;
862 let (reason, help) = split_provider_denial_text(output);
863 let (reason, _) =
864 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
865 let help = help.map(|help| {
866 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
867 });
868 AgentStreamChunkKindV1::Blocked {
869 correlation_id: Some(tool_correlation(event, source, id)),
870 tool_class: tool_class_from_denial_help(help.as_deref()),
871 authority,
872 reason_kind: Some(reason_kind.to_owned()),
873 reason,
874 help,
875 }
876 }
877 ProviderEvent::SessionEnded { stop_reason, .. } => {
881 let (authority, reason_kind, reason) =
882 stop_reason.as_ref().and_then(provider_session_end_block)?;
883 let (reason, _) =
884 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
885 AgentStreamChunkKindV1::Blocked {
886 correlation_id: None,
887 tool_class: "Session".to_owned(),
888 authority,
889 reason_kind: Some(reason_kind),
890 reason,
891 help: None,
892 }
893 }
894 ProviderEvent::SessionStarted { .. }
895 | ProviderEvent::SessionIdentityObserved { .. }
896 | ProviderEvent::TurnStarted { .. }
897 | ProviderEvent::WorkingObserved
898 | ProviderEvent::ToolStarted { .. }
899 | ProviderEvent::TurnCompleted { .. }
900 | ProviderEvent::ContextWindowUsage { .. }
901 | ProviderEvent::TurnInterrupted
902 | ProviderEvent::Error { .. }
903 | ProviderEvent::Ready
904 | ProviderEvent::InteractionResolved { .. }
905 | ProviderEvent::SubagentStarted { .. }
906 | ProviderEvent::SubagentStopped { .. }
907 | ProviderEvent::RateLimited { .. }
908 | ProviderEvent::UnrecognizedNotification { .. }
909 | ProviderEvent::UserMessage { .. }
910 | ProviderEvent::Plan { .. }
911 | ProviderEvent::AvailableCommandsUpdated { .. }
912 | ProviderEvent::SessionInfoUpdated { .. }
913 | ProviderEvent::UsageUpdated { .. } => return None,
914 };
915 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
916}
917
918async fn run_native_session_catalog_operation<T, E, F>(
919 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
920 operation: &'static str,
921 execute: F,
922) -> Result<T, NodeFailure>
923where
924 T: Send + 'static,
925 E: std::fmt::Display + Send + 'static,
926 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
927{
928 let task = tokio::task::spawn_blocking(move || {
929 let mut catalog = match catalog.try_lock() {
930 Ok(catalog) => catalog,
931 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
932 Err(std::sync::TryLockError::WouldBlock) => {
933 return Err(failure(
934 NodeFailureCode::BackendBusy,
935 "native session catalog operation is already running",
936 ));
937 }
938 };
939 execute(&mut catalog).map_err(|error| {
940 failure(
941 NodeFailureCode::BackendOperationFailed,
942 &format!("{operation} failed: {error}"),
943 )
944 })
945 });
946 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
947 .await
948 .map_err(|_| {
949 failure(
950 NodeFailureCode::BackendBusy,
951 &format!("{operation} exceeded its bounded deadline"),
952 )
953 })?
954 .map_err(|_| {
955 failure(
956 NodeFailureCode::BackendOperationFailed,
957 &format!("{operation} task failed"),
958 )
959 })?
960}
961
962fn windows_path_text(path: &OpaqueHostPath) -> &str {
963 path.as_utf8()
964 .expect("Windows node only creates UTF-8 opaque host paths")
965}
966
967fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
968 path.as_utf8()
969 .map(str::to_owned)
970 .ok_or_else(|| failure(
971 NodeFailureCode::InvalidRequest,
972 "this node requires UTF-8 host paths",
973 ))
974}
975
976fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
977 GitWorktreeSnapshot {
978 path: opaque_windows_path(worktree.path),
979 head: worktree.head,
980 branch: worktree.branch,
981 is_bare: worktree.is_bare,
982 is_main: worktree.is_main,
983 locked: worktree.locked,
984 lock_reason: worktree.lock_reason,
985 prunable: worktree.prunable,
986 prunable_reason: worktree.prunable_reason,
987 workspace_id: worktree.workspace_id.take(),
988 }
989}
990
991#[derive(Clone, Debug, Eq, PartialEq)]
992pub struct WorkspaceConfig {
993 workspace_id: WorkspaceId,
994 canonical_root: String,
995 worktree_service_mode: WorktreeServiceMode,
996 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
997}
998
999impl WorkspaceConfig {
1000 pub fn new(
1001 workspace_id: WorkspaceId,
1002 root: impl AsRef<Path>,
1003 ) -> Result<Self, NodeServerError> {
1004 let root = root.as_ref();
1005 if !root.is_absolute() {
1006 return Err(NodeServerError::InvalidWorkspaceRoot {
1007 workspace_id,
1008 path: root.to_string_lossy().into_owned(),
1009 message: "path is not absolute".to_owned(),
1010 });
1011 }
1012 let canonical = std::fs::canonicalize(root).map_err(|error| {
1013 NodeServerError::InvalidWorkspaceRoot {
1014 workspace_id: workspace_id.clone(),
1015 path: root.to_string_lossy().into_owned(),
1016 message: error.to_string(),
1017 }
1018 })?;
1019 if !canonical.is_dir() {
1020 return Err(NodeServerError::InvalidWorkspaceRoot {
1021 workspace_id,
1022 path: root.to_string_lossy().into_owned(),
1023 message: "path is not a directory".to_owned(),
1024 });
1025 }
1026 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1027 NodeServerError::InvalidWorkspaceRoot {
1028 workspace_id: workspace_id.clone(),
1029 path: path.to_string_lossy().into_owned(),
1030 message: "canonical path is not valid Unicode".to_owned(),
1031 }
1032 })?;
1033 let canonical_root = platform::normalize_canonical_root(canonical_root);
1034 validate_workspace_root(&workspace_id, &canonical_root)?;
1035 Ok(Self {
1036 workspace_id,
1037 canonical_root,
1038 worktree_service_mode: WorktreeServiceMode::Manual,
1039 managed_worktree_profiles: BTreeMap::new(),
1040 })
1041 }
1042
1043 pub fn workspace_id(&self) -> &WorkspaceId {
1044 &self.workspace_id
1045 }
1046
1047 pub fn canonical_root(&self) -> &str {
1048 &self.canonical_root
1049 }
1050
1051 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1052 self.worktree_service_mode = mode;
1053 self
1054 }
1055
1056 pub fn with_managed_worktree_profile(
1057 mut self,
1058 profile: ManagedWorktreeProfile,
1059 ) -> Result<Self, NodeServerError> {
1060 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1061 NodeServerError::InvalidManagedWorktreeProfile {
1062 workspace_id: self.workspace_id.clone(),
1063 profile_id: profile.profile_id().clone(),
1064 message,
1065 }
1066 })?;
1067 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1068 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1069 workspace_id: self.workspace_id,
1070 profile_id: profile.profile_id().clone(),
1071 });
1072 }
1073 if self.managed_worktree_profiles.len()
1074 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1075 {
1076 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1077 workspace_id: self.workspace_id,
1078 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1079 });
1080 }
1081 self.managed_worktree_profiles
1082 .insert(profile.profile_id().clone(), profile);
1083 Ok(self)
1084 }
1085}
1086
1087#[derive(Clone)]
1088pub struct NodeServerConfig {
1089 pub endpoint: String,
1090 api_listen: Option<std::net::SocketAddr>,
1091 bridge_listen: Option<std::net::SocketAddr>,
1094 bridge_token: Option<String>,
1097 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1100 call_home: Option<std::net::SocketAddr>,
1103 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1107 pub node_id: NodeId,
1108 pub workspaces: Vec<WorkspaceConfig>,
1109 access_token: String,
1110 pub runtime: NativeRuntimeConfig,
1111 session_record_retention: SessionRecordRetentionConfig,
1112 state_path: Option<PathBuf>,
1113 spawn_profiles: SpawnProfileRegistry,
1114 session_environment: Option<NodeSessionEnvironmentConfig>,
1115 history: Option<NativeHistoryConfig>,
1116 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1117 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1121 #[cfg(feature = "fixture")]
1122 fixture_raw_pty_runtime: bool,
1123}
1124
1125#[derive(Clone)]
1126struct NodeSessionEnvironmentConfig {
1127 root: PathBuf,
1128 resolver: Arc<dyn NodeSecretResolver>,
1129}
1130
1131impl NodeServerConfig {
1132 pub fn new(
1133 endpoint: impl Into<String>,
1134 access_token: impl Into<String>,
1135 node_id: NodeId,
1136 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1137 ) -> Result<Self, NodeServerError> {
1138 let endpoint = endpoint.into();
1139 let access_token = access_token.into();
1140 if !platform::validate_endpoint(&endpoint) {
1141 return Err(NodeServerError::InvalidEndpoint);
1142 }
1143 if access_token.is_empty() || access_token.len() > 4_096 {
1144 return Err(NodeServerError::InvalidAccessToken);
1145 }
1146 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1147 if workspaces.is_empty() {
1148 return Err(NodeServerError::NoWorkspaces);
1149 }
1150 let mut workspace_ids = BTreeMap::new();
1151 let mut workspace_roots = BTreeMap::new();
1152 for workspace in &workspaces {
1153 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1154 && workspace.managed_worktree_profiles.is_empty()
1155 {
1156 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1157 workspace.workspace_id.clone(),
1158 ));
1159 }
1160 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1161 && !workspace.managed_worktree_profiles.is_empty()
1162 {
1163 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1164 workspace.workspace_id.clone(),
1165 ));
1166 }
1167 if workspace_ids
1168 .insert(workspace.workspace_id.clone(), ())
1169 .is_some()
1170 {
1171 return Err(NodeServerError::DuplicateWorkspaceId(
1172 workspace.workspace_id.clone(),
1173 ));
1174 }
1175 let root_key = platform::root_identity(&workspace.canonical_root);
1176 if let Some(existing) = workspace_roots.insert(
1177 root_key,
1178 workspace.workspace_id.clone(),
1179 ) {
1180 return Err(NodeServerError::DuplicateWorkspaceRoot {
1181 first: existing,
1182 second: workspace.workspace_id.clone(),
1183 root: workspace.canonical_root.clone(),
1184 });
1185 }
1186 }
1187 Ok(Self {
1188 endpoint,
1189 api_listen: None,
1190 bridge_listen: None,
1191 bridge_token: None,
1192 bridge_underlay: None,
1193 call_home: None,
1194 call_home_identity: None,
1195 node_id,
1196 workspaces,
1197 access_token,
1198 runtime: NativeRuntimeConfig {
1201 worker_poll_interval_ms: 4,
1202 ..NativeRuntimeConfig::default()
1203 },
1204 session_record_retention: SessionRecordRetentionConfig::default(),
1205 state_path: None,
1206 spawn_profiles: SpawnProfileRegistry::default(),
1207 session_environment: None,
1208 history: None,
1209 harness_mcp_helper: None,
1210 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1211 #[cfg(feature = "fixture")]
1212 fixture_raw_pty_runtime: false,
1213 })
1214 }
1215
1216 pub fn with_api_listen(
1217 mut self,
1218 api_listen: std::net::SocketAddr,
1219 ) -> Result<Self, NodeServerError> {
1220 if !api_listen.ip().is_loopback() {
1221 return Err(NodeServerError::InvalidApiListen(api_listen));
1222 }
1223 self.api_listen = Some(api_listen);
1224 Ok(self)
1225 }
1226
1227 pub fn with_bridge_listen(
1231 mut self,
1232 bridge_listen: std::net::SocketAddr,
1233 ) -> Result<Self, NodeServerError> {
1234 if !bridge_listen.ip().is_loopback() {
1235 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1236 }
1237 self.bridge_listen = Some(bridge_listen);
1238 Ok(self)
1239 }
1240
1241 pub fn with_bridge_token(
1244 mut self,
1245 bridge_token: impl Into<String>,
1246 ) -> Result<Self, NodeServerError> {
1247 let bridge_token = bridge_token.into();
1248 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1249 return Err(NodeServerError::InvalidBridgeToken);
1250 }
1251 self.bridge_token = Some(bridge_token);
1252 Ok(self)
1253 }
1254
1255 pub fn with_bridge_underlay(
1260 mut self,
1261 bind: std::net::SocketAddr,
1262 transport_key: [u8; 32],
1263 underlay_token: impl Into<String>,
1264 ) -> Result<Self, NodeServerError> {
1265 if self.bridge_listen.is_none() {
1266 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1267 }
1268 if self.bridge_token.is_none() {
1269 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1270 }
1271 let underlay_token = underlay_token.into();
1272 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1273 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1274 }
1275 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1277 bind,
1278 transport_key,
1279 underlay_token,
1280 });
1281 Ok(self)
1282 }
1283
1284 pub fn with_call_home(
1305 mut self,
1306 relay: std::net::SocketAddr,
1307 ) -> Result<Self, NodeServerError> {
1308 if relay.port() == 0 || relay.ip().is_unspecified() {
1311 return Err(NodeServerError::InvalidCallHome(relay));
1312 }
1313 self.call_home = Some(relay);
1314 Ok(self)
1315 }
1316
1317 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1319 self.call_home_identity = Some(identity);
1320 self
1321 }
1322
1323 pub fn with_state_path(
1324 mut self,
1325 state_path: impl Into<PathBuf>,
1326 ) -> Result<Self, NodeServerError> {
1327 let state_path = state_path.into();
1328 if !state_path.is_absolute() || state_path.file_name().is_none() {
1329 return Err(NodeServerError::InvalidStatePath(
1330 state_path.to_string_lossy().into_owned(),
1331 ));
1332 }
1333 self.state_path = Some(state_path);
1334 Ok(self)
1335 }
1336
1337 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1338 self.spawn_profiles = spawn_profiles;
1339 self
1340 }
1341
1342 pub fn with_session_record_retention(
1348 mut self,
1349 retention: SessionRecordRetentionConfig,
1350 ) -> Self {
1351 self.session_record_retention = retention;
1352 self
1353 }
1354
1355 pub fn with_session_environment_materialization(
1356 mut self,
1357 root: impl Into<PathBuf>,
1358 resolver: Arc<dyn NodeSecretResolver>,
1359 ) -> Result<Self, NodeServerError> {
1360 let root = root.into();
1361 if !root.is_absolute() {
1362 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1363 }
1364 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1365 Ok(self)
1366 }
1367
1368 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1371 self.history = Some(history);
1372 self
1373 }
1374
1375 pub fn with_harness_mcp_helper(
1381 mut self,
1382 helper_program: impl Into<PathBuf>,
1383 ) -> Result<Self, NodeServerError> {
1384 self.harness_mcp_helper = Some(
1385 ReviewedHarnessMcpProgram::review(helper_program.into())
1386 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1387 );
1388 Ok(self)
1389 }
1390
1391 pub fn with_network_allowlist_catalog(
1396 mut self,
1397 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1398 ) -> Result<Self, NodeServerError> {
1399 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1400 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1401 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1402 });
1403 }
1404 self.network_allowlist_catalog = catalog;
1405 Ok(self)
1406 }
1407
1408 pub fn with_network_allowlist_catalog_file(
1412 self,
1413 path: impl AsRef<std::path::Path>,
1414 ) -> Result<Self, NodeServerError> {
1415 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1416 .map_err(NodeServerError::from)?;
1417 self.with_network_allowlist_catalog(catalog)
1418 }
1419}
1420
1421pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1422 platform::default_state_path(node_id.as_str())
1423 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1424}
1425
1426pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1427 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1428}
1429
1430fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1431 let parent = state_path.parent()?;
1432 let mut name = state_path.file_name()?.to_os_string();
1433 name.push(".delivery-store");
1434 Some(parent.join(name))
1435}
1436
1437fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1438 let parent = state_path.parent()?;
1439 let mut name = state_path.file_name()?.to_os_string();
1440 name.push(".context-pack-store");
1441 Some(parent.join(name))
1442}
1443
1444pub struct NodeServer {
1445 config: NodeServerConfig,
1446 runtime: NativeRuntime,
1447 shared: Arc<NodeShared>,
1448 events: EventSubscription,
1449 state_path_lock: Option<session_registry::StatePathLock>,
1450}
1451
1452#[cfg(feature = "fixture")]
1453#[derive(Clone, Default)]
1454pub struct SpawnManagedWorktreeV2FailureProbe {
1455 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1456}
1457
1458#[cfg(feature = "fixture")]
1459impl SpawnManagedWorktreeV2FailureProbe {
1460 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1461 *self
1462 .latest_code
1463 .lock()
1464 .unwrap_or_else(|poisoned| poisoned.into_inner())
1465 }
1466
1467 fn record(&self, code: Option<NodeFailureCode>) {
1468 *self
1469 .latest_code
1470 .lock()
1471 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1472 }
1473}
1474
1475#[cfg(all(feature = "fixture", windows))]
1476struct IsolatedCodexHomeFixtureEnvironment;
1477
1478#[cfg(all(feature = "fixture", windows))]
1479impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1480 for IsolatedCodexHomeFixtureEnvironment
1481{
1482 fn resolve_child_environment(
1483 &self,
1484 ) -> Result<
1485 Vec<gate4agent_catalog::EnvMutation>,
1486 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1487 > {
1488 Ok(vec![gate4agent_catalog::EnvMutation {
1489 key: OsString::from("USERPROFILE"),
1490 value: None,
1491 }])
1492 }
1493}
1494
1495impl NodeServer {
1496 #[cfg(unix)]
1500 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1501 self.shared.signer_proxy.set(proxy).is_ok()
1502 }
1503
1504 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1505 self.shared.m4a_accounts.as_ref()?;
1506 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1507 }
1508
1509 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1510 let catalog = active_registry()?;
1511 Self::new_with_registry(config, catalog)
1512 }
1513
1514 #[cfg(feature = "fixture")]
1515 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1516 let mut spec = gate4agent_testkit::interactive_agent_spec();
1517 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1518 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1519 Self::new_fixture_with_spec(config, spec)
1520 }
1521
1522 #[cfg(feature = "fixture")]
1523 pub fn new_clean_exit_fixture(
1524 config: NodeServerConfig,
1525 fixture_root: PathBuf,
1526 started_marker: PathBuf,
1527 release_signal: PathBuf,
1528 ) -> Result<Self, NodeServerError> {
1529 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1530 &fixture_root,
1531 &started_marker,
1532 &release_signal,
1533 )
1534 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535 spec.id = AgentId::new("claude")
1536 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1538 Self::new_fixture_with_spec(config, spec)
1539 }
1540
1541 #[cfg(all(feature = "fixture", windows))]
1551 pub fn new_durable_context_pack_clean_exit_fixture(
1552 config: NodeServerConfig,
1553 fixture_root: PathBuf,
1554 started_marker: PathBuf,
1555 release_signal: PathBuf,
1556 session_id: &str,
1557 ) -> Result<Self, NodeServerError> {
1558 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1559 &fixture_root,
1560 &started_marker,
1561 &release_signal,
1562 session_id,
1563 )
1564 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1565 let source_id = AgentId::new("claude")
1566 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1568 NodeServerError::Registry(
1569 "durable context pack clean-exit fixture source adapter is unavailable"
1570 .to_owned(),
1571 )
1572 })?;
1573 source_spec.capabilities.adapters.history =
1574 source_provider.capabilities.adapters.history.clone();
1575 source_spec.id = source_id;
1576 source_spec.display_name =
1577 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1578 source_spec.detection.command =
1584 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1585 source_spec.detection.aliases.clear();
1586
1587 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1588 target_spec.id = AgentId::new("codex")
1589 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1590 target_spec.display_name =
1591 "Controlled Codex durable context-pack target fixture".to_owned();
1592 target_spec.detection.command =
1593 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1594 target_spec.detection.aliases.clear();
1595
1596 let catalog = AgentRegistry::new([source_spec, target_spec])
1597 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598 let mut server = Self::new_with_registry(config, catalog)?;
1599 Arc::get_mut(&mut server.shared)
1600 .expect("fixture Node shared state must remain uniquely owned before run")
1601 .fixture_semantic_hook_policy = true;
1602 Ok(server)
1603 }
1604
1605 #[cfg(feature = "fixture")]
1606 pub fn new_monitoring_hook_fixture(
1607 config: NodeServerConfig,
1608 ) -> Result<Self, NodeServerError> {
1609 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1610 spec.id = AgentId::new("claude")
1611 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1612 let mut server = Self::new_fixture_with_spec(config, spec)?;
1613 Arc::get_mut(&mut server.shared)
1614 .expect("fixture Node shared state must remain uniquely owned before run")
1615 .fixture_semantic_hook_policy = true;
1616 Ok(server)
1617 }
1618
1619 #[cfg(all(feature = "fixture", windows))]
1620 pub fn new_provider_bundle_argv_fixture(
1621 config: NodeServerConfig,
1622 agent_id: AgentId,
1623 proof_path: PathBuf,
1624 ) -> Result<Self, NodeServerError> {
1625 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1626 Self::new_fixture_with_spec(config, spec)
1627 }
1628
1629 #[cfg(all(feature = "fixture", windows))]
1630 pub fn new_provider_bundle_argv_hold_fixture(
1631 config: NodeServerConfig,
1632 agent_id: AgentId,
1633 proof_path: PathBuf,
1634 release_signal: PathBuf,
1635 ) -> Result<Self, NodeServerError> {
1636 let spec = Self::provider_bundle_argv_fixture_spec(
1637 agent_id,
1638 proof_path,
1639 Some(release_signal),
1640 )?;
1641 Self::new_fixture_with_spec(config, spec)
1642 }
1643
1644 #[cfg(all(feature = "fixture", windows))]
1645 fn provider_bundle_argv_fixture_spec(
1646 agent_id: AgentId,
1647 proof_path: PathBuf,
1648 release_signal: Option<PathBuf>,
1649 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1650 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1651 return Err(NodeServerError::Registry(
1652 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1653 ));
1654 }
1655 let mut spec = gate4agent_testkit::interactive_agent_spec();
1656 spec.id = agent_id.clone();
1657 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1658 let provider = builtin_registry()
1659 .get(&agent_id)
1660 .ok_or_else(|| {
1661 NodeServerError::Registry(
1662 "provider bundle argv fixture adapter is unavailable".to_owned(),
1663 )
1664 })?;
1665 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1666 if !proof_path.is_absolute()
1667 || !proof_path
1668 .parent()
1669 .is_some_and(|parent| parent.is_dir())
1670 {
1671 return Err(NodeServerError::Registry(
1672 "provider bundle argv proof path requires an existing absolute parent"
1673 .to_owned(),
1674 ));
1675 }
1676 if let Some(release_signal) = release_signal.as_ref() {
1677 if !release_signal.is_absolute()
1678 || !release_signal
1679 .parent()
1680 .is_some_and(|parent| parent.is_dir())
1681 {
1682 return Err(NodeServerError::Registry(
1683 "provider bundle argv release path requires an existing absolute parent"
1684 .to_owned(),
1685 ));
1686 }
1687 if release_signal.exists() {
1688 return Err(NodeServerError::Registry(
1689 "provider bundle argv release path must not exist".to_owned(),
1690 ));
1691 }
1692 let proof_parent = std::fs::canonicalize(
1693 proof_path
1694 .parent()
1695 .expect("validated provider bundle proof parent"),
1696 )
1697 .map_err(|_| {
1698 NodeServerError::Registry(
1699 "provider bundle argv proof parent is unavailable".to_owned(),
1700 )
1701 })?;
1702 let release_parent = std::fs::canonicalize(
1703 release_signal
1704 .parent()
1705 .expect("validated provider bundle release parent"),
1706 )
1707 .map_err(|_| {
1708 NodeServerError::Registry(
1709 "provider bundle argv release parent is unavailable".to_owned(),
1710 )
1711 })?;
1712 if proof_parent != release_parent {
1713 return Err(NodeServerError::Registry(
1714 "provider bundle argv release path must share the proof parent".to_owned(),
1715 ));
1716 }
1717 }
1718 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1719 NodeServerError::Registry(
1720 "provider bundle argv proof path is not valid Unicode".to_owned(),
1721 )
1722 })?;
1723 let release_signal = release_signal.map(|release_signal| {
1724 release_signal.into_os_string().into_string().map_err(|_| {
1725 NodeServerError::Registry(
1726 "provider bundle argv release path is not valid Unicode".to_owned(),
1727 )
1728 })
1729 }).transpose()?;
1730 let script = spec
1731 .launch
1732 .fixed_args
1733 .pop()
1734 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1735 let bundle_validation = if agent_id.as_str() == "codex" {
1736 "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1737 } else if agent_id.as_str() == "claude" {
1738 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1739 } else {
1740 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1741 };
1742 if let Some(release_signal) = release_signal {
1743 spec.launch.fixed_args.push(format!(
1744 "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1745 ));
1746 spec.launch.fixed_args.push(proof_path);
1747 spec.launch.fixed_args.push(release_signal);
1748 } else {
1749 spec.launch.fixed_args.push(format!(
1750 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1751 ));
1752 spec.launch.fixed_args.push(proof_path);
1753 }
1754 Ok(spec)
1755 }
1756
1757 #[cfg(all(feature = "fixture", windows))]
1758 pub fn new_context_pack_fixture(
1759 config: NodeServerConfig,
1760 proof_path: PathBuf,
1761 ) -> Result<Self, NodeServerError> {
1762 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1763 Self::new_with_registry(config, catalog)
1764 }
1765
1766 #[cfg(all(feature = "fixture", windows))]
1767 pub fn new_monitoring_context_pack_fixture(
1768 config: NodeServerConfig,
1769 proof_path: PathBuf,
1770 ) -> Result<Self, NodeServerError> {
1771 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1772 let mut server = Self::new_with_registry(config, catalog)?;
1773 Arc::get_mut(&mut server.shared)
1774 .expect("fixture Node shared state must remain uniquely owned before run")
1775 .fixture_semantic_hook_policy = true;
1776 Ok(server)
1777 }
1778
1779 #[cfg(all(feature = "fixture", windows))]
1780 pub fn install_isolated_codex_home_environment_fixture(
1781 &mut self,
1782 profile_id: SpawnEnvironmentProfileId,
1783 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1784 ) -> Result<(), NodeServerError> {
1785 let provider = AgentId::new("codex")
1786 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1788 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1789 "{}-pty",
1790 profile_id.as_str(),
1791 ))
1792 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1793 provider.clone(),
1794 TransportKind::Pty,
1795 vec![OsString::from("USERPROFILE")],
1796 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1797 )
1798 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1799 let materialization = NodeSessionMaterializationProfile::new(
1800 Vec::new(),
1801 vec![crate::session_environment::NodeSessionPathBinding::new(
1802 "CODEX_HOME",
1803 crate::session_environment::NodeSessionPathClass::ProviderHome,
1804 )
1805 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1806 Vec::new(),
1807 )
1808 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1809 self.install_environment_profile(
1810 NodeEnvironmentProfile::new_with_materialization(
1811 profile_id,
1812 profile_revision,
1813 provider,
1814 [native_profile],
1815 Some(materialization),
1816 )
1817 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1818 )
1819 }
1820
1821 #[cfg(all(feature = "fixture", windows))]
1822 pub fn new_context_only_proof_fixture(
1823 config: NodeServerConfig,
1824 target_provider: AgentId,
1825 proof_path: PathBuf,
1826 ) -> Result<Self, NodeServerError> {
1827 if target_provider.as_str() != "kimi" {
1828 return Err(NodeServerError::Registry(
1829 "context-only proof fixture requires Kimi".to_owned(),
1830 ));
1831 }
1832 let catalog = Self::context_pack_fixture_catalog(
1833 proof_path,
1834 Some(&target_provider),
1835 false,
1836 )?;
1837 Self::new_with_registry(config, catalog)
1838 }
1839
1840 #[cfg(all(feature = "fixture", windows))]
1841 fn context_pack_fixture_catalog(
1842 proof_path: PathBuf,
1843 context_only_target: Option<&AgentId>,
1844 monitoring_claude_source: bool,
1845 ) -> Result<AgentRegistry, NodeServerError> {
1846 if !proof_path.is_absolute()
1847 || !proof_path
1848 .parent()
1849 .is_some_and(|parent| parent.is_dir())
1850 {
1851 return Err(NodeServerError::Registry(
1852 "context pack proof path requires an existing absolute parent".to_owned(),
1853 ));
1854 }
1855 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1856 NodeServerError::Registry(
1857 "context pack proof path is not valid Unicode".to_owned(),
1858 )
1859 })?;
1860 let providers = ["claude", "codex", "grok", "kimi"];
1861 let builtins = builtin_registry();
1862 let mut specs = Vec::with_capacity(providers.len());
1863 for provider_id in providers {
1864 let agent_id = AgentId::new(provider_id)
1865 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1866 let provider = builtins.get(&agent_id).ok_or_else(|| {
1867 NodeServerError::Registry(
1868 "context pack fixture provider adapter is unavailable".to_owned(),
1869 )
1870 })?;
1871 let monitored_provider =
1872 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1873 let mut spec = if monitored_provider {
1874 gate4agent_testkit::monitoring_hook_agent_spec()
1875 } else {
1876 gate4agent_testkit::interactive_agent_spec()
1877 };
1878 spec.id = agent_id.clone();
1879 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1880 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1881 spec.detection.aliases.clear();
1882 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1883 if provider_id == "codex" {
1884 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1885 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1886 })?;
1887 let validation = r#"
1888$bundleArgs = @($bundleArgs)
1889if ($bundleArgs.Count -ne 0) { exit 91 }
1890$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1891$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1892$cwd = (Get-Location).ProviderPath
1893if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1894$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1895if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1896$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1897if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1901if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1902$contextPath = Join-Path $contextRoot 'context-pack.json'
1903if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1904$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1905if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1906$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1907$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1908$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$separator = [IO.Path]::DirectorySeparatorChar
1910if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1912$rawContext = [IO.File]::ReadAllText($contextPath)
1913if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1914try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1915$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1916$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1917foreach ($property in @($document.PSObject.Properties.Name)) {
1918 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1919}
1920foreach ($property in $requiredTopLevel) {
1921 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1922}
1923if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1924if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1925$messages = @($document.retained_messages)
1926if ($messages.Count -eq 0) { exit 107 }
1927foreach ($message in $messages) {
1928 $messageProperties = @($message.PSObject.Properties.Name)
1929 foreach ($property in $messageProperties) {
1930 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1931 }
1932 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1933}
1934$sha = [Security.Cryptography.SHA256]::Create()
1935try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1936[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1937$rawProof = [IO.File]::ReadAllText($proofPath)
1938if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1939[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1940"#;
1941 spec.launch.fixed_args.push(format!(
1942 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1943 ));
1944 spec.launch.fixed_args.push(proof_path.clone());
1945 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1946 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1947 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1948 })?;
1949 let validation = r#"
1950$bundleArgs = @($bundleArgs)
1951if ($bundleArgs.Count -ne 0) { exit 91 }
1952$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1953$cwd = (Get-Location).ProviderPath
1954if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1955$contextPath = Join-Path $contextRoot 'context-pack.json'
1956if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1957$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1958if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1959$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1960$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1961$separator = [IO.Path]::DirectorySeparatorChar
1962if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1963try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1964if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1965if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1966$messages = @($document.retained_messages)
1967if ($messages.Count -eq 0) { exit 99 }
1968$hasUser = $false
1969$hasAssistant = $false
1970foreach ($message in $messages) {
1971 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1972 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1973 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1974 else { exit 101 }
1975}
1976if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1977$sha = [Security.Cryptography.SHA256]::Create()
1978try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1979[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1980[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1981"#;
1982 spec.launch.fixed_args.push(format!(
1983 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1984 ));
1985 spec.launch.fixed_args.push(proof_path.clone());
1986 }
1987 specs.push(spec);
1988 }
1989 let catalog = AgentRegistry::new(specs)
1990 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1991 Ok(catalog)
1992 }
1993
1994 #[cfg(feature = "fixture")]
1995 pub fn new_exact_launcher_fixture(
1996 config: NodeServerConfig,
1997 agent_id: AgentId,
1998 launcher: String,
1999 ) -> Result<Self, NodeServerError> {
2000 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2001 return Err(NodeServerError::Registry(
2002 "exact launcher fixture requires a supported provider".to_owned(),
2003 ));
2004 }
2005 if launcher.contains('\0') {
2006 return Err(NodeServerError::Registry(
2007 "exact launcher fixture path contains NUL".to_owned(),
2008 ));
2009 }
2010 let launcher_path = Path::new(&launcher);
2011 if !launcher_path.is_absolute() {
2012 return Err(NodeServerError::Registry(
2013 "exact launcher fixture path must be absolute".to_owned(),
2014 ));
2015 }
2016 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2017 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2018 })?;
2019 if !metadata.is_file() {
2020 return Err(NodeServerError::Registry(
2021 "exact launcher fixture path must be a regular file".to_owned(),
2022 ));
2023 }
2024 let mut spec = builtin_registry()
2025 .get(&agent_id)
2026 .ok_or_else(|| {
2027 NodeServerError::Registry(
2028 "exact launcher fixture provider is unavailable".to_owned(),
2029 )
2030 })?
2031 .clone();
2032 spec.launch.program = launcher;
2033 Self::new_fixture_with_spec(config, spec)
2034 }
2035
2036 #[cfg(feature = "fixture")]
2045 fn h3b_provider_spec_with_program_and_args(
2046 agent_id: AgentId,
2047 provider_program: PathBuf,
2048 fixed_args: Vec<OsString>,
2049 ) -> Result<AgentSpec, NodeServerError> {
2050 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2051 return Err(NodeServerError::Registry(
2052 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2053 ));
2054 }
2055 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2056 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2057 })?;
2058 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2059 return Err(NodeServerError::Registry(
2060 "H3B fixture provider must be an exact regular file".to_owned(),
2061 ));
2062 }
2063 let program = provider_program.into_os_string().into_string().map_err(|_| {
2064 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2065 })?;
2066 let fixed_args = fixed_args.into_iter().map(|argument| {
2067 argument.into_string().map_err(|_| NodeServerError::Registry(
2068 "H3B fixture argument is not Unicode".to_owned(),
2069 ))
2070 }).collect::<Result<Vec<_>, _>>()?;
2071 let argument_bytes = fixed_args.iter()
2072 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2073 if program.contains('\0')
2074 || fixed_args.iter().any(|argument| argument.contains('\0'))
2075 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2076 {
2077 return Err(NodeServerError::Registry(
2078 "H3B fixture provider or arguments are invalid".to_owned(),
2079 ));
2080 }
2081 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2082 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2083 })?.clone();
2084 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2085 .ok_or_else(|| NodeServerError::Registry(
2086 "H3B fixture provider basename is unavailable".to_owned(),
2087 ))?.to_owned();
2088 spec.detection.command = process_name.clone();
2089 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2090 name: process_name,
2091 }];
2092 spec.launch.program = program;
2093 spec.launch.fixed_args = fixed_args;
2094 Ok(spec)
2095 }
2096
2097 #[cfg(feature = "fixture")]
2098 pub fn new_harness_mcp_proxy_fixture(
2099 config: NodeServerConfig,
2100 provider_program: PathBuf,
2101 fixed_args: Vec<OsString>,
2102 harness_mcp_program: PathBuf,
2103 ) -> Result<Self, NodeServerError> {
2104 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2105 let agent_id = AgentId::new("codex")
2106 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2107 let mut spec =
2108 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2109 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2110 config.fixture_raw_pty_runtime = true;
2111 Self::new_fixture_with_spec(config, spec)
2112 }
2113
2114 #[cfg(feature = "fixture")]
2138 pub fn new_harness_mcp_acp_launcher_fixture(
2139 config: NodeServerConfig,
2140 helper_program: PathBuf,
2141 provider: AgentId,
2142 program: PathBuf,
2143 args: Vec<OsString>,
2144 ) -> Result<Self, NodeServerError> {
2145 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2146 return Err(NodeServerError::Registry(
2147 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2148 ));
2149 }
2150 let config = config.with_harness_mcp_helper(helper_program)?;
2151 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2152 let launch = spec.launch.clone();
2153 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2154 NodeServerError::Registry(
2155 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2156 .to_owned(),
2157 )
2158 })?;
2159 acp.launch_override = Some(launch);
2167 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2168 Self::new_fixture_with_spec(config, spec)
2169 }
2170
2171 #[cfg(feature = "fixture")]
2172 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2173 let mut spec = gate4agent_testkit::interactive_agent_spec();
2174 let claude_id = AgentId::new("claude")
2175 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176 let claude = builtin_registry()
2177 .get(&claude_id)
2178 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2179 spec.id = claude_id;
2180 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2181 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2182 name: "claude".to_owned(),
2183 }];
2184 spec.capabilities.transports.pty_adapter = claude
2185 .capabilities
2186 .transports
2187 .pty_adapter
2188 .clone();
2189 spec.capabilities.adapters.resume = claude
2190 .capabilities
2191 .adapters
2192 .resume
2193 .clone();
2194 let script = spec
2195 .launch
2196 .fixed_args
2197 .pop()
2198 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2199 spec.launch.fixed_args.push(format!(
2200 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2201 ));
2202 Self::new_fixture_with_spec(config, spec)
2203 }
2204
2205 #[cfg(feature = "fixture")]
2206 pub fn new_cmd_cwd_fixture(
2207 config: NodeServerConfig,
2208 command: String,
2209 ) -> Result<Self, NodeServerError> {
2210 let mut spec = gate4agent_testkit::interactive_agent_spec();
2211 spec.id = AgentId::new("claude")
2212 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2214 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2215 spec.launch.program = command;
2216 spec.launch.fixed_args.clear();
2217 Self::new_fixture_with_spec(config, spec)
2218 }
2219
2220 #[cfg(feature = "fixture")]
2221 fn new_fixture_with_spec(
2222 config: NodeServerConfig,
2223 spec: gate4agent_types::AgentSpec,
2224 ) -> Result<Self, NodeServerError> {
2225 let catalog = AgentRegistry::new([spec])
2226 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2227 Self::new_with_registry(config, catalog)
2228 }
2229
2230 fn new_with_registry(
2231 config: NodeServerConfig,
2232 catalog: AgentRegistry,
2233 ) -> Result<Self, NodeServerError> {
2234 for profile in config.spawn_profiles.iter() {
2235 if catalog.get(&profile.provider).is_none() {
2236 return Err(NodeServerError::Registry(
2237 "spawn profile references an unavailable provider".to_owned(),
2238 ));
2239 }
2240 }
2241 let input_settle_timeout_ms = catalog
2242 .iter()
2243 .map(|spec| spec.readiness.timeout_ms)
2244 .max()
2245 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2246 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2247 .max(MUTATION_SETTLE_TIMEOUT_MS);
2248 let (provider_contracts, provider_adapter_contracts) =
2249 provider_contract_manifest(&catalog)?;
2250 let compatibility_support = node_compatibility_support_for_manifest(
2251 &provider_contracts,
2252 &provider_adapter_contracts,
2253 )?;
2254 validate_node_negotiated_handshake_capacity(&compatibility_support)
2255 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2256 let enabled_providers = provider_contracts
2257 .iter()
2258 .map(|contract| contract.provider.clone())
2259 .collect();
2260 #[cfg(feature = "fixture")]
2261 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2262 #[cfg(not(feature = "fixture"))]
2263 let fixture_raw_pty_runtime = false;
2264 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2265 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2266 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2267 })).expect("fixture provider catalog remains bounded");
2268 (None, statuses)
2269 } else {
2270 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2271 let statuses = monitor.collect();
2272 (Some(monitor), statuses)
2273 };
2274 let state_path_lock =
2275 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2276 .map_err(|error| {
2277 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2278 })?;
2279 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2280 Some(state_path) => {
2281 let delivery_root = delivery_store_root_for_state_path(state_path)
2282 .ok_or_else(|| {
2283 NodeServerError::DeliveryStore(
2284 "delivery store root could not be derived from state path".to_owned(),
2285 )
2286 })?;
2287 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2288 let detail = match &error {
2289 DeliveryStoreError::Storage(io_error) => {
2290 format!("{error}: {io_error}")
2291 }
2292 _ => error.to_string(),
2293 };
2294 NodeServerError::DeliveryStore(detail)
2295 })?;
2296 (Some(store), bundles)
2297 }
2298 None => (None, Vec::new()),
2299 };
2300 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2301 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2302 })?;
2303 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2304 Some(state_path) => {
2305 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2306 .ok_or(NodeServerError::ContextPackStore)?;
2307 let (store, packs) = ContextPackStore::open(context_pack_root)
2308 .map_err(|_| NodeServerError::ContextPackStore)?;
2309 (Some(store), packs)
2310 }
2311 None => (None, Vec::new()),
2312 };
2313 let mut durable_context_catalog = ContextPackCatalog::default();
2314 for pack in durable_context_packs {
2315 durable_context_catalog
2316 .insert(pack)
2317 .map_err(|_| NodeServerError::ContextPackStore)?;
2318 }
2319 let session_environment_materializer = config
2320 .session_environment
2321 .as_ref()
2322 .map(|environment| {
2323 SessionEnvironmentMaterializer::new(
2324 environment.root.clone(),
2325 Arc::clone(&environment.resolver),
2326 )
2327 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2328 })
2329 .transpose()?;
2330 #[cfg(unix)]
2333 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2334 let native_session_catalog = config.history.clone().map(|history| {
2335 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2336 });
2337 let (handle, runtime) = match config.history.clone() {
2338 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2339 None => NativeRuntime::new(catalog, config.runtime),
2340 };
2341 let native_launch_profile_control = runtime.native_launch_profile_control();
2342 let harness_mcp_registry = config
2343 .harness_mcp_helper
2344 .clone()
2345 .map(HarnessMcpProxyRegistry::new);
2346 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2347 let incarnation_id = random_incarnation_id()
2348 .map_err(NodeServerError::IncarnationIdentity)?;
2349 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2350 .map_err(durable_state_load_error)?;
2351 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2352 let managed_worktree_tombstones =
2353 std::mem::take(&mut loaded.managed_worktree_tombstones);
2354 let materializations = std::mem::take(&mut loaded.materializations);
2355 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2356 let (workspaces, records, persistence_warning) =
2357 merge_durable_state(&config.workspaces, loaded)?;
2358 let mut shared = NodeShared::new_with_incarnation(
2359 handle,
2360 config.access_token.clone(),
2361 config.node_id.clone(),
2362 incarnation_id,
2363 workspaces,
2364 enabled_providers,
2365 provider_runtime_statuses,
2366 provider_runtime_monitor,
2367 provider_contracts,
2368 provider_adapter_contracts,
2369 config.spawn_profiles.clone(),
2370 Some(native_launch_profile_control),
2371 native_session_catalog,
2372 session_environment_materializer,
2373 config.state_path.clone(),
2374 records,
2375 managed_worktrees,
2376 managed_worktree_tombstones,
2377 materializations,
2378 persistence_warning,
2379 input_settle_timeout_ms,
2380 );
2381 shared.harness_mcp_registry = harness_mcp_registry.clone();
2382 shared.session_record_retention = config.session_record_retention;
2383 shared.network_allowlist_catalog =
2384 RwLock::new(config.network_allowlist_catalog.clone());
2385 shared.bundle_catalog = RwLock::new(delivered_catalog);
2386 shared.delivery_store = Mutex::new(delivery_store);
2387 shared.context_catalog = RwLock::new(durable_context_catalog);
2388 shared.context_pack_store = Mutex::new(context_pack_store);
2389 shared.managed_spawn_replays = Mutex::new(
2390 managed_spawn_replays
2391 .into_iter()
2392 .map(|record| (record.idempotency_key.clone(), record))
2393 .collect(),
2394 );
2395 let shared = Arc::new(shared);
2396 if let Some(registry) = harness_mcp_registry {
2397 let weak = Arc::downgrade(&shared);
2398 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2399 Some(shared) => shared.publish_transient(event),
2400 None => false,
2401 }));
2402 }
2403 if shared.persistence_error().is_none() {
2404 shared.persist_state().map_err(|error| {
2405 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2406 })?;
2407 }
2408 Ok(Self {
2409 config,
2410 runtime,
2411 shared,
2412 events,
2413 state_path_lock,
2414 })
2415 }
2416
2417 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2418 NodeShutdownHandle {
2419 shared: Arc::clone(&self.shared),
2420 }
2421 }
2422
2423 #[cfg(feature = "fixture")]
2424 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2425 &self,
2426 ) -> SpawnManagedWorktreeV2FailureProbe {
2427 self.shared
2428 .fixture_spawn_managed_worktree_v2_failure_probe
2429 .clone()
2430 }
2431
2432 pub fn install_environment_profile(
2437 &mut self,
2438 profile: NodeEnvironmentProfile,
2439 ) -> Result<(), NodeServerError> {
2440 if !self.shared.enabled_providers.contains(profile.provider()) {
2441 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2442 profile.provider().clone(),
2443 ));
2444 }
2445 let (binding, native_profiles, materialization) = profile.into_parts();
2446 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2447 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2448 }
2449 let mut profiles = self
2450 .shared
2451 .environment_profiles
2452 .write()
2453 .unwrap_or_else(|poisoned| poisoned.into_inner());
2454 if profiles.contains_key(&binding.id) {
2455 return Err(NodeServerError::DuplicateEnvironmentProfile(
2456 binding.id.clone(),
2457 ));
2458 }
2459 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2460 return Err(NodeServerError::EnvironmentProfileCapacity {
2461 max: MAX_NODE_ENVIRONMENT_PROFILES,
2462 });
2463 }
2464 for native_id in binding.native_profile_ids() {
2465 if profiles.values().any(|current| {
2466 current.native_profile_ids().any(|current_id| current_id == native_id)
2467 }) {
2468 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2469 native_id.to_string(),
2470 ));
2471 }
2472 }
2473
2474 let mut installed = Vec::with_capacity(native_profiles.len());
2475 for native_profile in native_profiles {
2476 let native_id = native_profile.id().clone();
2477 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2478 for installed_id in installed.into_iter().rev() {
2479 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2480 }
2481 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2482 }
2483 installed.push(native_id);
2484 }
2485 if let Some(materialization) = materialization {
2486 self.shared
2487 .environment_materialization_profiles
2488 .write()
2489 .unwrap_or_else(|poisoned| poisoned.into_inner())
2490 .insert(binding.id.clone(), materialization);
2491 }
2492 profiles.insert(binding.id.clone(), binding);
2493 Ok(())
2494 }
2495
2496 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2498 if self.shared.session_environment_materializer.is_none() {
2499 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2500 }
2501 let mut catalog = self
2502 .shared
2503 .bundle_catalog
2504 .write()
2505 .unwrap_or_else(|poisoned| poisoned.into_inner());
2506 catalog
2507 .insert_idempotent(bundle)
2508 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2509 Ok(())
2510 }
2511
2512 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2513 let shutdown = self.shutdown_handle();
2514 let run = self.run();
2515 tokio::pin!(run);
2516 tokio::select! {
2517 result = &mut run => result,
2518 signal = wait_for_ctrl_signal() => {
2519 signal?;
2520 let shutdown_result = shutdown.request_shutdown().await;
2521 let run_result = run.await;
2522 shutdown_result.and(run_result)
2523 }
2524 }
2525 }
2526
2527 pub async fn run(self) -> Result<(), NodeServerError> {
2528 let NodeServer {
2529 config,
2530 mut runtime,
2531 shared,
2532 events,
2533 state_path_lock: _state_path_lock,
2534 } = self;
2535 shared.reconcile_materializations();
2536 shared.reconcile_managed_worktrees().await;
2537 shared.retire_unavailable_session_records().await;
2538 shared.reconcile_context_pack_exports().await;
2539 {
2540 let restore = Arc::clone(&shared);
2541 tokio::spawn(async move {
2542 sleep(Duration::from_secs(3)).await;
2543 restore.restore_journalled_sessions().await;
2544 loop {
2545 restore.refresh_session_journal();
2546 if restore.shutdown.load(Ordering::Acquire) { break; }
2547 sleep(Duration::from_secs(10)).await;
2548 }
2549 });
2550 }
2551 let endpoint = config.endpoint.clone();
2555 let call_home = config.call_home;
2556 let call_home_identity = config.call_home_identity.clone();
2557 let api_listen = config.api_listen;
2558 let bridge_listen = config.bridge_listen;
2559 let bridge_underlay = config.bridge_underlay;
2560 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2561 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2562 let accept_shared = Arc::clone(&shared);
2563 let api_shared = Arc::clone(&shared);
2564 let bridge_shared = Arc::clone(&shared);
2565 let shutdown_shared = Arc::clone(&shared);
2566 let shutdown_timeout = Duration::from_millis(
2567 config.runtime.provider_shutdown_timeout_ms.max(1),
2568 );
2569 let result = {
2570 let runtime_loop = drive_runtime_until_shutdown(
2571 &mut runtime,
2572 events,
2573 Arc::clone(&shared),
2574 shutdown_timeout,
2575 );
2576 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2577 let api_loop = http_api::run(api_listen, api_shared);
2578 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2579 tokio::pin!(runtime_loop);
2580 tokio::pin!(accept_loop);
2581 tokio::pin!(api_loop);
2582 tokio::pin!(bridge_loop);
2583 tokio::select! {
2584 runtime_result = &mut runtime_loop => {
2585 let shutdown_result = shutdown_shared
2586 .begin_shutdown()
2587 .await
2588 .map_err(NodeServerError::ShutdownDispatch);
2589 let (accept_result, api_result, bridge_result) =
2590 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2591 match runtime_result {
2592 Err(error) => Err(error),
2593 Ok(()) => shutdown_result
2594 .and(accept_result)
2595 .and(api_result.map_err(NodeServerError::HttpApi))
2596 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2597 }
2598 }
2599 accept_result = &mut accept_loop => {
2600 let shutdown_result = shutdown_shared
2601 .begin_shutdown()
2602 .await
2603 .map_err(NodeServerError::ShutdownDispatch);
2604 let (runtime_result, api_result, bridge_result) =
2605 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2606 match accept_result {
2607 Err(error) => Err(error),
2608 Ok(()) => shutdown_result
2609 .and(runtime_result)
2610 .and(api_result.map_err(NodeServerError::HttpApi))
2611 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2612 }
2613 }
2614 api_result = &mut api_loop => {
2615 let shutdown_result = shutdown_shared
2616 .begin_shutdown()
2617 .await
2618 .map_err(NodeServerError::ShutdownDispatch);
2619 let (runtime_result, accept_result, bridge_result) =
2620 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2621 match api_result {
2622 Err(error) => Err(NodeServerError::HttpApi(error)),
2623 Ok(()) => shutdown_result
2624 .and(runtime_result)
2625 .and(accept_result)
2626 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2627 }
2628 }
2629 bridge_result = &mut bridge_loop => {
2630 let shutdown_result = shutdown_shared
2631 .begin_shutdown()
2632 .await
2633 .map_err(NodeServerError::ShutdownDispatch);
2634 let (runtime_result, accept_result, api_result) =
2635 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2636 match bridge_result {
2637 Err(error) => Err(NodeServerError::BridgeApi(error)),
2638 Ok(()) => shutdown_result
2639 .and(runtime_result)
2640 .and(accept_result)
2641 .and(api_result.map_err(NodeServerError::HttpApi)),
2642 }
2643 }
2644 }
2645 };
2646 if let Ok(mut guard) = shared.delivery_store.lock() {
2653 *guard = None;
2654 }
2655 if let Ok(mut guard) = shared.context_pack_store.lock() {
2656 *guard = None;
2657 }
2658 result
2659 }
2660}
2661
2662async fn drive_runtime_until_shutdown(
2663 runtime: &mut NativeRuntime,
2664 events: EventSubscription,
2665 shared: Arc<NodeShared>,
2666 shutdown_timeout: Duration,
2667) -> Result<(), NodeServerError> {
2668 let _ = shared
2672 .shell_efficiency_profile
2673 .set(runtime.shell_efficiency_profile());
2674 let mut shutdown_started = None;
2675 let mut last_retention_sweep = Instant::now();
2676 let mut last_provisional_reap = Instant::now();
2677 loop {
2678 let runtime_tick_start = Instant::now();
2685 let tick_result = runtime.tick().await;
2686 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2687
2688 let event_drain_start = Instant::now();
2689 for outcome in &tick_result.command_outcomes {
2697 if let Err(error) = &outcome.result {
2698 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2699 tracing::warn!(
2700 command_id = outcome.command_id.0,
2701 cause = %error,
2702 "control command rejected"
2703 );
2704 shared.record_rejected_command(
2705 outcome.command_id,
2706 error.to_string(),
2707 error.is_unsupported_transport(),
2708 );
2709 }
2710 }
2711 let mut events_drained: u64 = 0;
2712 while let Ok(event) = events.try_recv() {
2713 events_drained += 1;
2714 let clean_exit = matches!(
2715 event.event,
2716 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2717 );
2718 let instance_id = event.instance_id;
2719 let generation = event.generation;
2720 if clean_exit {
2732 if let Some((record_id, session)) =
2733 shared.managed_record_export_target_for_instance(instance_id, generation)
2734 {
2735 let export_shared = Arc::clone(&shared);
2736 tokio::spawn(async move {
2737 export_shared
2738 .reconcile_context_pack_export_for_record(&record_id, session)
2739 .await;
2740 });
2741 }
2742 }
2743 shared.publish_control(event);
2744 }
2745 let event_drain_elapsed = event_drain_start.elapsed();
2746
2747 let publish_terminal_frames_start = Instant::now();
2748 shared.publish_terminal_frames();
2749 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2750
2751 shared
2757 .native_session_gauge
2758 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2759 *shared
2760 .runtime_tick_profile
2761 .lock()
2762 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2763
2764 let remainder_start = Instant::now();
2765 if shared.shutdown.load(Ordering::Acquire) {
2766 let started = *shutdown_started.get_or_insert_with(Instant::now);
2767 let snapshot = shared.handle.snapshot();
2768 let sessions_settled = snapshot.sessions.iter().all(|session| {
2769 !matches!(
2770 session.status,
2771 gate4agent_types::SessionStatus::Starting
2772 | gate4agent_types::SessionStatus::Running
2773 | gate4agent_types::SessionStatus::Stopping
2774 )
2775 });
2776 if runtime.active_native_sessions() == 0 && sessions_settled {
2777 return Ok(());
2778 }
2779 if started.elapsed() >= shutdown_timeout {
2780 return Err(NodeServerError::ShutdownTimedOut {
2781 active_native_sessions: runtime.active_native_sessions(),
2782 });
2783 }
2784 }
2785 let remainder_elapsed = remainder_start.elapsed();
2786
2787 {
2788 let mut profile = shared
2789 .drive_loop_profile
2790 .lock()
2791 .unwrap_or_else(|poisoned| poisoned.into_inner());
2792 profile.record_runtime_tick(runtime_tick_elapsed);
2793 profile.record_event_drain(event_drain_elapsed);
2794 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2795 profile.record_remainder(remainder_elapsed);
2796 profile.note_iteration(Instant::now());
2797 }
2798
2799 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2811 if tick_result.observations_applied == 0 && events_drained == 0 {
2812 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2813 }
2814
2815 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2820 shared.retire_unavailable_session_records().await;
2821 last_retention_sweep = Instant::now();
2822 }
2823 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2825 for address in shared.take_due_provisional_spawns() {
2826 shared.reap_provisional_spawn(address).await;
2827 }
2828 last_provisional_reap = Instant::now();
2829 }
2830
2831 let hot = shared
2834 .pty_input_marks
2835 .lock()
2836 .unwrap_or_else(|poisoned| poisoned.into_inner())
2837 .values()
2838 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2839 if hot {
2840 sleep(Duration::from_millis(1)).await;
2841 } else {
2842 tokio::select! {
2843 _ = sleep(Duration::from_millis(10)) => {}
2844 _ = shared.pty_input_wake.notified() => {}
2845 }
2846 }
2847 }
2848}
2849
2850#[derive(Clone)]
2851pub struct NodeShutdownHandle {
2852 shared: Arc<NodeShared>,
2853}
2854
2855impl NodeShutdownHandle {
2856 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2857 self.shared
2858 .begin_shutdown()
2859 .await
2860 .map_err(NodeServerError::ShutdownDispatch)
2861 }
2862}
2863
2864async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2865 #[cfg(windows)]
2866 {
2867 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2868 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2869 return tokio::select! {
2870 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2871 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872 };
2873 }
2874 #[cfg(unix)]
2875 {
2876 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2877 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2878 tokio::select! {
2879 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2880 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2881 }
2882 }
2883}
2884
2885fn shell_host_spec() -> AgentSpec {
2899 #[cfg(windows)]
2903 let (detect_command, program, fixed_args, process_name) = {
2904 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2905 r"C:\Windows\System32\cmd.exe".to_owned()
2906 });
2907 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2908 };
2909 #[cfg(not(windows))]
2910 let (detect_command, program, fixed_args, process_name) = (
2911 "bash".to_owned(),
2912 "/bin/bash".to_owned(),
2913 vec!["-i".to_owned()],
2914 "bash".to_owned(),
2915 );
2916 AgentSpec {
2917 id: AgentId::new("shell").expect("shell agent id"),
2918 revision: "hatchery:shell-pty-1".to_owned(),
2919 display_name: "Host shell".to_owned(),
2920 detection: DetectionSpec {
2921 command: detect_command,
2922 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2923 required_commands: Vec::new(),
2924 unsupported_platforms: Vec::new(),
2925 },
2926 launch: LaunchSpec {
2927 program,
2928 fixed_args,
2929 },
2930 expected_processes: vec![ProcessMatcher::Exact {
2931 name: process_name,
2932 }],
2933 prompt: PromptSpec {
2934 initial: InitialPromptMode::None,
2935 native_draft: None,
2936 },
2937 readiness: AgentReadinessSpec::default(),
2938 capabilities: AgentCapabilities {
2939 agent_commands: None,
2940 transports: AgentTransportCapabilities {
2941 pty: true,
2942 pty_adapter: None,
2943 pipe: None,
2944 acp: None,
2945 },
2946 adapters: AgentAdapterCapabilities::default(),
2947 },
2948 verification: SpecVerification::Gate4AgentVerified,
2949 }
2950}
2951
2952fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2953 let mut specs = vec![shell_host_spec()];
2954 specs.extend(
2955 builtin_registry()
2956 .iter()
2957 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2958 .cloned(),
2959 );
2960 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2961}
2962
2963fn provider_contract_manifest(
2964 catalog: &AgentRegistry,
2965) -> Result<
2966 (
2967 Vec<ProviderContractSupport>,
2968 Vec<ProviderAdapterContractSupport>,
2969 ),
2970 NodeServerError,
2971> {
2972 let mut provider_contracts = Vec::new();
2973 let mut provider_adapter_contracts = Vec::new();
2974 for spec in catalog.iter() {
2975 let provider = spec.id.clone();
2976 let revision = ProviderContractRevision::new(spec.revision.clone())
2977 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2978 provider_contracts.push(ProviderContractSupport {
2979 provider: provider.clone(),
2980 revision,
2981 });
2982 for (family, binding) in declared_adapter_bindings(spec)? {
2983 let revision = AdapterContractRevision::new(binding.revision.clone())
2984 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2985 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2986 provider: provider.clone(),
2987 family,
2988 adapter_id: binding.id.clone(),
2989 revision,
2990 });
2991 }
2992 }
2993 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2994 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2995 Ok((provider_contracts, provider_adapter_contracts))
2996}
2997
2998fn declared_adapter_bindings(
2999 spec: &AgentSpec,
3000) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3001 let mut bindings = Vec::new();
3002 push_declared_binding(
3003 &mut bindings,
3004 AdapterFamily::PtySemantic,
3005 spec.capabilities.transports.pty_adapter.as_ref(),
3006 )?;
3007 push_declared_binding(
3008 &mut bindings,
3009 AdapterFamily::Pipe,
3010 spec.capabilities.adapters.pty_sidecar.as_ref(),
3011 )?;
3012 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3013 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3014 gate4agent_types::PipeProtocol::SemanticNdjson
3015 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3016 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3017 });
3018 push_declared_binding(
3019 &mut bindings,
3020 AdapterFamily::Pipe,
3021 pipe_transport
3022 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3023 .map(|transport| &transport.adapter),
3024 )?;
3025 push_declared_binding(
3026 &mut bindings,
3027 AdapterFamily::Acp,
3028 spec.capabilities
3029 .transports
3030 .acp
3031 .as_ref()
3032 .map(|transport| &transport.adapter),
3033 )?;
3034 push_declared_binding(
3035 &mut bindings,
3036 AdapterFamily::Hook,
3037 spec.capabilities.adapters.hook.as_ref(),
3038 )?;
3039 push_declared_binding(
3040 &mut bindings,
3041 AdapterFamily::ManagedHook,
3042 spec.capabilities.adapters.managed_hook.as_ref(),
3043 )?;
3044 push_declared_binding(
3045 &mut bindings,
3046 AdapterFamily::OneShot,
3047 pipe_transport
3048 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3049 .map(|transport| &transport.adapter),
3050 )?;
3051 push_declared_binding(
3052 &mut bindings,
3053 AdapterFamily::OneShot,
3054 spec.capabilities.adapters.one_shot.as_ref(),
3055 )?;
3056 push_declared_binding(
3057 &mut bindings,
3058 AdapterFamily::History,
3059 spec.capabilities.adapters.history.as_ref(),
3060 )?;
3061 push_declared_binding(
3062 &mut bindings,
3063 AdapterFamily::Resume,
3064 spec.capabilities.adapters.resume.as_ref(),
3065 )?;
3066 push_declared_binding(
3067 &mut bindings,
3068 AdapterFamily::SessionOptions,
3069 spec.capabilities.adapters.session_options.as_ref(),
3070 )?;
3071 push_declared_binding(
3072 &mut bindings,
3073 AdapterFamily::CapabilityProbe,
3074 spec.capabilities.adapters.capability_probe.as_ref(),
3075 )?;
3076 Ok(bindings)
3077}
3078
3079fn push_declared_binding<'a>(
3080 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3081 family: AdapterFamily,
3082 binding: Option<&'a AdapterBinding>,
3083) -> Result<(), NodeServerError> {
3084 let Some(binding) = binding else {
3085 return Ok(());
3086 };
3087 if let Some((_, existing)) = bindings
3088 .iter()
3089 .find(|(existing_family, _)| *existing_family == family)
3090 {
3091 if *existing == binding {
3092 return Ok(());
3093 }
3094 return Err(NodeServerError::ProviderContractManifest(format!(
3095 "provider declares conflicting {family:?} adapter bindings"
3096 )));
3097 }
3098 bindings.push((family, binding));
3099 Ok(())
3100}
3101
3102fn merge_durable_state(
3103 configured: &[WorkspaceConfig],
3104 mut loaded: LoadedNodeState,
3105) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3106 let mut workspaces = configured
3107 .iter()
3108 .cloned()
3109 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3110 .collect::<BTreeMap<_, _>>();
3111 let mut roots = workspaces
3112 .values()
3113 .map(|workspace| {
3114 (
3115 platform::root_identity(&workspace.canonical_root),
3116 workspace.workspace_id.clone(),
3117 )
3118 })
3119 .collect::<BTreeMap<_, _>>();
3120 let mut warning = loaded
3121 .warning
3122 .take()
3123 .map(|message| session_registry::sanitized_persistence_summary(&message));
3124
3125 for (workspace_id, canonical_root) in loaded.workspaces {
3126 if let Some(existing) = workspaces.get(&workspace_id) {
3127 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3128 return Err(NodeServerError::DurableState(io::Error::new(
3129 io::ErrorKind::InvalidData,
3130 DURABLE_STATE_CONFLICT_ERROR,
3131 )));
3132 }
3133 continue;
3134 }
3135 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3136 return Err(NodeServerError::DurableState(io::Error::new(
3137 io::ErrorKind::InvalidData,
3138 DURABLE_STATE_CONFLICT_ERROR,
3139 )));
3140 }
3141 if !Path::new(&canonical_root).is_dir() {
3142 warning.get_or_insert_with(|| {
3143 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3144 });
3145 continue;
3146 }
3147 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3148 NodeServerError::DurableState(io::Error::new(
3149 io::ErrorKind::InvalidData,
3150 DURABLE_STATE_CONFLICT_ERROR,
3151 ))
3152 })?;
3153 roots.insert(
3154 platform::root_identity(&workspace.canonical_root),
3155 workspace.workspace_id.clone(),
3156 );
3157 workspaces.insert(workspace_id, workspace);
3158 }
3159
3160 for record in &mut loaded.records {
3161 match workspaces.get(&record.workspace_id) {
3162 Some(workspace)
3163 if platform::roots_equal(
3164 &workspace.canonical_root,
3165 windows_path_text(&record.canonical_root),
3166 ) => {}
3167 Some(_) => {
3168 return Err(NodeServerError::DurableState(io::Error::new(
3169 io::ErrorKind::InvalidData,
3170 DURABLE_STATE_CONFLICT_ERROR,
3171 )));
3172 }
3173 None => {
3174 record.state = ManagedSessionState::Unavailable;
3175 record.active_session = None;
3176 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3177 }
3178 }
3179 }
3180 Ok((workspaces.into_values().collect(), loaded.records, warning))
3181}
3182
3183struct RejectedCommandReason {
3186 message: String,
3187 unsupported_transport: bool,
3194 expires_at: Instant,
3195}
3196
3197struct NodeShared {
3198 handle: Gate4AgentHandle,
3199 access_token: String,
3200 node_id: NodeId,
3201 incarnation_id: NodeIncarnationId,
3202 started_at_unix_ms: u64,
3203 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3204 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3205 managed_worktree_profiles:
3206 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3207 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3208 enabled_providers: Vec<AgentId>,
3209 provider_runtime_statuses: ProviderRuntimeStatuses,
3210 provider_runtime_status_updates:
3211 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3212 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3213 provider_contracts: Vec<ProviderContractSupport>,
3214 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3215 spawn_profiles: SpawnProfileRegistry,
3216 environment_profiles:
3217 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3218 environment_materialization_profiles:
3219 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3220 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3228 #[cfg(feature = "dig2-station-probe")]
3232 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3233 bundle_catalog: RwLock<BundleCatalog>,
3234 delivery_store: Mutex<Option<DeliveryStore>>,
3235 context_pack_store: Mutex<Option<ContextPackStore>>,
3236 context_catalog: RwLock<ContextPackCatalog>,
3237 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3238 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3239 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3240 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3241 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3243 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3245 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3247 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3249 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3251 #[cfg(unix)]
3254 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3255 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3256 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3257 managed_spawn_replays:
3258 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3259 controller: Mutex<Option<ControllerLease>>,
3260 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3263 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3265 history: Mutex<NodeEventHistory>,
3266 event_tx: broadcast::Sender<NodeEventEnvelope>,
3267 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3268 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3276 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3285 terminal_frame_watermarks:
3286 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3287 pty_input_wake: tokio::sync::Notify,
3289 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3292 next_connection_id: AtomicU64,
3293 next_instance_id: AtomicU64,
3294 next_command_id: AtomicU64,
3295 shutdown: AtomicBool,
3296 shutdown_notify: Notify,
3297 preauth_slots: Arc<Semaphore>,
3298 authenticated_slots: Arc<Semaphore>,
3299 inspection_slots: Arc<Semaphore>,
3300 mutation_gate: AsyncMutex<()>,
3301 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3302 session_records: Mutex<SessionRecords>,
3303 state_path: Option<PathBuf>,
3304 persistence_error: RwLock<Option<String>>,
3305 state_transaction: Mutex<()>,
3306 input_settle_timeout_ms: u64,
3307 drive_loop_profile: Mutex<DriveLoopProfiler>,
3311 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3317 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3329 native_session_gauge: AtomicUsize,
3336 connection_loop_iterations: AtomicU64,
3347 connection_events_sent: AtomicU64,
3348 rejected_commands_total: AtomicU64,
3357 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3377 drive_loop_iterations_total: AtomicU64,
3378 drive_loop_iterations_idle: AtomicU64,
3379 session_record_retention: SessionRecordRetentionConfig,
3383 retired_records_total: AtomicU64,
3389 #[cfg(feature = "fixture")]
3390 fixture_semantic_hook_policy: bool,
3391 #[cfg(feature = "fixture")]
3392 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3393}
3394
3395impl Drop for NodeShared {
3396 fn drop(&mut self) {
3397 SessionLifecycleHook::on_shutdown(self);
3398 }
3399}
3400
3401#[derive(Clone, Debug, Eq, PartialEq)]
3402struct SessionBinding {
3403 workspace_id: WorkspaceId,
3404 generation: SessionGeneration,
3405 runtime_policy: ProviderRuntimePolicy,
3406 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3407 record_id: Option<SessionRecordId>,
3408 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3409 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3410 bundle: Option<ResolvedBundleReceipt>,
3411 context: Option<ResolvedContextPackReceipt>,
3412 materialization_id: Option<MaterializationId>,
3413}
3414
3415#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3416enum SpawnRecordPolicy {
3417 ProviderIdentityOnly,
3418 Always,
3419}
3420
3421struct NativeEnvironmentSelectionGuard {
3422 control: Option<NativeLaunchProfileControl>,
3423 instance_id: AgentInstanceId,
3424}
3425
3426struct NativeInstanceOverlayGuard {
3427 control: Option<NativeLaunchProfileControl>,
3428 instance_id: AgentInstanceId,
3429}
3430
3431struct NativeHarnessMcpOverlayGuard {
3432 control: NativeLaunchProfileControl,
3433 instance_id: AgentInstanceId,
3434}
3435
3436impl NativeHarnessMcpOverlayGuard {
3437 fn retain(self) { std::mem::forget(self); }
3438}
3439
3440impl Drop for NativeHarnessMcpOverlayGuard {
3441 fn drop(&mut self) {
3442 self.control
3443 .clear_native_mcp_server_launch_overlay(self.instance_id);
3444 }
3445}
3446
3447impl NativeInstanceOverlayGuard {
3448 fn retain(mut self) {
3449 self.control = None;
3450 }
3451}
3452
3453impl Drop for NativeInstanceOverlayGuard {
3454 fn drop(&mut self) {
3455 if let Some(control) = self.control.as_ref() {
3456 control.clear_native_instance_launch_overlay(self.instance_id);
3457 }
3458 }
3459}
3460
3461enum PreparedNativeLaunchOverlay {
3462 Environment(NativeLaunchEnvironmentOverlay),
3463 Instance(NativeInstanceLaunchOverlay),
3464}
3465
3466impl NativeEnvironmentSelectionGuard {
3467 fn retain(mut self) {
3468 self.control = None;
3469 }
3470}
3471
3472impl Drop for NativeEnvironmentSelectionGuard {
3473 fn drop(&mut self) {
3474 if let Some(control) = self.control.as_ref() {
3475 control.clear_native_launch_profile_selection(self.instance_id);
3476 }
3477 }
3478}
3479
3480struct SessionMaterializationGuard<'a> {
3481 shared: &'a NodeShared,
3482 id: Option<MaterializationId>,
3483 provider_home: Option<PathBuf>,
3486}
3487
3488impl SessionMaterializationGuard<'_> {
3489 fn id(&self) -> Option<&MaterializationId> {
3490 self.id.as_ref()
3491 }
3492
3493 fn provider_home(&self) -> Option<&Path> {
3494 self.provider_home.as_deref()
3495 }
3496
3497 fn retain(mut self) {
3498 self.id = None;
3499 }
3500}
3501
3502impl Drop for SessionMaterializationGuard<'_> {
3503 fn drop(&mut self) {
3504 if let Some(id) = self.id.take() {
3505 let _ = self.shared.cleanup_materialization(&id);
3506 }
3507 }
3508}
3509
3510
3511trait SessionLifecycleHook {
3514 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3516 fn on_session_ended(&self, address: &SessionAddress);
3518 fn on_shutdown(&self);
3520 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3522 fn cleanup_session_materialization(
3524 &self,
3525 address: &SessionAddress,
3526 binding: &SessionBinding,
3527 ) -> Result<(), NodeFailure>;
3528}
3529
3530impl SessionLifecycleHook for NodeShared {
3531 fn on_session_ended(&self, address: &SessionAddress) {
3532 self.retire_session_grok_leader(address.session.instance_id);
3533 if !self.shutdown.load(Ordering::Acquire) {
3534 if let Some(file) = self.session_resume_journal() {
3535 crate::pty_resume::remove(&file, address.session.instance_id.0);
3536 }
3537 }
3538 if let Some(accounts) = self.m4a_accounts.as_ref() {
3539 let instance = address.session.instance_id;
3540 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3541 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3542 accounts.remove_session_file(nick);
3543 }
3544 }
3545 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3546 registry.revoke_session(address);
3547 }
3548 if let Some(control) = self.native_launch_profile_control.as_ref() {
3549 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3550 }
3551 }
3552
3553 fn on_shutdown(&self) {
3554 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3555 .into_values()
3556 .collect();
3557 for socket in leaders {
3558 crate::grok_leader::retire(&socket);
3559 }
3560 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3561 let instances = registry.shutdown();
3562 if let Some(control) = self.native_launch_profile_control.as_ref() {
3563 for instance_id in instances {
3564 control.clear_native_mcp_server_launch_overlay(instance_id);
3565 }
3566 }
3567 }
3568 }
3569
3570 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3571 if let Some(control) = self.native_launch_profile_control.as_ref() {
3572 control.clear_native_mcp_server_launch_overlay(instance_id);
3573 }
3574 }
3575
3576 fn cleanup_session_materialization(
3577 &self,
3578 address: &SessionAddress,
3579 binding: &SessionBinding,
3580 ) -> Result<(), NodeFailure> {
3581 self.cleanup_session_owned_materialization(address, binding)
3582 }
3583
3584 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3585 #[cfg(feature = "dig2-station-probe")]
3588 {
3589 crate::dig2_station_lease::release_lease_for_holder(
3590 &self.browser_station_leases,
3591 address.session.instance_id,
3592 );
3593 }
3594 if let Some(control) = self.native_launch_profile_control.as_ref() {
3595 control.clear_native_mcp_server_launch_overlay(
3596 address.session.instance_id,
3597 );
3598 }
3599 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3600 registry.revoke_session(address);
3601 }
3602 if removed
3603 .is_some_and(|binding| {
3604 binding.environment_profile.is_some()
3605 || binding.bundle.is_some()
3606 || binding.context.is_some()
3607 })
3608 {
3609 if let Some(control) = self.native_launch_profile_control.as_ref() {
3610 control.clear_native_launch_profile_selection(
3611 address.session.instance_id,
3612 );
3613 control.clear_native_instance_launch_overlay(
3614 address.session.instance_id,
3615 );
3616 }
3617 }
3618 }
3619}
3620
3621struct SpawnPrepareRequest<'r> {
3622 instance_id: AgentInstanceId,
3623 address: &'r SessionAddress,
3624 provider: &'r AgentId,
3625 mode: SessionMode,
3626 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3627 bundle: Option<&'r ResolvedBundleReceipt>,
3628 context: Option<&'r ResolvedContextPackReceipt>,
3629 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3630 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3631 approval_level: ApprovalLevel,
3632 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3633 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3634}
3635
3636struct PreparedSpawn<'a> {
3640 instance_overlay: Option<NativeInstanceOverlayGuard>,
3641 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3642 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3643 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3644 #[cfg(feature = "dig2-station-probe")]
3645 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3646 materialization_id: Option<MaterializationId>,
3647}
3648
3649impl PreparedSpawn<'_> {
3650 fn retain_session_guards(&mut self) {
3652 if let Some(selection) = self.environment_selection.take() {
3653 selection.retain();
3654 }
3655 if let Some(overlay) = self.instance_overlay.take() {
3656 overlay.retain();
3657 }
3658 if let Some(guard) = self.materialization_guard.take() {
3659 guard.retain();
3660 }
3661 }
3662
3663 fn retain_launch_guards(&mut self) {
3665 if let Some(overlay) = self.harness_mcp_overlay.take() {
3666 overlay.retain();
3667 }
3668 #[cfg(feature = "dig2-station-probe")]
3669 if let Some(guard) = self.browser_station_lease_guard.take() {
3670 guard.retain();
3671 }
3672 }
3673}
3674
3675trait SpawnPreparer {
3679 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3680 fn select_environment(
3682 &self,
3683 instance_id: AgentInstanceId,
3684 provider: &AgentId,
3685 mode: SessionMode,
3686 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3687 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3688 fn install_launch_overlay(
3690 &self,
3691 instance_id: AgentInstanceId,
3692 overlay: PreparedNativeLaunchOverlay,
3693 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3694}
3695
3696impl NodeShared {
3697 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3700 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3701 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3702 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3703 if let Some(file) = self.session_resume_journal() {
3704 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3705 }
3706 key
3707 }
3708
3709 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3710 let accounts = self.m4a_accounts.as_ref()?;
3711 let nick = self.m4a_nick_of(instance_id)?;
3712 match accounts.session_workdir(root, provider.as_str(), &nick) {
3713 Ok(dir) => dir,
3714 Err(error) => {
3715 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3716 None
3717 }
3718 }
3719 }
3720
3721 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3722 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3723 }
3724
3725 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3728 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3729 for record in records.records.values_mut() {
3730 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3731 record.display_name = nick.to_owned();
3732 }
3733 }
3734 }
3735
3736 fn m4a_session_overlay(
3739 &self,
3740 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3741 m4a_provider: &str,
3742 provider: &AgentId,
3743 instance_id: AgentInstanceId,
3744 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3745 session_key: &str,
3746 mode: SessionMode,
3747 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3748 let session_key = session_key.to_owned();
3749 if mode == SessionMode::Acp {
3750 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3751 }
3752 Ok( match accounts.provision(m4a_provider, &session_key) {
3753 Ok(account) => {
3754 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3755 let mut identity = account.env_mutations();
3756 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3757 let merged = match environment_overlay {
3758 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3759 .map(PreparedNativeLaunchOverlay::Instance),
3760 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3761 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3762 }
3763 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3764 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3765 }
3766 }
3767 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3768 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3769 let mut merged = merged;
3770 match accounts.write_session_file(&account) {
3771 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3772 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3773 Ok(args) if !args.is_empty() => {
3774 merged = match merged {
3775 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3776 .append_extra_args(args)
3777 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3778 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3779 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3780 .into_instance_with_extra_args(args)
3781 .map(PreparedNativeLaunchOverlay::Instance)
3782 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3783 };
3784 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3785 }
3786 Ok(_) => {}
3787 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3788 },
3789 }
3790 self.m4a_live
3791 .lock()
3792 .unwrap_or_else(|p| p.into_inner())
3793 .insert(account.nick.clone(), (instance_id, provider.clone()));
3794 self.name_record_after_nick(instance_id, &account.nick);
3795 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3796 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3798 Some(merged)
3799 }
3800 Err(error) => {
3801 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3802 environment_overlay
3803 }
3804 })
3805 }
3806}
3807
3808impl NodeShared {
3809 fn m4a_acp_mcp_overlay(
3812 &self,
3813 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3814 m4a_provider: &str,
3815 provider: &AgentId,
3816 instance_id: AgentInstanceId,
3817 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3818 session_key: &str,
3819 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3820 let Some(control) = self.native_launch_profile_control.as_ref() else {
3821 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3822 return Ok(environment_overlay);
3823 };
3824 let account = match accounts.provision(m4a_provider, session_key) {
3825 Ok(a) => a,
3826 Err(error) => {
3827 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3828 return Ok(environment_overlay);
3829 }
3830 };
3831 let session_file = match accounts.write_session_file(&account) {
3832 Ok(p) => p,
3833 Err(error) => {
3834 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3835 return Ok(environment_overlay);
3836 }
3837 };
3838 let env_entries: Vec<(OsString, OsString)> = account
3839 .env_mutations()
3840 .into_iter()
3841 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3842 .filter_map(|m| m.value.map(|v| (m.key, v)))
3843 .collect();
3844 let args: Vec<OsString> = vec![
3845 "--session-file".into(),
3846 session_file.into_os_string(),
3847 "mcp".into(),
3848 "--provider".into(),
3849 provider.as_str().into(),
3850 ];
3851 let overlay = NativeMcpServerLaunchOverlay::new(
3852 provider.clone(),
3853 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3854 accounts.bin().as_os_str().to_os_string(),
3855 args,
3856 env_entries,
3857 Vec::new(),
3858 );
3859 match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3860 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3861 Err(error) => {
3862 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3863 return Ok(environment_overlay);
3864 }
3865 }
3866 self.m4a_live
3867 .lock()
3868 .unwrap_or_else(|p| p.into_inner())
3869 .insert(account.nick.clone(), (instance_id, provider.clone()));
3870 self.name_record_after_nick(instance_id, &account.nick);
3871 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3872 Ok(environment_overlay)
3873 }
3874}
3875
3876impl SpawnPreparer for NodeShared {
3877 fn select_environment(
3878 &self,
3879 instance_id: AgentInstanceId,
3880 provider: &AgentId,
3881 mode: SessionMode,
3882 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3883 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3884 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3885 }
3886
3887 fn install_launch_overlay(
3888 &self,
3889 instance_id: AgentInstanceId,
3890 overlay: PreparedNativeLaunchOverlay,
3891 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3892 self.install_prepared_launch_overlay(instance_id, overlay)
3893 }
3894
3895 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3896 let SpawnPrepareRequest {
3897 instance_id,
3898 address,
3899 provider,
3900 mode,
3901 environment_profile,
3902 bundle,
3903 context,
3904 managed_authority,
3905 harness_mcp,
3906 approval_level,
3907 network_allowlist,
3908 browser_profile_id,
3909 } = request;
3910 #[cfg(feature = "dig2-station-probe")]
3915 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3916 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3917 match acquire_lease(
3918 &self.browser_station_leases,
3919 profile_id,
3920 instance_id,
3921 unix_time_ms(),
3922 ) {
3923 Ok(guard) => Some(guard),
3924 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3925 return Err(failure(
3926 NodeFailureCode::BrowserStationProfileBusy,
3927 "dig2browser station profile is already leased by another session",
3928 ));
3929 }
3930 Err(BrowserStationLeaseError::CapacityExceeded) => {
3931 return Err(failure(
3932 NodeFailureCode::BrowserStationProfileBusy,
3933 "dig2browser station profile lease capacity exhausted",
3934 ));
3935 }
3936 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3937 return Err(failure(
3938 NodeFailureCode::BrowserStationProfileBusy,
3939 "session already holds a dig2browser station profile lease",
3940 ));
3941 }
3942 }
3943 } else {
3944 None
3945 };
3946 #[cfg(not(feature = "dig2-station-probe"))]
3947 let _ = browser_profile_id;
3948 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3949 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3958 {
3959 return Err(failure(
3960 NodeFailureCode::BindingMismatch,
3961 "harness MCP reservation does not match the exact PTY/ACP provider",
3962 ));
3963 }
3964 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3965 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3966 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3967 })?;
3968 let launch = prepared.launch();
3973 let mut env_entries = vec![
3974 (
3975 OsString::from(launch.endpoint_env.as_str()),
3976 prepared.endpoint().as_os_str().to_os_string(),
3977 ),
3978 (
3979 OsString::from(launch.token_env.as_str()),
3980 prepared.token().expose().into(),
3981 ),
3982 (
3983 OsString::from(launch.program_env.as_str()),
3984 prepared.helper_program().as_os_str().to_os_string(),
3985 ),
3986 ];
3987 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3988 env_entries.push(trace_entry);
3989 }
3990 if let Some(door) = crate::door::door_listen_addr() {
3991 env_entries.push((
3992 OsString::from("HATCHERY_NODE_DOOR"),
3993 OsString::from(door.to_string()),
3994 ));
3995 let session_key = format!(
3999 "pty-{}-{}-{}",
4000 self.incarnation_id,
4001 address.workspace_id.as_str(),
4002 address.session.instance_id.0
4003 );
4004 env_entries.push((
4005 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4006 OsString::from(session_key),
4007 ));
4008 }
4009 let overlay = NativeMcpServerLaunchOverlay::new(
4010 provider.clone(),
4011 launch.server_name.as_str(),
4012 prepared.helper_program().as_os_str().to_os_string(),
4013 launch.args.iter().map(OsString::from).collect(),
4014 env_entries,
4015 launch.scrub_env.iter().map(OsString::from).collect(),
4016 ).map_err(|_| failure(
4017 NodeFailureCode::HarnessMcpUnavailable,
4018 "harness MCP launch overlay is unavailable",
4019 ))?;
4020 control
4021 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4022 .map_err(|_| failure(
4023 NodeFailureCode::HarnessMcpUnavailable,
4024 "harness MCP launch overlay could not be installed",
4025 ))?;
4026 tracing::info!(
4032 node_id = %self.node_id,
4033 instance_id = ?instance_id,
4034 provider = %provider,
4035 mode = ?mode,
4036 "harness MCP overlay installed for spawn",
4037 );
4038 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4039 } else {
4040 None
4041 };
4042 let prepared_materialization = self.prepare_session_materialization(
4043 &address,
4044 &provider,
4045 mode,
4046 environment_profile,
4047 bundle,
4048 context,
4049 managed_authority.cloned(),
4050 )?;
4051 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4052 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4053 None => (None, None),
4054 };
4055 let materialization_id = materialization_guard
4056 .as_ref()
4057 .and_then(SessionMaterializationGuard::id)
4058 .cloned();
4059 let mut environment_selection = SpawnPreparer::select_environment(self,
4060 instance_id,
4061 &provider,
4062 mode,
4063 environment_profile,
4064 )?;
4065 let provider_home = materialization_guard
4068 .as_ref()
4069 .and_then(SessionMaterializationGuard::provider_home);
4070 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4071 &provider,
4072 mode,
4073 approval_level,
4074 network_allowlist,
4075 provider_home,
4076 )?;
4077 let mut network_overlay_args = network_overlay_args;
4078 {
4080 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4081 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4082 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4083 homes.extend(std::env::split_paths(&extra));
4084 }
4085 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4086 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4087 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4088 }
4089 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4090 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4091 }
4092 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4094 if let Some(file) = self.session_resume_journal() {
4095 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4096 instance_id: instance_id.0,
4097 provider: provider.as_str().to_owned(),
4098 workspace_id: address.workspace_id.as_str().to_owned(),
4099 provider_session_id: String::new(),
4100 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4101 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4102 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4103 cwd: String::new(),
4104 acp: mode == SessionMode::Acp,
4105 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4106 cols: 0,
4107 rows: 0,
4108 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4109 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4110 });
4111 if let Some(dir) = file.parent() {
4112 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4113 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4114 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4115 provider: Some(provider.as_str().to_owned()),
4116 ..crate::session_meta::Meta::default()
4117 });
4118 crate::session_meta::revive(dir, instance_id.0);
4119 }
4120 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4121 }
4122 }
4123 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4124 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4125 }
4126 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4127 (prepared, args) if args.is_empty() => prepared,
4128 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4129 overlay.append_extra_args(args).map_err(|_| {
4130 failure(
4131 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4132 "network allowlist overlay could not merge with instance launch args",
4133 )
4134 })?;
4135 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4136 }
4137 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4138 Some(PreparedNativeLaunchOverlay::Instance(
4139 overlay
4140 .into_instance_with_extra_args(args)
4141 .map_err(|_| {
4142 failure(
4143 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4144 "network allowlist overlay could not merge with environment overlay",
4145 )
4146 })?,
4147 ))
4148 }
4149 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4150 NativeInstanceLaunchOverlay::new(
4151 provider.clone(),
4152 match mode {
4153 SessionMode::Pty => TransportKind::Pty,
4154 SessionMode::Acp => TransportKind::Acp,
4155 SessionMode::Inline => TransportKind::Pipe,
4156 },
4157 Vec::new(),
4158 args,
4159 )
4160 .map_err(|_| {
4161 failure(
4162 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4163 "network allowlist overlay is invalid for this transport",
4164 )
4165 })?,
4166 )),
4167 };
4168 #[cfg(unix)]
4171 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4173 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4174 });
4175 #[cfg(unix)]
4176 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4179 #[cfg(unix)]
4180 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4181 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4182 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4183 let uid = unsafe { libc_geteuid() };
4184 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4185 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4186 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4187 Some(vec![
4188 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4189 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4190 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4191 ])
4192 }
4193 _ => None,
4194 };
4195 #[cfg(not(unix))]
4196 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4197 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4198 (SessionMode::Pty, _, _, Some(identity)) => {
4199 let merged = match environment_overlay {
4200 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4201 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4202 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4203 }
4204 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4205 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4206 }
4207 }
4208 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4209 Some(merged)
4210 }
4211 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4212 let session_key = self.m4a_session_key(instance_id);
4213 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4214 }
4215 (SessionMode::Pty, Some(_), None, _) => {
4216 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4217 environment_overlay
4218 }
4219 _ => environment_overlay,
4220 };
4221 let mut instance_overlay = environment_overlay
4222 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4223 .transpose()?
4224 .flatten();
4225 Ok(PreparedSpawn {
4226 instance_overlay,
4227 environment_selection,
4228 materialization_guard,
4229 harness_mcp_overlay,
4230 #[cfg(feature = "dig2-station-probe")]
4231 browser_station_lease_guard,
4232 materialization_id,
4233 })
4234 }
4235}
4236
4237struct SessionRecords {
4238 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4239}
4240
4241struct SpawnIdempotencyCache {
4242 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4243}
4244
4245struct SpawnIdempotencyEntry {
4246 value: SpawnIdempotencyValue,
4247 expires_at: Instant,
4248}
4249
4250enum SpawnIdempotencyValue {
4251 Standard {
4252 spec: SpawnSpec,
4253 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4254 },
4255 Managed {
4256 request: ManagedWorktreeSpawnRequest,
4257 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4258 },
4259 HarnessMcp,
4260}
4261
4262enum DurableManagedSpawnReplayDecision {
4263 Reserved,
4264 PendingLinked,
4265 Committed(ManagedWorktreeSpawnReceipt),
4266}
4267
4268impl SpawnIdempotencyValue {
4269 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4270 match self {
4271 Self::Standard { result, .. } => result
4272 .as_ref()
4273 .ok()
4274 .and_then(|receipt| receipt.context.as_ref()),
4275 Self::Managed { result, .. } => result
4276 .as_ref()
4277 .ok()
4278 .and_then(|receipt| receipt.spawn.context.as_ref()),
4279 Self::HarnessMcp => None,
4280 }
4281 .is_some_and(|receipt| &receipt.id == context_id)
4282 }
4283}
4284
4285#[derive(Clone, Copy)]
4286struct ControllerLease {
4287 connection_id: u64,
4288 expires_at: Instant,
4289}
4290
4291struct NodeEventHistory {
4292 last_sequence: u64,
4293 replay_floor_sequence: u64,
4294 events: VecDeque<NodeEventEnvelope>,
4295 record_providers: BTreeMap<SessionRecordId, AgentId>,
4296 removed_record_providers: BTreeMap<u64, AgentId>,
4297}
4298
4299impl NodeEventHistory {
4300 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4301 Self {
4302 last_sequence: 0,
4303 replay_floor_sequence: 1,
4304 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4305 record_providers,
4306 removed_record_providers: BTreeMap::new(),
4307 }
4308 }
4309}
4310
4311impl NodeShared {
4312 fn session_resume_journal(&self) -> Option<PathBuf> {
4316 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4317 }
4318
4319 async fn restore_journalled_sessions(&self) {
4322 let Some(file) = self.session_resume_journal() else { return };
4323 if !crate::pty_resume::enabled() { return; }
4324 for e in crate::pty_resume::load(&file) {
4325 crate::pty_resume::remove(&file, e.instance_id);
4326 if !e.session_key.is_empty() {
4328 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4329 }
4330 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4331 if e.acp {
4332 let record_id = {
4333 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4334 records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4335 };
4336 let cur = self.next_instance_id.load(Ordering::Acquire);
4337 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4338 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4339 let resumed = match record_id {
4340 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4341 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4342 };
4343 let r = match resumed {
4345 Ok(a) => Ok(a),
4346 Err(err) => {
4347 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4348 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4349 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4350 }
4351 };
4352 match r {
4353 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4354 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4355 }
4356 continue;
4357 }
4358 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4359 let cur = self.next_instance_id.load(Ordering::Acquire);
4361 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4362 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4363 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4364 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4365 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4366 }
4367 continue;
4368 }
4369 let folder = std::path::Path::new(&e.cwd);
4370 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4371 let name = match (in_session_dir, folder.file_name()) {
4372 (true, Some(n)) => n.to_string_lossy().into_owned(),
4373 _ => format!("{} #{}", e.provider, e.instance_id),
4374 };
4375 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4376 let record = match self.index_provider_session(ws, provider, identity, name) {
4377 Ok(r) => r,
4378 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4379 };
4380 let cur = self.next_instance_id.load(Ordering::Acquire);
4381 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4382 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4383 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4384 match r {
4385 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4386 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4387 }
4388 }
4389 }
4390
4391 fn refresh_session_journal(&self) {
4392 let Some(file) = self.session_resume_journal() else { return };
4393 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4394 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4395 crate::pty_resume::refresh(&file, &homes);
4396 let ids: Vec<(u64, String)> = {
4397 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4398 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4399 };
4400 for (inst, sid) in ids {
4401 crate::pty_resume::set_session_id(&file, inst, &sid);
4402 }
4403 }
4404
4405 fn session_grok_leader_socket(
4406 &self,
4407 provider: &AgentId,
4408 mode: SessionMode,
4409 instance_id: AgentInstanceId,
4410 ) -> Option<std::path::PathBuf> {
4411 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4412 return None;
4413 }
4414 let root = self
4415 .m4a_accounts
4416 .as_ref()
4417 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4418 .unwrap_or_else(std::env::temp_dir);
4419 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4420 if let Some(previous) = self
4421 .grok_leaders
4422 .lock()
4423 .unwrap_or_else(|p| p.into_inner())
4424 .insert(instance_id, socket.clone())
4425 {
4426 crate::grok_leader::retire(&previous);
4427 }
4428 Some(socket)
4429 }
4430
4431 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4433 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4434 if let Some(socket) = socket {
4435 crate::grok_leader::retire(&socket);
4436 }
4437 }
4438
4439 fn new_with_incarnation(
4440 handle: Gate4AgentHandle,
4441 access_token: String,
4442 node_id: NodeId,
4443 incarnation_id: NodeIncarnationId,
4444 workspaces: Vec<WorkspaceConfig>,
4445 enabled_providers: Vec<AgentId>,
4446 provider_runtime_statuses: ProviderRuntimeStatuses,
4447 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4448 provider_contracts: Vec<ProviderContractSupport>,
4449 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4450 spawn_profiles: SpawnProfileRegistry,
4451 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4452 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4453 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4454 state_path: Option<PathBuf>,
4455 records: Vec<ManagedSessionRecord>,
4456 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4457 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4458 materializations: Vec<MaterializationOwnershipRecord>,
4459 persistence_warning: Option<String>,
4460 input_settle_timeout_ms: u64,
4461 ) -> Self {
4462 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4463 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4464 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4465 let (harness_mcp_proxy_event_tx, _) =
4466 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4467 let record_providers = records
4468 .iter()
4469 .map(|record| (record.record_id.clone(), record.provider.clone()))
4470 .collect();
4471 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4472 .iter()
4473 .map(|workspace| {
4474 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4475 })
4476 .collect();
4477 for lease in &managed_worktrees {
4478 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4479 }
4480 let managed_worktree_profiles = workspaces.iter()
4481 .map(|workspace| (
4482 workspace.workspace_id.clone(),
4483 workspace.managed_worktree_profiles.clone(),
4484 ))
4485 .collect();
4486 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4487 managed_worktrees,
4488 managed_worktree_tombstones,
4489 ).expect("durable managed worktree registry was validated while loading");
4490 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4491 managed_worktrees.reattach_record_holders(
4492 &managed_worktree_record_holders(&records),
4493 unix_time_ms(),
4494 );
4495 Self {
4496 handle,
4497 access_token,
4498 node_id,
4499 incarnation_id,
4500 started_at_unix_ms: std::time::SystemTime::now()
4501 .duration_since(std::time::UNIX_EPOCH)
4502 .unwrap_or_default()
4503 .as_millis()
4504 .min(u64::MAX as u128) as u64,
4505 workspaces: RwLock::new(
4506 workspaces
4507 .into_iter()
4508 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4509 .collect(),
4510 ),
4511 worktree_service_modes,
4512 managed_worktree_profiles,
4513 managed_worktrees: Mutex::new(managed_worktrees),
4514 enabled_providers,
4515 provider_runtime_statuses,
4516 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4517 provider_runtime_monitor,
4518 provider_contracts,
4519 provider_adapter_contracts,
4520 spawn_profiles,
4521 environment_profiles: RwLock::new(BTreeMap::new()),
4522 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4523 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4524 #[cfg(feature = "dig2-station-probe")]
4525 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4526 bundle_catalog: RwLock::new(BundleCatalog::default()),
4527 delivery_store: Mutex::new(None),
4528 context_pack_store: Mutex::new(None),
4529 context_catalog: RwLock::new(ContextPackCatalog::default()),
4530 native_launch_profile_control,
4531 harness_mcp_registry: None,
4532 native_session_catalog,
4533 session_environment_materializer,
4534 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4535 m4a_live: Mutex::new(BTreeMap::new()),
4536 m4a_keys: Mutex::new(BTreeMap::new()),
4537 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4538 grok_leaders: Mutex::new(BTreeMap::new()),
4539 #[cfg(unix)]
4540 #[cfg(unix)]
4541 signer_proxy: std::sync::OnceLock::new(),
4542 materializations: Mutex::new(
4543 materializations
4544 .into_iter()
4545 .map(|record| (record.id().clone(), record))
4546 .collect(),
4547 ),
4548 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4549 entries: BTreeMap::new(),
4550 }),
4551 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4552 controller: Mutex::new(None),
4553 provisional_spawns: Mutex::new(BTreeMap::new()),
4554 pending_acp_options: Mutex::new(BTreeMap::new()),
4555 history: Mutex::new(NodeEventHistory::new(record_providers)),
4556 event_tx,
4557 terminal_event_tx,
4558 agent_stream_event_tx,
4559 harness_mcp_proxy_event_tx,
4560 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4561 pty_input_wake: tokio::sync::Notify::new(),
4562 pty_input_marks: Mutex::new(BTreeMap::new()),
4563 next_connection_id: AtomicU64::new(1),
4564 next_instance_id: AtomicU64::new(1),
4565 next_command_id: AtomicU64::new(1),
4566 shutdown: AtomicBool::new(false),
4567 shutdown_notify: Notify::new(),
4568 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4569 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4570 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4571 mutation_gate: AsyncMutex::new(()),
4572 session_bindings: Mutex::new(BTreeMap::new()),
4573 session_records: Mutex::new(SessionRecords {
4574 records: records
4575 .into_iter()
4576 .map(|record| (record.record_id.clone(), record))
4577 .collect(),
4578 }),
4579 state_path,
4580 persistence_error: RwLock::new(
4581 persistence_warning
4582 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4583 ),
4584 state_transaction: Mutex::new(()),
4585 input_settle_timeout_ms,
4586 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4587 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4588 shell_efficiency_profile: OnceLock::new(),
4589 native_session_gauge: AtomicUsize::new(0),
4590 connection_loop_iterations: AtomicU64::new(0),
4591 connection_events_sent: AtomicU64::new(0),
4592 rejected_commands_total: AtomicU64::new(0),
4593 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4594 drive_loop_iterations_total: AtomicU64::new(0),
4595 drive_loop_iterations_idle: AtomicU64::new(0),
4596 session_record_retention: SessionRecordRetentionConfig::default(),
4597 retired_records_total: AtomicU64::new(0),
4598 #[cfg(feature = "fixture")]
4599 fixture_semantic_hook_policy: false,
4600 #[cfg(feature = "fixture")]
4601 fixture_spawn_managed_worktree_v2_failure_probe:
4602 SpawnManagedWorktreeV2FailureProbe::default(),
4603 }
4604 }
4605
4606 #[cfg(test)]
4607 fn new(
4608 handle: Gate4AgentHandle,
4609 access_token: String,
4610 node_id: NodeId,
4611 workspaces: Vec<WorkspaceConfig>,
4612 enabled_providers: Vec<AgentId>,
4613 ) -> Self {
4614 Self::new_with_incarnation(
4615 handle,
4616 access_token,
4617 node_id,
4618 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4619 workspaces,
4620 enabled_providers,
4621 ProviderRuntimeStatuses::default(),
4622 None,
4623 Vec::new(),
4624 Vec::new(),
4625 SpawnProfileRegistry::default(),
4626 None,
4627 None,
4628 None,
4629 None,
4630 Vec::new(),
4631 Vec::new(),
4632 Vec::new(),
4633 Vec::new(),
4634 None,
4635 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4636 )
4637 }
4638
4639 fn persistence_error(&self) -> Option<String> {
4640 self.persistence_error
4641 .read()
4642 .unwrap_or_else(|poisoned| poisoned.into_inner())
4643 .clone()
4644 }
4645
4646 async fn admit_provider_runtime(
4647 &self,
4648 provider: &AgentId,
4649 requirement: ProviderRuntimeRequirement,
4650 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4651 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4652 let pipe_transport = monitor.supports_pipe_transport(provider);
4653 let acp_transport = monitor.supports_acp_transport(provider);
4654 let provider = provider.clone();
4655 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4656 let (status, admission) = timeout(
4657 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4658 refresh,
4659 )
4660 .await
4661 .map_err(|_| failure(
4662 NodeFailureCode::BackendBusy,
4663 "provider runtime probe exceeded its bounded deadline",
4664 ))?
4665 .map_err(|_| failure(
4666 NodeFailureCode::BackendOperationFailed,
4667 "provider runtime probe task failed",
4668 ))?;
4669 if let Some(status) = status {
4670 self
4671 .provider_runtime_status_updates
4672 .write()
4673 .unwrap_or_else(|poisoned| poisoned.into_inner())
4674 .insert(status.provider().clone(), status);
4675 }
4676 admission.and_then(|policy| {
4677 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4678 })
4679 } else {
4680 crate::provider_runtime::admit_status(
4686 &self.provider_runtime_statuses,
4687 provider,
4688 false,
4689 false,
4690 requirement,
4691 )
4692 };
4693 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4694 admission.map_err(|error| match error {
4695 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4696 NodeFailureCode::BackendOperationFailed,
4697 "provider launcher is unavailable",
4698 ),
4699 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4700 NodeFailureCode::UnsupportedCapability,
4701 "provider semantic capability is not verified",
4702 ),
4703 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4704 NodeFailureCode::BackendBusy,
4705 "provider runtime probe is already in progress",
4706 ),
4707 })
4708 }
4709
4710 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4716 match self.provider_runtime_monitor.as_deref() {
4717 Some(monitor) => monitor.supports_pipe_transport(provider),
4718 None => false,
4719 }
4720 }
4721
4722 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4728 match self.provider_runtime_monitor.as_deref() {
4729 Some(monitor) => monitor.supports_acp_transport(provider),
4730 None => false,
4731 }
4732 }
4733
4734 fn admit_declared_hook_semantics(
4750 &self,
4751 provider: &AgentId,
4752 policy: ProviderRuntimePolicy,
4753 ) -> ProviderRuntimePolicy {
4754 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4755 return policy;
4756 }
4757 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4758 contract.provider == *provider && contract.family == AdapterFamily::Hook
4759 });
4760 if !declares_hook {
4761 return policy;
4762 }
4763 ProviderRuntimePolicy::new(
4764 policy.raw_pty_lifecycle,
4765 policy.semantic_readiness,
4766 policy.structured_prompt,
4767 policy.provider_session_identity,
4768 policy.semantic_resume,
4769 true,
4770 )
4771 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4772 }
4773
4774 fn resolve_network_allowlist_overlay_args(
4789 &self,
4790 provider: &AgentId,
4791 mode: SessionMode,
4792 approval_level: ApprovalLevel,
4793 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4794 provider_home: Option<&Path>,
4795 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4796 let Some(allowlist_id) = network_allowlist else {
4797 return Ok(Vec::new());
4798 };
4799 let catalog = self
4800 .network_allowlist_catalog
4801 .read()
4802 .unwrap_or_else(|poisoned| poisoned.into_inner());
4803 let Some(entry) = catalog.get(allowlist_id) else {
4804 return Err(failure(
4805 NodeFailureCode::UnknownNetworkAllowlist,
4806 "spawn network allowlist is unavailable on this node",
4807 ));
4808 };
4809
4810 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4812 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4813 |message| {
4814 failure(
4815 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4816 &message,
4817 )
4818 },
4819 )?;
4820 if !matches!(mode, SessionMode::Pty) {
4821 return Err(failure(
4822 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4823 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4824 ));
4825 }
4826 let Some(home) = provider_home else {
4827 return Err(failure(
4828 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4829 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4830 ));
4831 };
4832 let domains =
4833 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4834 if domains.is_empty() {
4835 return Err(failure(
4836 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4837 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4838 ));
4839 }
4840 let settings_path = home.join(
4841 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4842 );
4843 let json =
4844 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4845 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4846 |_| {
4847 failure(
4848 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4849 "Claude station network settings sidecar could not be written under ProviderHome",
4850 )
4851 },
4852 )?;
4853 let overlay =
4854 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4855 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4856 }
4857
4858 let Some(native) = entry.provider_native.as_ref() else {
4859 return Ok(Vec::new());
4860 };
4861 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4862 provider.as_str(),
4863 approval_level,
4864 native,
4865 )
4866 .map_err(|message| {
4867 failure(
4868 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4869 &message,
4870 )
4871 })?;
4872 if overlay.is_empty() {
4873 return Ok(Vec::new());
4874 }
4875 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4876 return Err(failure(
4877 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4878 "Codex network_access -c overlay requires PTY or ACP transport",
4879 ));
4880 }
4881 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4882 }
4883
4884 fn resolve_codex_network_access_overlay_args(
4886 &self,
4887 provider: &AgentId,
4888 mode: SessionMode,
4889 approval_level: ApprovalLevel,
4890 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4891 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4892 self.resolve_network_allowlist_overlay_args(
4893 provider,
4894 mode,
4895 approval_level,
4896 network_allowlist,
4897 None,
4898 )
4899 }
4900
4901 fn resolve_environment_profile(
4902 &self,
4903 resolved: &ResolvedSpawnSpec,
4904 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4905 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4911 let catalog = self
4912 .network_allowlist_catalog
4913 .read()
4914 .unwrap_or_else(|poisoned| poisoned.into_inner());
4915 let Some(entry) = catalog.get(allowlist_id) else {
4916 return Err(failure(
4917 NodeFailureCode::UnknownNetworkAllowlist,
4918 "spawn network allowlist is unavailable on this node",
4919 ));
4920 };
4921 if let Some(native) = entry.provider_native.as_ref() {
4924 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4925 resolved.provider.as_str(),
4926 resolved.approval_level,
4927 native,
4928 ) {
4929 return Err(failure(
4930 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4931 &message,
4932 ));
4933 }
4934 }
4935 }
4936 #[cfg(feature = "dig2-station-probe")]
4945 if resolved.browser_profile_id.is_some() {
4946 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4947 match probe_station_reachable() {
4948 Ok(()) => {}
4949 Err(StationProbeError::UnsupportedPlatform)
4950 | Err(StationProbeError::InvalidPipeSuffix) => {
4951 return Err(failure(
4952 NodeFailureCode::BrowserStationProbeUnavailable,
4953 "dig2browser station probe unavailable on this platform",
4954 ));
4955 }
4956 Err(StationProbeError::Unreachable) => {
4957 return Err(failure(
4958 NodeFailureCode::BrowserStationUnreachable,
4959 "dig2browser station named-pipe is unreachable",
4960 ));
4961 }
4962 }
4963 }
4964 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4965 return Ok(None);
4966 };
4967 let profiles = self
4968 .environment_profiles
4969 .read()
4970 .unwrap_or_else(|poisoned| poisoned.into_inner());
4971 let binding = profiles.get(profile_id).ok_or_else(|| {
4972 failure(
4973 NodeFailureCode::UnknownEnvironmentProfile,
4974 "spawn environment profile is unavailable on this node",
4975 )
4976 })?;
4977 if binding.provider != resolved.provider
4978 || binding.native_profile_id(resolved.mode).is_none()
4979 {
4980 return Err(failure(
4981 NodeFailureCode::EnvironmentProfileBindingMismatch,
4982 "spawn environment profile does not match the provider and mode",
4983 ));
4984 }
4985 Ok(Some(ResolvedEnvironmentProfileReceipt {
4989 profile_id: binding.id.clone(),
4990 profile_revision: binding.revision.clone(),
4991 network_allowlist: resolved.network_allowlist.clone(),
4992 browser_profile_id: resolved.browser_profile_id.clone(),
4993 }))
4994 }
4995
4996 fn resolve_bundle(
4997 &self,
4998 resolved: &ResolvedSpawnSpec,
4999 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5000 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5001 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5002 return Ok(None);
5003 };
5004 let catalog = self.bundle_catalog
5005 .read()
5006 .unwrap_or_else(|poisoned| poisoned.into_inner());
5007 let bundle = catalog.get(bundle_id).ok_or_else(|| {
5008 failure(
5009 NodeFailureCode::UnknownBundle,
5010 "spawn bundle is unavailable on this node",
5011 )
5012 })?;
5013 self.bundle_layout(
5014 &resolved.provider,
5015 resolved.mode,
5016 environment_profile,
5017 bundle,
5018 )?;
5019 Ok(Some(bundle.receipt()))
5020 }
5021
5022 fn begin_delivery_stage(
5023 &self,
5024 manifest: DeliveryBundleManifestV2,
5025 ) -> Result<NodeResponse, NodeFailure> {
5026 let mut store = self
5027 .delivery_store
5028 .lock()
5029 .unwrap_or_else(|poisoned| poisoned.into_inner());
5030 let store = store.as_mut().ok_or_else(|| {
5031 failure(
5032 NodeFailureCode::DeliveryStageStorageFailed,
5033 "node delivery store is unavailable",
5034 )
5035 })?;
5036 let begun = store.begin(manifest).map_err(delivery_failure)?;
5037 Ok(NodeResponse::DeliveryStageBegun {
5038 stage_id: begun.stage_id,
5039 manifest_digest: begun.manifest_digest,
5040 missing_blobs: begun.missing_blobs,
5041 })
5042 }
5043
5044 fn put_delivery_blob_chunk(
5045 &self,
5046 stage_id: DeliveryStageId,
5047 blob_digest: DeliveryBlobDigestV1,
5048 offset: u64,
5049 chunk_hex: DeliveryBlobChunkHexV1,
5050 ) -> Result<NodeResponse, NodeFailure> {
5051 let mut store = self
5052 .delivery_store
5053 .lock()
5054 .unwrap_or_else(|poisoned| poisoned.into_inner());
5055 let store = store.as_mut().ok_or_else(|| {
5056 failure(
5057 NodeFailureCode::DeliveryStageStorageFailed,
5058 "node delivery store is unavailable",
5059 )
5060 })?;
5061 let accepted = store
5062 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5063 .map_err(delivery_failure)?;
5064 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5065 stage_id: accepted.stage_id,
5066 blob_digest: accepted.blob_digest,
5067 next_offset: accepted.next_offset,
5068 })
5069 }
5070
5071 fn commit_delivery_stage(
5072 &self,
5073 stage_id: DeliveryStageId,
5074 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5075 let mut store = self
5076 .delivery_store
5077 .lock()
5078 .unwrap_or_else(|poisoned| poisoned.into_inner());
5079 let store = store.as_mut().ok_or_else(|| {
5080 failure(
5081 NodeFailureCode::DeliveryStageStorageFailed,
5082 "node delivery store is unavailable",
5083 )
5084 })?;
5085 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5086 let mut catalog = self
5087 .bundle_catalog
5088 .write()
5089 .unwrap_or_else(|poisoned| poisoned.into_inner());
5090 let mut next_catalog = catalog.clone();
5091 next_catalog
5092 .insert_idempotent(prepared.bundle().clone())
5093 .map_err(|_| {
5094 failure(
5095 NodeFailureCode::DeliveryStageConflict,
5096 "delivery bundle identity conflicts with installed content",
5097 )
5098 })?;
5099 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5100 *catalog = next_catalog;
5101 Ok(receipt)
5102 }
5103
5104 fn abort_delivery_stage(
5105 &self,
5106 stage_id: &DeliveryStageId,
5107 ) -> Result<(), NodeFailure> {
5108 let mut store = self
5109 .delivery_store
5110 .lock()
5111 .unwrap_or_else(|poisoned| poisoned.into_inner());
5112 let store = store.as_mut().ok_or_else(|| {
5113 failure(
5114 NodeFailureCode::DeliveryStageStorageFailed,
5115 "node delivery store is unavailable",
5116 )
5117 })?;
5118 store.abort(stage_id).map_err(delivery_failure)
5119 }
5120
5121 fn resolve_context(
5122 &self,
5123 resolved: &ResolvedSpawnSpec,
5124 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5125 let Some(context_id) = resolved.context_id.as_ref() else {
5126 return Ok(None);
5127 };
5128 self.context_catalog
5129 .read()
5130 .unwrap_or_else(|poisoned| poisoned.into_inner())
5131 .get(context_id)
5132 .map(|context| context.receipt().clone())
5133 .ok_or_else(|| {
5134 failure(
5135 NodeFailureCode::UnknownContextPack,
5136 "spawn context pack is unavailable on this node",
5137 )
5138 })
5139 .map(Some)
5140 }
5141
5142 fn bundle_layout(
5143 &self,
5144 provider: &AgentId,
5145 mode: SessionMode,
5146 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5147 bundle: &NodeBundle,
5148 ) -> Result<BundleProviderLayout, NodeFailure> {
5149 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5150 failure(
5151 NodeFailureCode::BundleBindingMismatch,
5152 "spawn bundle does not match the provider and mode",
5153 )
5154 })?;
5155 if layout != BundleProviderLayout::Codex {
5156 return Ok(layout);
5157 }
5158 let profile = environment_profile.and_then(|receipt| {
5159 let profiles = self
5160 .environment_profiles
5161 .read()
5162 .unwrap_or_else(|poisoned| poisoned.into_inner());
5163 let binding = profiles.get(&receipt.profile_id)?;
5164 if binding.revision != receipt.profile_revision
5165 || &binding.provider != provider
5166 || binding.native_profile_id(mode).is_none()
5167 {
5168 return None;
5169 }
5170 self.environment_materialization_profiles
5171 .read()
5172 .unwrap_or_else(|poisoned| poisoned.into_inner())
5173 .get(&receipt.profile_id)
5174 .cloned()
5175 });
5176 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5177 return Err(failure(
5178 NodeFailureCode::BundleBindingMismatch,
5179 "Codex bundle requires an exact isolated CODEX_HOME profile",
5180 ));
5181 }
5182 Ok(layout)
5183 }
5184
5185 fn select_environment_profile(
5186 &self,
5187 instance_id: AgentInstanceId,
5188 provider: &AgentId,
5189 mode: SessionMode,
5190 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5191 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5192 let Some(environment_profile) = environment_profile else {
5193 return Ok(None);
5194 };
5195 let native_profile_id = {
5196 let profiles = self
5197 .environment_profiles
5198 .read()
5199 .unwrap_or_else(|poisoned| poisoned.into_inner());
5200 let binding = profiles
5201 .get(&environment_profile.profile_id)
5202 .ok_or_else(|| {
5203 failure(
5204 NodeFailureCode::UnknownEnvironmentProfile,
5205 "session environment profile is unavailable on this node",
5206 )
5207 })?;
5208 if &binding.revision != &environment_profile.profile_revision
5209 || &binding.provider != provider
5210 {
5211 return Err(failure(
5212 NodeFailureCode::EnvironmentProfileBindingMismatch,
5213 "session environment profile revision or provider changed",
5214 ));
5215 }
5216 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5217 failure(
5218 NodeFailureCode::EnvironmentProfileBindingMismatch,
5219 "session environment profile does not support this mode",
5220 )
5221 })?
5222 };
5223 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5224 failure(
5225 NodeFailureCode::BackendOperationFailed,
5226 "native environment profile controller is unavailable",
5227 )
5228 })?;
5229 control
5230 .select_native_launch_profile(instance_id, native_profile_id)
5231 .map_err(|_| {
5232 failure(
5233 NodeFailureCode::BackendOperationFailed,
5234 "native environment profile selection failed",
5235 )
5236 })?;
5237 Ok(Some(NativeEnvironmentSelectionGuard {
5238 control: Some(control),
5239 instance_id,
5240 }))
5241 }
5242
5243 fn install_prepared_launch_overlay(
5244 &self,
5245 instance_id: AgentInstanceId,
5246 overlay: PreparedNativeLaunchOverlay,
5247 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5248 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5249 failure(
5250 NodeFailureCode::BackendOperationFailed,
5251 "native launch profile controller is unavailable",
5252 )
5253 })?;
5254 match overlay {
5255 PreparedNativeLaunchOverlay::Environment(overlay) => {
5256 control
5257 .install_native_launch_environment_overlay(instance_id, overlay)
5258 .map_err(|_| failure(
5259 NodeFailureCode::BackendOperationFailed,
5260 "session environment overlay installation failed",
5261 ))?;
5262 Ok(None)
5263 }
5264 PreparedNativeLaunchOverlay::Instance(overlay) => {
5265 control
5266 .install_native_instance_launch_overlay(instance_id, overlay)
5267 .map_err(|_| failure(
5268 NodeFailureCode::BundleBindingMismatch,
5269 "session bundle launch overlay installation failed",
5270 ))?;
5271 Ok(Some(NativeInstanceOverlayGuard {
5272 control: Some(control),
5273 instance_id,
5274 }))
5275 }
5276 }
5277 }
5278
5279 fn environment_materialization_profile(
5280 &self,
5281 environment_profile: &ResolvedEnvironmentProfileReceipt,
5282 ) -> Option<NodeSessionMaterializationProfile> {
5283 self.environment_materialization_profiles
5284 .read()
5285 .unwrap_or_else(|poisoned| poisoned.into_inner())
5286 .get(&environment_profile.profile_id)
5287 .cloned()
5288 }
5289
5290 fn materialization_profile(
5291 &self,
5292 provider: &AgentId,
5293 mode: SessionMode,
5294 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5295 bundle: Option<&ResolvedBundleReceipt>,
5296 context: Option<&ResolvedContextPackReceipt>,
5297 ) -> Result<Option<(
5298 NodeSessionMaterializationProfile,
5299 bool,
5300 Option<BundleProviderLayout>,
5301 )>, NodeFailure> {
5302 let environment = environment_profile
5303 .and_then(|receipt| self.environment_materialization_profile(receipt));
5304 let (bundle, bundle_layout) = match bundle {
5305 Some(receipt) => {
5306 let catalog = self.bundle_catalog.read()
5307 .unwrap_or_else(|poisoned| poisoned.into_inner());
5308 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5309 NodeFailureCode::UnknownBundle,
5310 "session bundle is unavailable on this node",
5311 ))?;
5312 if installed.receipt() != *receipt {
5313 return Err(failure(
5314 NodeFailureCode::BundleBindingMismatch,
5315 "session bundle revision or digest changed",
5316 ));
5317 }
5318 let layout = self.bundle_layout(
5319 provider,
5320 mode,
5321 environment_profile,
5322 installed,
5323 )?;
5324 (Some(installed.clone()), Some(layout))
5325 }
5326 None => (None, None),
5327 };
5328 let has_environment_overlay = environment.is_some() || context.is_some();
5329 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5330 (None, None, None) => None,
5331 (Some(profile), None, None) => Some(Ok(profile)),
5332 (None, Some(bundle), Some(layout)) => {
5333 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5334 }
5335 (Some(profile), Some(bundle), Some(layout)) => {
5336 Some(profile.with_bundle(bundle, layout))
5337 }
5338 _ => {
5339 return Err(failure(
5340 NodeFailureCode::BundleBindingMismatch,
5341 "session bundle provider layout is unavailable",
5342 ));
5343 }
5344 };
5345 let profile = profile
5346 .transpose()
5347 .map_err(|_| failure(
5348 NodeFailureCode::BundleMaterializationFailed,
5349 "session bundle materialization profile is invalid",
5350 ))?;
5351 let profile = match (profile, context) {
5352 (Some(profile), Some(receipt)) => {
5353 let catalog = self.context_catalog
5354 .read()
5355 .unwrap_or_else(|poisoned| poisoned.into_inner());
5356 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5357 NodeFailureCode::UnknownContextPack,
5358 "session context pack is unavailable on this node",
5359 ))?;
5360 if pack.receipt() != receipt {
5361 return Err(failure(
5362 NodeFailureCode::ContextPackMaterializationFailed,
5363 "session context pack identity changed",
5364 ));
5365 }
5366 profile.with_context(pack)
5367 }
5368 (None, Some(receipt)) => {
5369 let catalog = self.context_catalog
5370 .read()
5371 .unwrap_or_else(|poisoned| poisoned.into_inner());
5372 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5373 NodeFailureCode::UnknownContextPack,
5374 "session context pack is unavailable on this node",
5375 ))?;
5376 if pack.receipt() != receipt {
5377 return Err(failure(
5378 NodeFailureCode::ContextPackMaterializationFailed,
5379 "session context pack identity changed",
5380 ));
5381 }
5382 NodeSessionMaterializationProfile::from_context(pack)
5383 }
5384 (Some(profile), None) => Ok(profile),
5385 (None, None) => return Ok(None),
5386 }
5387 .map_err(|_| failure(
5388 NodeFailureCode::ContextPackMaterializationFailed,
5389 "session context pack materialization profile is invalid",
5390 ))?;
5391 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5392 }
5393
5394 fn prepare_session_materialization<'a>(
5395 &'a self,
5396 address: &SessionAddress,
5397 provider: &AgentId,
5398 mode: SessionMode,
5399 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5400 bundle: Option<&ResolvedBundleReceipt>,
5401 context: Option<&ResolvedContextPackReceipt>,
5402 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5403 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5404 let Some((profile, has_environment_overlay, bundle_layout)) =
5405 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5406 else {
5407 return Ok(None);
5408 };
5409 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5410 failure(
5411 NodeFailureCode::BackendOperationFailed,
5412 "session environment materializer is unavailable",
5413 )
5414 })?;
5415 let materialization_failure_code = if context.is_some() {
5416 NodeFailureCode::ContextPackMaterializationFailed
5417 } else if bundle.is_some() {
5418 NodeFailureCode::BundleMaterializationFailed
5419 } else {
5420 NodeFailureCode::BackendOperationFailed
5421 };
5422 let id = MaterializationId::new(format!(
5423 "mat-{}-{}-{}",
5424 self.incarnation_id,
5425 address.session.instance_id.0,
5426 address.session.generation.0,
5427 ))
5428 .map_err(|_| {
5429 failure(
5430 NodeFailureCode::BackendOperationFailed,
5431 "session environment materialization identity failed",
5432 )
5433 })?;
5434 let owner = MaterializationOwner::Session {
5435 incarnation_id: self.incarnation_id,
5436 instance_id: address.session.instance_id,
5437 generation: address.session.generation,
5438 };
5439 let mut ownership = materializer
5440 .begin(
5441 id.clone(),
5442 environment_profile.cloned(),
5443 bundle.cloned(),
5444 context.cloned(),
5445 owner,
5446 managed_lease_id,
5447 &profile,
5448 unix_time_ms(),
5449 )
5450 .map_err(|_| {
5451 failure(
5452 materialization_failure_code,
5453 "session materialization preparation failed",
5454 )
5455 })?;
5456 {
5457 let _transaction = self
5458 .state_transaction
5459 .lock()
5460 .unwrap_or_else(|poisoned| poisoned.into_inner());
5461 let duplicate = self
5462 .materializations
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner())
5465 .insert(id.clone(), ownership.clone());
5466 if duplicate.is_some() {
5467 return Err(failure(
5468 NodeFailureCode::BackendOperationFailed,
5469 "session environment materialization identity conflict",
5470 ));
5471 }
5472 if let Err(error) = self.persist_state_locked() {
5473 self.materializations
5474 .lock()
5475 .unwrap_or_else(|poisoned| poisoned.into_inner())
5476 .remove(&id);
5477 return Err(persistence_failure(error));
5478 }
5479 }
5480 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5481 Ok(environment) => environment,
5482 Err(_) => {
5483 let transaction = self
5484 .state_transaction
5485 .lock()
5486 .unwrap_or_else(|poisoned| poisoned.into_inner());
5487 self.materializations
5488 .lock()
5489 .unwrap_or_else(|poisoned| poisoned.into_inner())
5490 .insert(id.clone(), ownership.clone());
5491 if self.persist_state_locked().is_err() {
5492 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5493 }
5494 drop(transaction);
5495 if ownership.state() == MaterializationState::CleanupRequired {
5496 let _ = self.cleanup_materialization(&id);
5497 }
5498 return Err(failure(
5499 materialization_failure_code,
5500 "session materialization failed",
5501 ));
5502 }
5503 };
5504 {
5505 let _transaction = self
5506 .state_transaction
5507 .lock()
5508 .unwrap_or_else(|poisoned| poisoned.into_inner());
5509 self.materializations
5510 .lock()
5511 .unwrap_or_else(|poisoned| poisoned.into_inner())
5512 .insert(id.clone(), ownership.clone());
5513 if let Err(error) = self.persist_state_locked() {
5514 drop(_transaction);
5515 let _ = self.cleanup_materialization(&id);
5516 return Err(persistence_failure(error));
5517 }
5518 }
5519 let guard = SessionMaterializationGuard {
5520 shared: self,
5521 id: Some(id),
5522 provider_home: Some(ownership.provider_home().to_path_buf()),
5523 };
5524 let bundle_arguments = match bundle {
5525 Some(receipt) => {
5526 let catalog = self.bundle_catalog.read()
5527 .unwrap_or_else(|poisoned| poisoned.into_inner());
5528 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5529 NodeFailureCode::UnknownBundle,
5530 "session bundle is unavailable on this node",
5531 ))?;
5532 if installed.receipt() != *receipt {
5533 return Err(failure(
5534 NodeFailureCode::BundleBindingMismatch,
5535 "session bundle revision or digest changed",
5536 ));
5537 }
5538 let layout = bundle_layout.ok_or_else(|| failure(
5539 NodeFailureCode::BundleBindingMismatch,
5540 "materialized bundle provider layout is unavailable",
5541 ))?;
5542 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5543 failure(
5544 NodeFailureCode::BundleMaterializationFailed,
5545 "session bundle byte revalidation failed",
5546 )
5547 })?;
5548 Some(bundle_launch_arguments(
5549 layout,
5550 ownership.bundle_root(),
5551 ownership.provider_home(),
5552 ).map_err(|_| failure(
5553 NodeFailureCode::BundleBindingMismatch,
5554 "session bundle launch binding is invalid",
5555 ))?)
5556 }
5557 None => None,
5558 };
5559 if let Some(receipt) = context {
5560 materializer
5561 .revalidate_context(&ownership, receipt)
5562 .map_err(|_| failure(
5563 NodeFailureCode::ContextPackMaterializationFailed,
5564 "session context pack byte revalidation failed",
5565 ))?;
5566 }
5567 let transport = match mode {
5568 SessionMode::Pty => TransportKind::Pty,
5569 SessionMode::Inline => TransportKind::Pipe,
5570 SessionMode::Acp => TransportKind::Acp,
5571 };
5572 let overlay = if let Some(extra_args) = bundle_arguments {
5573 Some(PreparedNativeLaunchOverlay::Instance(
5574 NativeInstanceLaunchOverlay::new(
5575 provider.clone(),
5576 transport,
5577 if has_environment_overlay {
5578 environment
5579 } else {
5580 Vec::new()
5581 },
5582 extra_args,
5583 )
5584 .map_err(|_| {
5585 failure(
5586 NodeFailureCode::BundleBindingMismatch,
5587 "session bundle launch overlay is invalid",
5588 )
5589 })?,
5590 ))
5591 } else if has_environment_overlay {
5592 let context_only = environment_profile.is_none()
5593 && bundle.is_none()
5594 && context.is_some();
5595 let overlay = if context_only {
5596 NativeLaunchEnvironmentOverlay::new_context_root(
5597 provider.clone(),
5598 transport,
5599 environment,
5600 )
5601 .map_err(|_| {
5602 failure(
5603 NodeFailureCode::ContextPackMaterializationFailed,
5604 "context-only launch overlay is invalid",
5605 )
5606 })?
5607 } else {
5608 NativeLaunchEnvironmentOverlay::new(
5609 provider.clone(),
5610 transport,
5611 environment,
5612 )
5613 .map_err(|_| {
5614 failure(
5615 NodeFailureCode::BackendOperationFailed,
5616 "session environment overlay is invalid",
5617 )
5618 })?
5619 };
5620 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5621 } else {
5622 None
5623 };
5624 Ok(Some((overlay, guard)))
5625 }
5626
5627 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5628 let Some(mut ownership) = self
5629 .materializations
5630 .lock()
5631 .unwrap_or_else(|poisoned| poisoned.into_inner())
5632 .get(id)
5633 .cloned()
5634 else {
5635 return Ok(());
5636 };
5637 if ownership.state() != MaterializationState::CleanupRequired {
5638 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5639 failure(
5640 NodeFailureCode::BackendOperationFailed,
5641 "session environment cleanup state is invalid",
5642 )
5643 })?;
5644 }
5645 {
5646 let _transaction = self
5647 .state_transaction
5648 .lock()
5649 .unwrap_or_else(|poisoned| poisoned.into_inner());
5650 let previous = self
5651 .materializations
5652 .lock()
5653 .unwrap_or_else(|poisoned| poisoned.into_inner())
5654 .insert(id.clone(), ownership.clone());
5655 if let Err(error) = self.persist_state_locked() {
5656 let mut materializations = self
5657 .materializations
5658 .lock()
5659 .unwrap_or_else(|poisoned| poisoned.into_inner());
5660 if let Some(previous) = previous {
5661 materializations.insert(id.clone(), previous);
5662 }
5663 return Err(persistence_failure(error));
5664 }
5665 }
5666 let cleanup = self
5667 .session_environment_materializer
5668 .as_ref()
5669 .ok_or(())
5670 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5671 if cleanup.is_err() {
5672 let _ = ownership.mark_recovery_required(unix_time_ms());
5673 self.materializations
5674 .lock()
5675 .unwrap_or_else(|poisoned| poisoned.into_inner())
5676 .insert(id.clone(), ownership);
5677 if self.persist_state().is_err() {
5678 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5679 }
5680 return Err(failure(
5681 NodeFailureCode::BackendOperationFailed,
5682 "session environment cleanup requires recovery",
5683 ));
5684 }
5685 let _transaction = self
5686 .state_transaction
5687 .lock()
5688 .unwrap_or_else(|poisoned| poisoned.into_inner());
5689 self.materializations
5690 .lock()
5691 .unwrap_or_else(|poisoned| poisoned.into_inner())
5692 .remove(id);
5693 if let Err(error) = self.persist_state_locked() {
5694 self.materializations
5695 .lock()
5696 .unwrap_or_else(|poisoned| poisoned.into_inner())
5697 .insert(id.clone(), ownership);
5698 return Err(persistence_failure(error));
5699 }
5700 Ok(())
5701 }
5702
5703 fn resolve_record_materialization(
5704 &self,
5705 record_id: &SessionRecordId,
5706 provider: &AgentId,
5707 mode: SessionMode,
5708 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5709 bundle: Option<&ResolvedBundleReceipt>,
5710 context: Option<&ResolvedContextPackReceipt>,
5711 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5712 let ownership = {
5713 let materializations = self
5714 .materializations
5715 .lock()
5716 .unwrap_or_else(|poisoned| poisoned.into_inner());
5717 let mut matches = materializations.values().filter(|record| {
5718 record.owner()
5719 == &MaterializationOwner::Record {
5720 record_id: record_id.clone(),
5721 }
5722 });
5723 let ownership = matches.next().cloned();
5724 if matches.next().is_some() {
5725 return Err(failure(
5726 NodeFailureCode::BackendOperationFailed,
5727 "managed session has conflicting environment ownership",
5728 ));
5729 }
5730 ownership
5731 };
5732 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5733 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5734 failure(
5735 NodeFailureCode::ContextPackMaterializationFailed,
5736 "session context materializer is unavailable",
5737 )
5738 })?;
5739 let pack = materializer
5740 .revalidate_context(ownership, receipt)
5741 .map_err(|_| {
5742 self.mark_materialization_recovery_required(ownership.id());
5743 failure(
5744 NodeFailureCode::ContextPackMaterializationFailed,
5745 "managed session context pack revalidation failed",
5746 )
5747 })?;
5748 self.context_catalog
5749 .write()
5750 .unwrap_or_else(|poisoned| poisoned.into_inner())
5751 .insert(pack)
5752 .map_err(|_| failure(
5753 NodeFailureCode::ContextPackMaterializationFailed,
5754 "managed session context pack catalog restore failed",
5755 ))?;
5756 }
5757 let materialization = self.materialization_profile(
5758 provider,
5759 mode,
5760 environment_profile,
5761 bundle,
5762 context,
5763 )?;
5764 match (ownership, materialization) {
5765 (None, None) => Ok(None),
5766 (None, Some(_)) | (Some(_), None) => Err(failure(
5767 NodeFailureCode::BackendOperationFailed,
5768 "managed session materialization is unavailable",
5769 )),
5770 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5771 if ownership.environment_profile() != environment_profile
5772 || ownership.bundle() != bundle
5773 || ownership.context() != context
5774 || ownership.state() != MaterializationState::Ready
5775 {
5776 self.mark_materialization_recovery_required(ownership.id());
5777 return Err(failure(
5778 NodeFailureCode::BackendOperationFailed,
5779 "managed session environment ownership requires recovery",
5780 ));
5781 }
5782 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5783 failure(
5784 NodeFailureCode::BackendOperationFailed,
5785 "session environment materializer is unavailable",
5786 )
5787 })?;
5788 if materializer.revalidate(&ownership).is_err() {
5789 self.mark_materialization_recovery_required(ownership.id());
5790 return Err(failure(
5791 NodeFailureCode::BackendOperationFailed,
5792 "managed session environment revalidation failed",
5793 ));
5794 }
5795 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5796 let catalog = self.bundle_catalog.read()
5797 .unwrap_or_else(|poisoned| poisoned.into_inner());
5798 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5799 return Err(failure(
5800 NodeFailureCode::UnknownBundle,
5801 "managed session bundle is unavailable",
5802 ));
5803 };
5804 let layout = bundle_layout.ok_or_else(|| failure(
5805 NodeFailureCode::BundleBindingMismatch,
5806 "record bundle provider layout is unavailable",
5807 ))?;
5808 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5809 drop(catalog);
5810 self.mark_materialization_recovery_required(ownership.id());
5811 return Err(failure(
5812 NodeFailureCode::BundleMaterializationFailed,
5813 "managed session bundle revalidation failed",
5814 ));
5815 }
5816 Some(bundle_launch_arguments(
5817 layout,
5818 ownership.bundle_root(),
5819 ownership.provider_home(),
5820 ).map_err(|_| failure(
5821 NodeFailureCode::BundleBindingMismatch,
5822 "managed session bundle launch binding is invalid",
5823 ))?)
5824 } else {
5825 None
5826 };
5827 if !has_environment_materialization {
5828 let overlay = bundle_arguments.map(|extra_args| {
5829 NativeInstanceLaunchOverlay::new(
5830 provider.clone(),
5831 TransportKind::Pty,
5832 Vec::new(),
5833 extra_args,
5834 )
5835 .map(PreparedNativeLaunchOverlay::Instance)
5836 .map_err(|_| failure(
5837 NodeFailureCode::BundleBindingMismatch,
5838 "managed session bundle launch overlay is invalid",
5839 ))
5840 }).transpose()?;
5841 return Ok(Some((ownership.id().clone(), overlay)));
5842 }
5843 let environment = match materializer.resolve_environment(&ownership, &profile) {
5844 Ok(environment) => environment,
5845 Err(
5846 SessionEnvironmentMaterializeError::SecretUnavailable
5847 | SessionEnvironmentMaterializeError::SecretDenied
5848 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5849 ) => {
5850 return Err(failure(
5851 NodeFailureCode::BackendOperationFailed,
5852 "managed session environment resolution failed",
5853 ));
5854 }
5855 Err(
5856 SessionEnvironmentMaterializeError::InvalidRoot
5857 | SessionEnvironmentMaterializeError::OwnershipMismatch
5858 | SessionEnvironmentMaterializeError::Filesystem(_)
5859 | SessionEnvironmentMaterializeError::Record(_),
5860 ) => {
5861 self.mark_materialization_recovery_required(ownership.id());
5862 return Err(failure(
5863 NodeFailureCode::BackendOperationFailed,
5864 "managed session environment resolution failed",
5865 ));
5866 }
5867 };
5868 let transport = match mode {
5869 SessionMode::Pty => TransportKind::Pty,
5870 SessionMode::Inline => TransportKind::Pipe,
5871 SessionMode::Acp => TransportKind::Acp,
5872 };
5873 let overlay = match bundle_arguments {
5874 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5875 NativeInstanceLaunchOverlay::new(
5876 provider.clone(),
5877 transport,
5878 environment,
5879 extra_args,
5880 )
5881 .map_err(|_| failure(
5882 NodeFailureCode::BundleBindingMismatch,
5883 "managed session bundle launch overlay is invalid",
5884 ))?,
5885 ),
5886 None => PreparedNativeLaunchOverlay::Environment(
5887 NativeLaunchEnvironmentOverlay::new(
5888 provider.clone(),
5889 transport,
5890 environment,
5891 )
5892 .map_err(|_| {
5893 failure(
5894 NodeFailureCode::BackendOperationFailed,
5895 "managed session environment overlay is invalid",
5896 )
5897 })?,
5898 ),
5899 };
5900 Ok(Some((ownership.id().clone(), Some(overlay))))
5901 }
5902 }
5903 }
5904
5905 fn destroy_record_materialization(
5906 &self,
5907 record_id: &SessionRecordId,
5908 ) -> Result<bool, NodeFailure> {
5909 let ids = self
5910 .materializations
5911 .lock()
5912 .unwrap_or_else(|poisoned| poisoned.into_inner())
5913 .values()
5914 .filter_map(|record| {
5915 (record.owner()
5916 == &MaterializationOwner::Record {
5917 record_id: record_id.clone(),
5918 })
5919 .then(|| record.id().clone())
5920 })
5921 .collect::<Vec<_>>();
5922 if ids.len() > 1 {
5923 return Err(failure(
5924 NodeFailureCode::BackendOperationFailed,
5925 "managed session has conflicting environment ownership",
5926 ));
5927 }
5928 let Some(id) = ids.first().cloned() else {
5929 return Ok(false);
5930 };
5931 let (terminal_record, mut cleanup_ownership) = {
5932 let _transaction = self
5933 .state_transaction
5934 .lock()
5935 .unwrap_or_else(|poisoned| poisoned.into_inner());
5936 let original_record = self.record(record_id)?;
5937 let mut terminal_record = original_record.clone();
5938 terminal_record.provider_session = None;
5939 terminal_record.active_session = None;
5940 terminal_record.state = ManagedSessionState::Unavailable;
5941 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5942 terminal_record.updated_at_unix_ms = unix_time_ms();
5943 let original_ownership = self
5944 .materializations
5945 .lock()
5946 .unwrap_or_else(|poisoned| poisoned.into_inner())
5947 .get(&id)
5948 .cloned()
5949 .ok_or_else(|| {
5950 failure(
5951 NodeFailureCode::BackendOperationFailed,
5952 "managed session environment ownership disappeared during cleanup",
5953 )
5954 })?;
5955 let mut cleanup_ownership = original_ownership.clone();
5956 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5957 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5958 failure(
5959 NodeFailureCode::BackendOperationFailed,
5960 "managed session environment cleanup state is invalid",
5961 )
5962 })?;
5963 }
5964 self.session_records
5965 .lock()
5966 .unwrap_or_else(|poisoned| poisoned.into_inner())
5967 .records
5968 .insert(record_id.clone(), terminal_record.clone());
5969 self.materializations
5970 .lock()
5971 .unwrap_or_else(|poisoned| poisoned.into_inner())
5972 .insert(id.clone(), cleanup_ownership.clone());
5973 if let Err(error) = self.persist_state_locked() {
5974 self.session_records
5975 .lock()
5976 .unwrap_or_else(|poisoned| poisoned.into_inner())
5977 .records
5978 .insert(record_id.clone(), original_record);
5979 self.materializations
5980 .lock()
5981 .unwrap_or_else(|poisoned| poisoned.into_inner())
5982 .insert(id.clone(), original_ownership);
5983 return Err(persistence_failure(error));
5984 }
5985 (terminal_record, cleanup_ownership)
5986 };
5987 let cleanup = self
5988 .session_environment_materializer
5989 .as_ref()
5990 .ok_or(())
5991 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5992 if cleanup.is_err() {
5993 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5994 self.materializations
5995 .lock()
5996 .unwrap_or_else(|poisoned| poisoned.into_inner())
5997 .insert(id.clone(), cleanup_ownership);
5998 if self.persist_state().is_err() {
5999 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6000 }
6001 return Err(failure(
6002 NodeFailureCode::BackendOperationFailed,
6003 "managed session environment cleanup requires recovery",
6004 ));
6005 }
6006 let _transaction = self
6007 .state_transaction
6008 .lock()
6009 .unwrap_or_else(|poisoned| poisoned.into_inner());
6010 self.remove_record_memory(record_id);
6011 self.materializations
6012 .lock()
6013 .unwrap_or_else(|poisoned| poisoned.into_inner())
6014 .remove(&id);
6015 if let Err(error) = self.persist_state_locked() {
6016 self.session_records
6017 .lock()
6018 .unwrap_or_else(|poisoned| poisoned.into_inner())
6019 .records
6020 .insert(record_id.clone(), terminal_record);
6021 self.materializations
6022 .lock()
6023 .unwrap_or_else(|poisoned| poisoned.into_inner())
6024 .insert(id, cleanup_ownership);
6025 return Err(persistence_failure(error));
6026 }
6027 Ok(true)
6028 }
6029
6030 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6031 let changed = {
6032 let mut materializations = self
6033 .materializations
6034 .lock()
6035 .unwrap_or_else(|poisoned| poisoned.into_inner());
6036 let Some(ownership) = materializations.get_mut(id) else {
6037 return;
6038 };
6039 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6040 };
6041 if changed && self.persist_state().is_err() {
6042 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6043 }
6044 }
6045
6046 fn reconcile_materializations(&self) {
6047 let records = self
6048 .materializations
6049 .lock()
6050 .unwrap_or_else(|poisoned| poisoned.into_inner())
6051 .values()
6052 .cloned()
6053 .collect::<Vec<_>>();
6054 for mut ownership in records {
6055 let result = match (ownership.state(), ownership.owner()) {
6056 (MaterializationState::Preparing, _)
6057 | (MaterializationState::CleanupRequired, _) => {
6058 self.cleanup_materialization(ownership.id())
6059 }
6060 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6061 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6062 self.materializations
6063 .lock()
6064 .unwrap_or_else(|poisoned| poisoned.into_inner())
6065 .insert(ownership.id().clone(), ownership.clone());
6066 self.persist_state().map_err(persistence_failure)
6067 } else {
6068 Err(failure(
6069 NodeFailureCode::BackendOperationFailed,
6070 "session environment reconciliation failed",
6071 ))
6072 }
6073 }
6074 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6075 .session_environment_materializer
6076 .as_ref()
6077 .ok_or_else(|| {
6078 failure(
6079 NodeFailureCode::BackendOperationFailed,
6080 "session environment materializer is unavailable",
6081 )
6082 })
6083 .and_then(|materializer| {
6084 materializer.revalidate(&ownership).map_err(|_| failure(
6085 NodeFailureCode::BackendOperationFailed,
6086 "session materialization revalidation failed",
6087 ))?;
6088 let record = self.record(record_id)?;
6089 if record.environment_profile.as_ref() != ownership.environment_profile()
6090 || record.bundle.as_ref() != ownership.bundle()
6091 || record.context.as_ref() != ownership.context()
6092 {
6093 return Err(failure(
6094 NodeFailureCode::BackendOperationFailed,
6095 "session materialization ownership changed after restart",
6096 ));
6097 }
6098 if let Some(receipt) = ownership.bundle() {
6099 let catalog = self.bundle_catalog.read()
6100 .unwrap_or_else(|poisoned| poisoned.into_inner());
6101 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6102 NodeFailureCode::UnknownBundle,
6103 "session bundle is unavailable after restart",
6104 ))?;
6105 if bundle.receipt() != *receipt {
6106 return Err(failure(
6107 NodeFailureCode::BundleBindingMismatch,
6108 "session bundle changed after restart",
6109 ));
6110 }
6111 let layout = self.bundle_layout(
6112 &record.provider,
6113 record.mode,
6114 record.environment_profile.as_ref(),
6115 bundle,
6116 )?;
6117 materializer
6118 .revalidate_bundle(&ownership, bundle, layout)
6119 .map_err(|_| failure(
6120 NodeFailureCode::BundleMaterializationFailed,
6121 "session bundle revalidation failed",
6122 ))?;
6123 }
6124 if let Some(receipt) = ownership.context() {
6125 let pack = materializer
6126 .revalidate_context(&ownership, receipt)
6127 .map_err(|_| {
6128 failure(
6129 NodeFailureCode::ContextPackMaterializationFailed,
6130 "session context revalidation failed",
6131 )
6132 })?;
6133 self.context_catalog
6134 .write()
6135 .unwrap_or_else(|poisoned| poisoned.into_inner())
6136 .insert(pack)
6137 .map_err(|_| {
6138 failure(
6139 NodeFailureCode::ContextPackMaterializationFailed,
6140 "session context catalog restore failed",
6141 )
6142 })?;
6143 }
6144 Ok(())
6145 }),
6146 (MaterializationState::RecoveryRequired, _) => Ok(()),
6147 };
6148 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6149 let _ = ownership.mark_recovery_required(unix_time_ms());
6150 self.materializations
6151 .lock()
6152 .unwrap_or_else(|poisoned| poisoned.into_inner())
6153 .insert(ownership.id().clone(), ownership);
6154 if self.persist_state().is_err() {
6155 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6156 }
6157 }
6158 }
6159 let materialized_profiles = self
6160 .environment_materialization_profiles
6161 .read()
6162 .unwrap_or_else(|poisoned| poisoned.into_inner())
6163 .keys()
6164 .cloned()
6165 .collect::<Vec<_>>();
6166 let ownership = self
6167 .materializations
6168 .lock()
6169 .unwrap_or_else(|poisoned| poisoned.into_inner())
6170 .values()
6171 .cloned()
6172 .collect::<Vec<_>>();
6173 let changed = {
6174 let mut session_records = self
6175 .session_records
6176 .lock()
6177 .unwrap_or_else(|poisoned| poisoned.into_inner());
6178 let mut changed = false;
6179 for record in session_records.records.values_mut() {
6180 let environment_profile = record.environment_profile.as_ref();
6181 if environment_profile.is_none()
6182 && record.bundle.is_none()
6183 && record.context.is_none()
6184 {
6185 continue;
6186 }
6187 if record.bundle.is_none()
6188 && environment_profile.is_some_and(|profile| {
6189 !materialized_profiles.contains(&profile.profile_id)
6190 })
6191 {
6192 continue;
6193 }
6194 let has_exact_ownership = ownership.iter().any(|candidate| {
6195 candidate.state() == MaterializationState::Ready
6196 && candidate.environment_profile() == environment_profile
6197 && candidate.bundle() == record.bundle.as_ref()
6198 && candidate.context() == record.context.as_ref()
6199 && candidate.owner()
6200 == &MaterializationOwner::Record {
6201 record_id: record.record_id.clone(),
6202 }
6203 });
6204 if !has_exact_ownership {
6205 record.provider_session = None;
6206 record.active_session = None;
6207 record.state = ManagedSessionState::Unavailable;
6208 record.last_error = Some(if record.bundle.is_some() {
6209 "bundle-unavailable".to_owned()
6210 } else if record.context.is_some() {
6211 "context-unavailable".to_owned()
6212 } else {
6213 "environment-profile-unavailable".to_owned()
6214 });
6215 record.updated_at_unix_ms = unix_time_ms();
6216 changed = true;
6217 }
6218 }
6219 changed
6220 };
6221 if changed && self.persist_state().is_err() {
6222 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6223 }
6224 }
6225
6226 fn replay_spawn_spec(
6227 &self,
6228 spec: &SpawnSpec,
6229 now: Instant,
6230 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6231 let mut cache = self
6232 .spawn_idempotency
6233 .lock()
6234 .unwrap_or_else(|poisoned| poisoned.into_inner());
6235 cache.entries.retain(|_, entry| entry.expires_at > now);
6236 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6237 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6238 return Err(failure(
6239 NodeFailureCode::SpawnIdempotencyConflict,
6240 "spawn idempotency key was reused across spawn kinds",
6241 ));
6242 };
6243 if existing != spec {
6244 return Err(failure(
6245 NodeFailureCode::SpawnIdempotencyConflict,
6246 "spawn idempotency key was reused with a different specification",
6247 ));
6248 }
6249 return Ok(Some(result.clone()));
6250 }
6251 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6252 return Err(failure(
6253 NodeFailureCode::SpawnIdempotencyCapacity,
6254 "spawn idempotency cache reached its bounded capacity",
6255 ));
6256 }
6257 Ok(None)
6258 }
6259
6260 fn remember_spawn_spec(
6261 &self,
6262 spec: SpawnSpec,
6263 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6264 accepted_at: Instant,
6265 ) {
6266 self
6267 .spawn_idempotency
6268 .lock()
6269 .unwrap_or_else(|poisoned| poisoned.into_inner())
6270 .entries
6271 .insert(
6272 spec.idempotency_key.clone(),
6273 SpawnIdempotencyEntry {
6274 value: SpawnIdempotencyValue::Standard { spec, result },
6275 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6276 },
6277 );
6278 }
6279
6280 fn claim_harness_mcp_spawn(
6281 &self,
6282 _reservation_id: &HarnessMcpReservationId,
6283 _activation_digest: &HarnessMcpActivationDigest,
6284 spec: &SpawnSpec,
6285 now: Instant,
6286 ) -> Result<(), NodeFailure> {
6287 let mut cache = self.spawn_idempotency.lock()
6288 .unwrap_or_else(|poisoned| poisoned.into_inner());
6289 cache.entries.retain(|_, entry| entry.expires_at > now);
6290 if cache.entries.contains_key(&spec.idempotency_key) {
6291 return Err(failure(
6292 NodeFailureCode::SpawnIdempotencyConflict,
6293 "spawn idempotency key was reused across spawn kinds",
6294 ));
6295 }
6296 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6297 return Err(failure(
6298 NodeFailureCode::SpawnIdempotencyCapacity,
6299 "spawn idempotency cache reached its bounded capacity",
6300 ));
6301 }
6302 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6303 value: SpawnIdempotencyValue::HarnessMcp,
6304 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6305 });
6306 Ok(())
6307 }
6308
6309 fn replay_managed_spawn(
6310 &self,
6311 request: &ManagedWorktreeSpawnRequest,
6312 now: Instant,
6313 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6314 let mut cache = self.spawn_idempotency.lock()
6315 .unwrap_or_else(|poisoned| poisoned.into_inner());
6316 cache.entries.retain(|_, entry| entry.expires_at > now);
6317 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6318 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6319 return Err(failure(
6320 NodeFailureCode::SpawnIdempotencyConflict,
6321 "spawn idempotency key was reused across spawn kinds",
6322 ));
6323 };
6324 if existing != request {
6325 return Err(failure(
6326 NodeFailureCode::SpawnIdempotencyConflict,
6327 "spawn idempotency key was reused with a different managed specification",
6328 ));
6329 }
6330 return Ok(Some(result.clone()));
6331 }
6332 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6333 return Err(failure(
6334 NodeFailureCode::SpawnIdempotencyCapacity,
6335 "spawn idempotency cache reached its bounded capacity",
6336 ));
6337 }
6338 Ok(None)
6339 }
6340
6341 fn remember_managed_spawn(
6342 &self,
6343 request: ManagedWorktreeSpawnRequest,
6344 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6345 accepted_at: Instant,
6346 ) {
6347 self.spawn_idempotency.lock()
6348 .unwrap_or_else(|poisoned| poisoned.into_inner())
6349 .entries
6350 .insert(
6351 request.spawn_spec.idempotency_key.clone(),
6352 SpawnIdempotencyEntry {
6353 value: SpawnIdempotencyValue::Managed { request, result },
6354 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6355 },
6356 );
6357 }
6358
6359 fn remember_managed_spawn_attempt(
6360 &self,
6361 durable_v2_key: Option<&SpawnIdempotencyKey>,
6362 request: ManagedWorktreeSpawnRequest,
6363 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6364 accepted_at: Instant,
6365 ) {
6366 if durable_v2_key.is_none() {
6367 self.remember_managed_spawn(request, result, accepted_at);
6368 }
6369 }
6370
6371 fn acquire_durable_managed_spawn_v2(
6372 &self,
6373 request: &ManagedWorktreeSpawnRequestV2,
6374 request_digest: String,
6375 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6376 if self.state_path.is_none() {
6377 return Err(failure(
6378 NodeFailureCode::BackendOperationFailed,
6379 "managed worktree V2 requires durable node state",
6380 ));
6381 }
6382 let _transaction = self.state_transaction
6383 .lock()
6384 .unwrap_or_else(|poisoned| poisoned.into_inner());
6385 let key = request.spawn_spec.idempotency_key.clone();
6386 let mut previous = None;
6387 {
6388 let mut replays = self.managed_spawn_replays
6389 .lock()
6390 .unwrap_or_else(|poisoned| poisoned.into_inner());
6391 if let Some(record) = replays.get_mut(&key) {
6392 if record.request_digest != request_digest {
6393 return Err(failure(
6394 NodeFailureCode::SpawnIdempotencyConflict,
6395 "managed worktree V2 idempotency key was reused with a different request",
6396 ));
6397 }
6398 match &record.state {
6399 ManagedWorktreeSpawnReplayStateV10::Pending {
6400 owner_incarnation_id,
6401 lease_id: None,
6402 } if owner_incarnation_id != &self.incarnation_id => {
6403 previous = Some(record.clone());
6404 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6405 owner_incarnation_id: self.incarnation_id,
6406 lease_id: None,
6407 };
6408 record.updated_at_unix_ms = unix_time_ms();
6409 }
6410 ManagedWorktreeSpawnReplayStateV10::Pending {
6411 lease_id: None,
6412 ..
6413 } => {
6414 return Err(failure(
6415 NodeFailureCode::BackendBusy,
6416 "managed worktree V2 reservation is already active",
6417 ));
6418 }
6419 ManagedWorktreeSpawnReplayStateV10::Pending {
6420 lease_id: Some(_),
6421 ..
6422 } => {
6423 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6424 }
6425 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6426 return Ok(DurableManagedSpawnReplayDecision::Committed(
6427 receipt.clone(),
6428 ));
6429 }
6430 }
6431 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6432 return Err(failure(
6433 NodeFailureCode::SpawnIdempotencyCapacity,
6434 "durable managed worktree replay ledger reached its bounded capacity",
6435 ));
6436 } else {
6437 let now = unix_time_ms();
6438 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6439 idempotency_key: key.clone(),
6440 request_digest,
6441 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6442 profile_id: request.worktree_profile_id.clone(),
6443 expected_profile_revision: request.expected_profile_revision.clone(),
6444 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6445 owner_incarnation_id: self.incarnation_id,
6446 lease_id: None,
6447 },
6448 created_at_unix_ms: now,
6449 updated_at_unix_ms: now,
6450 });
6451 }
6452 }
6453 if let Err(error) = self.persist_state_locked() {
6454 let mut replays = self.managed_spawn_replays
6455 .lock()
6456 .unwrap_or_else(|poisoned| poisoned.into_inner());
6457 match previous {
6458 Some(previous) => {
6459 replays.insert(key, previous);
6460 }
6461 None => {
6462 replays.remove(&key);
6463 }
6464 }
6465 return Err(persistence_failure(error));
6466 }
6467 Ok(DurableManagedSpawnReplayDecision::Reserved)
6468 }
6469
6470 fn link_durable_managed_spawn_v2_lease_locked(
6471 &self,
6472 key: &SpawnIdempotencyKey,
6473 lease_id: &ManagedWorktreeLeaseId,
6474 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6475 let mut replays = self.managed_spawn_replays
6476 .lock()
6477 .unwrap_or_else(|poisoned| poisoned.into_inner());
6478 let record = replays.get_mut(key).ok_or_else(|| {
6479 failure(
6480 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6481 "managed worktree V2 reservation disappeared before allocation",
6482 )
6483 })?;
6484 let previous = record.clone();
6485 match &mut record.state {
6486 ManagedWorktreeSpawnReplayStateV10::Pending {
6487 lease_id: current,
6488 ..
6489 }
6490 if current.is_none() =>
6491 {
6492 *current = Some(lease_id.clone());
6493 record.updated_at_unix_ms = unix_time_ms();
6494 Ok(previous)
6495 }
6496 _ => Err(failure(
6497 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6498 "managed worktree V2 reservation is not allocation-ready",
6499 )),
6500 }
6501 }
6502
6503 fn restore_durable_managed_spawn_v2_locked(
6504 &self,
6505 key: &SpawnIdempotencyKey,
6506 previous: ManagedWorktreeSpawnReplayRecordV10,
6507 ) {
6508 self.managed_spawn_replays
6509 .lock()
6510 .unwrap_or_else(|poisoned| poisoned.into_inner())
6511 .insert(key.clone(), previous);
6512 }
6513
6514 fn commit_durable_managed_spawn_v2(
6515 &self,
6516 request: &ManagedWorktreeSpawnRequestV2,
6517 receipt: ManagedWorktreeSpawnReceipt,
6518 ) -> Result<(), NodeFailure> {
6519 let _transaction = self.state_transaction
6520 .lock()
6521 .unwrap_or_else(|poisoned| poisoned.into_inner());
6522 let key = &request.spawn_spec.idempotency_key;
6523 let previous = {
6524 let mut replays = self.managed_spawn_replays
6525 .lock()
6526 .unwrap_or_else(|poisoned| poisoned.into_inner());
6527 let record = replays.get_mut(key).ok_or_else(|| {
6528 failure(
6529 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6530 "managed worktree V2 reservation disappeared before commit",
6531 )
6532 })?;
6533 let previous = record.clone();
6534 match &record.state {
6535 ManagedWorktreeSpawnReplayStateV10::Pending {
6536 lease_id: Some(lease_id),
6537 ..
6538 } if lease_id == &receipt.lease.lease_id => {}
6539 _ => {
6540 return Err(failure(
6541 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6542 "managed worktree V2 reservation is not linked to the spawned lease",
6543 ));
6544 }
6545 }
6546 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6547 record.updated_at_unix_ms = unix_time_ms();
6548 previous
6549 };
6550 if let Err(error) = self.persist_state_locked() {
6551 self.restore_durable_managed_spawn_v2_locked(key, previous);
6552 return Err(persistence_failure(error));
6553 }
6554 Ok(())
6555 }
6556
6557 fn remove_unallocated_durable_managed_spawn_v2(
6558 &self,
6559 request: &ManagedWorktreeSpawnRequestV2,
6560 ) -> Result<(), NodeFailure> {
6561 let _transaction = self.state_transaction
6562 .lock()
6563 .unwrap_or_else(|poisoned| poisoned.into_inner());
6564 let key = &request.spawn_spec.idempotency_key;
6565 let removed = {
6566 let mut replays = self.managed_spawn_replays
6567 .lock()
6568 .unwrap_or_else(|poisoned| poisoned.into_inner());
6569 match replays.get(key).map(|record| &record.state) {
6570 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6571 lease_id: None,
6572 ..
6573 }) => {
6574 replays.remove(key)
6575 }
6576 _ => None,
6577 }
6578 };
6579 let Some(removed) = removed else {
6580 return Ok(());
6581 };
6582 if let Err(error) = self.persist_state_locked() {
6583 self.managed_spawn_replays
6584 .lock()
6585 .unwrap_or_else(|poisoned| poisoned.into_inner())
6586 .insert(key.clone(), removed);
6587 return Err(persistence_failure(error));
6588 }
6589 Ok(())
6590 }
6591
6592 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6593 if spec.target.node_id != self.node_id {
6594 return Err(failure(
6595 NodeFailureCode::SpawnTargetMismatch,
6596 "spawn target does not match this node",
6597 ));
6598 }
6599 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6600 failure(
6601 NodeFailureCode::UnknownSpawnProfile,
6602 "spawn profile is unavailable on this node",
6603 )
6604 })?;
6605 let resolved = spec.resolve(defaults).map_err(|error| match error {
6606 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6607 NodeFailureCode::SpawnProfileRevisionMismatch,
6608 "spawn profile revision does not match the loaded profile",
6609 ),
6610 _ => failure(
6611 NodeFailureCode::InvalidRequest,
6612 "spawn specification could not be resolved",
6613 ),
6614 })?;
6615 if resolved.target.worktree_id.is_some() {
6616 self.require_worktree_service(&resolved.target.workspace_id)?;
6617 }
6618 let environment_profile = self.resolve_environment_profile(&resolved)?;
6619 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6620 self.resolve_context(&resolved)?;
6621 Ok(resolved)
6622 }
6623
6624 async fn resolve_spawn_workspace(
6625 &self,
6626 resolved: &ResolvedSpawnSpec,
6627 deadline: Instant,
6628 ) -> Result<WorkspaceId, NodeFailure> {
6629 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6630 return Ok(resolved.target.workspace_id.clone());
6631 };
6632 if self.managed_worktrees.lock()
6633 .unwrap_or_else(|poisoned| poisoned.into_inner())
6634 .lease_for_workspace(worktree_id)
6635 .is_some()
6636 {
6637 return Err(failure(
6638 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6639 "managed worktree targets may only be spawned through their lease request",
6640 ));
6641 }
6642 self.require_worktree_service(&resolved.target.workspace_id)?;
6643 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6644 let selected_root = self.workspace_root(worktree_id)?;
6645 let remaining = spawn_deadline_remaining(deadline)?;
6646 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6647 .await
6648 .map_err(|_| {
6649 failure(
6650 NodeFailureCode::SpawnDeadlineExceeded,
6651 "Git worktree selection exceeded the spawn deadline",
6652 )
6653 })?
6654 .map_err(git_worktree_failure)?;
6655 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6656 Ok(worktree_id.clone())
6657 }
6658
6659 async fn spawn_from_spec(
6660 &self,
6661 spec: SpawnSpec,
6662 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6663 self.spawn_from_spec_with_proxy(spec, None).await
6664 }
6665
6666 async fn spawn_from_spec_with_proxy(
6667 &self,
6668 spec: SpawnSpec,
6669 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6670 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6671 let accepted_at = Instant::now();
6672 if harness_mcp.is_none() {
6673 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6674 return replayed;
6675 }
6676 }
6677 let resolved = self.resolve_spawn_spec(&spec)?;
6678 let environment_profile = self.resolve_environment_profile(&resolved)?;
6679 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6680 let context = self.resolve_context(&resolved)?;
6681 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6682 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6683 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6684 let spawn_workspace_id = match spawn_workspace_id {
6685 Ok(workspace_id) => workspace_id,
6686 Err(error) => {
6687 let result = Err(error);
6688 if harness_mcp.is_none() {
6689 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6690 }
6691 return result;
6692 }
6693 };
6694 if self.managed_worktrees.lock()
6695 .unwrap_or_else(|poisoned| poisoned.into_inner())
6696 .lease_for_workspace(&spawn_workspace_id)
6697 .is_some()
6698 {
6699 let result = Err(failure(
6700 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6701 "managed worktree targets may only be spawned through their lease request",
6702 ));
6703 if harness_mcp.is_none() {
6704 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6705 }
6706 return result;
6707 }
6708 let result = self
6709 .spawn_session_with_deadline(
6710 spawn_workspace_id,
6711 resolved.provider.clone(),
6712 resolved.mode,
6713 resolved.terminal_size,
6714 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6715 environment_profile.clone(),
6716 bundle.clone(),
6717 context.clone(),
6718 None,
6719 None,
6720 SpawnRecordPolicy::Always,
6721 Some(deadline),
6722 &required_capabilities,
6723 harness_mcp,
6724 resolved.approval_level,
6725 resolved.network_allowlist.clone(),
6726 resolved.browser_profile_id.clone(),
6727 )
6728 .await
6729 .map(|(session, _runtime_policy)| {
6730 resolved.receipt_with_materialization(
6731 self.incarnation_id,
6732 session,
6733 environment_profile,
6734 bundle,
6735 context,
6736 )
6737 });
6738 if harness_mcp.is_none() {
6739 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6740 }
6741 result
6742 }
6743
6744 async fn spawn_from_spec_with_harness_mcp(
6745 &self,
6746 reservation_id: HarnessMcpReservationId,
6747 activation_digest: HarnessMcpActivationDigest,
6748 spec: SpawnSpec,
6749 deadline_unix_ms: u64,
6750 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6751 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6752 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6753 })?;
6754 if let Some(receipt) = registry
6755 .replay_spawn(&reservation_id, &activation_digest, &spec)
6756 .map_err(harness_mcp_failure)?
6757 {
6758 return Ok(receipt);
6759 }
6760 let resolved = self.resolve_spawn_spec(&spec)?;
6761 let prepared = registry
6762 .prepare_spawn(
6763 &reservation_id,
6764 &activation_digest,
6765 &spec,
6766 deadline_unix_ms,
6767 resolved.provider.clone(),
6768 )
6769 .map_err(harness_mcp_failure)?;
6770 self.claim_harness_mcp_spawn(
6771 &reservation_id,
6772 &activation_digest,
6773 &spec,
6774 Instant::now(),
6775 )?;
6776 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6777 let mut receipt = match spawn {
6778 Ok(receipt) => receipt,
6779 Err(error) => {
6780 let _ = registry.abort(&reservation_id, &activation_digest);
6781 return Err(error);
6782 }
6783 };
6784 let record_id = self
6785 .session_bindings
6786 .lock()
6787 .unwrap_or_else(|poisoned| poisoned.into_inner())
6788 .get(&receipt.session.session.instance_id)
6789 .and_then(|binding| binding.record_id.clone());
6790 let record_id = match record_id {
6791 Some(record_id) => record_id,
6792 None => {
6793 let rollback = self.remove_session(&receipt.session).await;
6794 let _ = registry.abort(&reservation_id, &activation_digest);
6795 if let Err(rollback_error) = rollback {
6796 return Err(rollback_error);
6797 }
6798 return Err(failure(
6799 NodeFailureCode::BindingMismatch,
6800 "harness MCP spawn did not retain its exact managed record",
6801 ));
6802 }
6803 };
6804 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6805 reservation_id: reservation_id.clone(),
6806 activation_digest: activation_digest.clone(),
6807 });
6808 if let Err(error) = registry.mark_spawned(
6809 &prepared,
6810 receipt.session.clone(),
6811 record_id,
6812 receipt.clone(),
6813 ) {
6814 let rollback = self.remove_session(&receipt.session).await;
6815 let _ = registry.abort(&reservation_id, &activation_digest);
6816 if let Err(rollback_error) = rollback {
6817 return Err(rollback_error);
6818 }
6819 return Err(harness_mcp_failure(error));
6820 }
6821 Ok(receipt)
6822 }
6823
6824 async fn spawn_managed_worktree(
6825 &self,
6826 request: ManagedWorktreeSpawnRequest,
6827 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6828 self.spawn_managed_worktree_inner(request, None).await
6829 }
6830
6831 async fn spawn_managed_worktree_inner(
6832 &self,
6833 request: ManagedWorktreeSpawnRequest,
6834 durable_v2_key: Option<&SpawnIdempotencyKey>,
6835 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6836 let accepted_at = Instant::now();
6837 if durable_v2_key.is_none() {
6838 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6839 return replayed;
6840 }
6841 }
6842 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6843 if request.spawn_spec.target.worktree_id.is_some() {
6844 let result = Err(failure(
6845 NodeFailureCode::InvalidRequest,
6846 "managed worktree spawn must not select a caller-provided worktree",
6847 ));
6848 self.remember_managed_spawn_attempt(
6849 durable_v2_key,
6850 request,
6851 result.clone(),
6852 accepted_at,
6853 );
6854 return result;
6855 }
6856 let environment_profile = self.resolve_environment_profile(&resolved)?;
6857 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6858 let context = self.resolve_context(&resolved)?;
6859 let source_workspace_id = resolved.target.workspace_id.clone();
6860 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6861 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6862 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6863 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6864 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6865 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6866 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6867 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6868 };
6869 let runtime_policy = timeout(
6870 spawn_deadline_remaining(deadline)?,
6871 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6872 ).await.map_err(|_| failure(
6873 NodeFailureCode::SpawnDeadlineExceeded,
6874 "provider admission exceeded the managed spawn deadline",
6875 ))??;
6876 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6877 return Err(failure(
6878 NodeFailureCode::UnsupportedSpawnCapability,
6879 "provider runtime does not admit a required spawn capability",
6880 ));
6881 }
6882 self.ensure_binding_capacity()?;
6883 let source_root = self.workspace_root(&source_workspace_id)?;
6884 profile.validate_for_workspace(&source_root)
6885 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6886 let base_timeout_ms = u64::try_from(
6887 spawn_deadline_remaining(deadline)?.as_millis(),
6888 ).unwrap_or(u64::MAX).max(1);
6889 let base_commit = resolve_base_commit_with_timeout(
6890 &source_root,
6891 profile.base(),
6892 base_timeout_ms,
6893 ).await.map_err(|error| {
6894 if Instant::now() >= deadline || error.message.contains("timed out") {
6895 failure(
6896 NodeFailureCode::SpawnDeadlineExceeded,
6897 "managed worktree base resolution exceeded the spawn deadline",
6898 )
6899 } else {
6900 managed_git_worktree_failure(error)
6901 }
6902 })?;
6903
6904 let lease = {
6905 let _transaction = self.state_transaction.lock()
6906 .unwrap_or_else(|poisoned| poisoned.into_inner());
6907 let previous = self.managed_worktrees.lock()
6908 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6909 let lease = self.managed_worktrees.lock()
6910 .unwrap_or_else(|poisoned| poisoned.into_inner())
6911 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6912 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6913 let previous_replay = match durable_v2_key
6914 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6915 .transpose()
6916 {
6917 Ok(previous) => previous,
6918 Err(error) => {
6919 *self.managed_worktrees.lock()
6920 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6921 return Err(error);
6922 }
6923 };
6924 if let Err(error) = self.persist_state_locked() {
6925 *self.managed_worktrees.lock()
6926 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6927 if let (Some(key), Some(previous_replay)) =
6928 (durable_v2_key, previous_replay)
6929 {
6930 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6931 }
6932 return Err(persistence_failure(error));
6933 }
6934 lease
6935 };
6936 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6937 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6938 self.managed_worktrees.lock()
6939 .unwrap_or_else(|poisoned| poisoned.into_inner())
6940 .remove_unmutated(&lease.lease_id);
6941 if self.persist_state().is_err() {
6942 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6943 }
6944 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6945 self.remember_managed_spawn_attempt(
6946 durable_v2_key,
6947 request,
6948 result.clone(),
6949 accepted_at,
6950 );
6951 return result;
6952 }
6953 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6954 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6955 Err(error) => {
6956 self.reconcile_failed_allocation(&lease, &source_root).await;
6957 let result = Err(error);
6958 self.remember_managed_spawn_attempt(
6959 durable_v2_key,
6960 request,
6961 result.clone(),
6962 accepted_at,
6963 );
6964 return result;
6965 }
6966 };
6967 let created = match create_git_worktree_with_timeout(
6968 &source_root,
6969 &lease.target_root,
6970 &lease.branch,
6971 Some(&base_commit),
6972 mutation_timeout_ms,
6973 ).await {
6974 Ok(created) => created,
6975 Err(error) => {
6976 self.reconcile_failed_allocation(&lease, &source_root).await;
6977 let result = Err(if Instant::now() >= deadline
6978 || error.message.contains("timed out")
6979 || error.message.contains("deadline")
6980 {
6981 failure(
6982 NodeFailureCode::SpawnDeadlineExceeded,
6983 "managed worktree creation exceeded the spawn deadline",
6984 )
6985 } else {
6986 managed_git_worktree_failure(error)
6987 });
6988 self.remember_managed_spawn_attempt(
6989 durable_v2_key,
6990 request,
6991 result.clone(),
6992 accepted_at,
6993 );
6994 return result;
6995 }
6996 };
6997 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6998 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6999 let result = Err(failure(
7000 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7001 "created worktree identity did not match its durable lease",
7002 ));
7003 self.remember_managed_spawn_attempt(
7004 durable_v2_key,
7005 request,
7006 result.clone(),
7007 accepted_at,
7008 );
7009 return result;
7010 }
7011 {
7012 let mut registry = self.managed_worktrees.lock()
7013 .unwrap_or_else(|poisoned| poisoned.into_inner());
7014 let current = registry.get_mut(&lease.lease_id)
7015 .expect("allocated managed worktree lease remains present");
7016 current.expected_head = Some(created.head.clone());
7017 current.state = ManagedWorktreeLeaseState::Ready;
7018 current.updated_at_unix_ms = unix_time_ms();
7019 }
7020 if let Err(error) = self.persist_state() {
7021 self.mark_managed_recovery_required(
7022 &lease.lease_id,
7023 ManagedWorktreeCleanupFailure::Backend,
7024 );
7025 let result = Err(persistence_failure(error));
7026 self.remember_managed_spawn_attempt(
7027 durable_v2_key,
7028 request,
7029 result.clone(),
7030 accepted_at,
7031 );
7032 return result;
7033 }
7034 let ready = self.managed_worktrees.lock()
7035 .unwrap_or_else(|poisoned| poisoned.into_inner())
7036 .get(&lease.lease_id)
7037 .expect("ready managed worktree lease remains present")
7038 .snapshot();
7039 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7040 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7041 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7042 let result = Err(error);
7043 self.remember_managed_spawn_attempt(
7044 durable_v2_key,
7045 request,
7046 result.clone(),
7047 accepted_at,
7048 );
7049 return result;
7050 }
7051 let spawn = self.spawn_session_with_deadline(
7052 lease.workspace_id.clone(),
7053 resolved.provider.clone(),
7054 resolved.mode,
7055 resolved.terminal_size,
7056 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7057 environment_profile.clone(),
7058 bundle.clone(),
7059 context.clone(),
7060 Some(lease.lease_id.clone()),
7061 Some(runtime_policy),
7062 SpawnRecordPolicy::Always,
7063 Some(deadline),
7064 &required_capabilities,
7065 None,
7066 resolved.approval_level,
7067 resolved.network_allowlist.clone(),
7068 resolved.browser_profile_id.clone(),
7069 ).await;
7070 let (session, effective_runtime_policy) = match spawn {
7071 Ok(spawned) => spawned,
7072 Err(error) => {
7073 if self
7074 .cleanup_managed_worktree(&lease.lease_id, true)
7075 .await
7076 .is_err()
7077 {
7078 self.mark_managed_recovery_required(
7079 &lease.lease_id,
7080 ManagedWorktreeCleanupFailure::Backend,
7081 );
7082 }
7083 let result = Err(error);
7084 self.remember_managed_spawn_attempt(
7085 durable_v2_key,
7086 request,
7087 result.clone(),
7088 accepted_at,
7089 );
7090 return result;
7091 }
7092 };
7093 let (record_id, raw_inventory_record_id) = {
7094 let mut bindings = self.session_bindings.lock()
7095 .unwrap_or_else(|poisoned| poisoned.into_inner());
7096 let binding = bindings.get_mut(&session.session.instance_id)
7097 .expect("successful spawn retains its session binding");
7098 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7099 managed_spawn_record_ownership(binding, effective_runtime_policy)
7100 };
7101 let bound = self.managed_worktrees.lock()
7102 .unwrap_or_else(|poisoned| poisoned.into_inner())
7103 .bind_session(
7104 &lease.lease_id,
7105 ManagedWorktreeSessionHolder {
7106 incarnation_id: self.incarnation_id,
7107 instance_id: session.session.instance_id,
7108 generation: session.session.generation,
7109 },
7110 record_id,
7111 unix_time_ms(),
7112 )
7113 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7114 let snapshot = match bound {
7115 Ok(snapshot) => snapshot,
7116 Err(error) => {
7117 if raw_inventory_record_id
7118 .as_ref()
7119 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7120 {
7121 self.mark_managed_recovery_required(
7122 &lease.lease_id,
7123 ManagedWorktreeCleanupFailure::Backend,
7124 );
7125 }
7126 let _ = self.remove_session(&session).await;
7127 self.mark_managed_recovery_required(
7128 &lease.lease_id,
7129 ManagedWorktreeCleanupFailure::Backend,
7130 );
7131 let result = Err(error);
7132 self.remember_managed_spawn_attempt(
7133 durable_v2_key,
7134 request,
7135 result.clone(),
7136 accepted_at,
7137 );
7138 return result;
7139 }
7140 };
7141 if let Err(error) = self.persist_state() {
7142 if raw_inventory_record_id
7143 .as_ref()
7144 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7145 {
7146 self.mark_managed_recovery_required(
7147 &lease.lease_id,
7148 ManagedWorktreeCleanupFailure::Backend,
7149 );
7150 }
7151 let _ = self.remove_session(&session).await;
7152 if self
7153 .cleanup_managed_worktree(&lease.lease_id, true)
7154 .await
7155 .is_err()
7156 {
7157 self.mark_managed_recovery_required(
7158 &lease.lease_id,
7159 ManagedWorktreeCleanupFailure::Backend,
7160 );
7161 }
7162 let result = Err(persistence_failure(error));
7163 self.remember_managed_spawn_attempt(
7164 durable_v2_key,
7165 request,
7166 result.clone(),
7167 accepted_at,
7168 );
7169 return result;
7170 }
7171 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7172 let receipt = managed_spawn_receipt(
7173 &resolved,
7174 self.incarnation_id,
7175 session,
7176 snapshot,
7177 environment_profile,
7178 bundle,
7179 context,
7180 );
7181 let result = Ok(receipt);
7182 self.remember_managed_spawn_attempt(
7183 durable_v2_key,
7184 request,
7185 result.clone(),
7186 accepted_at,
7187 );
7188 result
7189 }
7190
7191 async fn spawn_managed_worktree_v2(
7192 &self,
7193 request: ManagedWorktreeSpawnRequestV2,
7194 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7195 if request.spawn_spec.target.node_id != self.node_id {
7196 return Err(failure(
7197 NodeFailureCode::SpawnTargetMismatch,
7198 "spawn target does not match this node",
7199 ));
7200 }
7201 if request.spawn_spec.target.worktree_id.is_some() {
7202 return Err(failure(
7203 NodeFailureCode::InvalidRequest,
7204 "managed worktree spawn must not select a caller-provided worktree",
7205 ));
7206 }
7207 let request_digest = managed_spawn_request_digest_v2(&request)?;
7208 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7209 match replay {
7210 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7211 DurableManagedSpawnReplayDecision::PendingLinked => {
7212 return Err(failure(
7213 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7214 "managed worktree V2 reservation has an incomplete durable allocation",
7215 ));
7216 }
7217 DurableManagedSpawnReplayDecision::Reserved => {}
7218 }
7219 let validated = (|| {
7220 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7221 let profile = self.managed_profile(
7222 &resolved.target.workspace_id,
7223 &request.worktree_profile_id,
7224 )?;
7225 if profile.revision() != &request.expected_profile_revision {
7226 return Err(failure(
7227 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7228 "managed worktree profile revision does not match the loaded profile",
7229 ));
7230 }
7231 Ok(())
7232 })();
7233 match validated {
7234 Ok(()) => {}
7235 Err(error) => {
7236 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7237 return Err(error);
7238 }
7239 }
7240 let legacy = ManagedWorktreeSpawnRequest {
7241 spawn_spec: request.spawn_spec.clone(),
7242 worktree_profile_id: request.worktree_profile_id.clone(),
7243 };
7244 match self
7245 .spawn_managed_worktree_inner(
7246 legacy,
7247 Some(&request.spawn_spec.idempotency_key),
7248 )
7249 .await
7250 {
7251 Ok(receipt) => {
7252 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7253 Ok(receipt)
7254 }
7255 Err(error) => {
7256 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7257 Err(error)
7258 }
7259 }
7260 }
7261
7262 fn managed_profile(
7263 &self,
7264 workspace_id: &WorkspaceId,
7265 profile_id: &WorktreeProfileId,
7266 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7267 if self.worktree_service_modes.get(workspace_id).copied()
7268 != Some(WorktreeServiceMode::Managed)
7269 {
7270 return Err(failure(
7271 NodeFailureCode::UnsupportedSpawnCapability,
7272 "workspace does not enable managed worktrees",
7273 ));
7274 }
7275 self.managed_worktree_profiles.get(workspace_id)
7276 .and_then(|profiles| profiles.get(profile_id))
7277 .cloned()
7278 .ok_or_else(|| failure(
7279 NodeFailureCode::InvalidRequest,
7280 "managed worktree profile is unavailable for this workspace",
7281 ))
7282 }
7283
7284 async fn reconcile_failed_allocation(
7285 &self,
7286 lease: &ManagedWorktreeLeaseRecord,
7287 source_root: &str,
7288 ) {
7289 match list_git_worktrees(source_root).await {
7290 Ok(worktrees) => {
7291 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7292 None => {
7293 match std::fs::symlink_metadata(&lease.target_root) {
7294 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7295 self.managed_worktrees.lock()
7296 .unwrap_or_else(|poisoned| poisoned.into_inner())
7297 .remove_unmutated(&lease.lease_id);
7298 let _ = self.persist_state();
7299 }
7300 _ => self.mark_managed_recovery_required(
7301 &lease.lease_id,
7302 ManagedWorktreeCleanupFailure::OwnershipConflict,
7303 ),
7304 }
7305 }
7306 Some(item) if exact_owned_worktree(
7307 lease,
7308 &item.path,
7309 item.branch.as_deref(),
7310 &item.head,
7311 ) => {
7312 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7313 }
7314 Some(_) => self.mark_managed_recovery_required(
7315 &lease.lease_id,
7316 ManagedWorktreeCleanupFailure::OwnershipConflict,
7317 ),
7318 }
7319 }
7320 Err(_) => self.mark_managed_recovery_required(
7321 &lease.lease_id,
7322 ManagedWorktreeCleanupFailure::Backend,
7323 ),
7324 }
7325 }
7326
7327 async fn reconcile_managed_worktrees(&self) {
7328 let leases = self.managed_worktrees.lock()
7329 .unwrap_or_else(|poisoned| poisoned.into_inner())
7330 .active_records()
7331 .cloned()
7332 .collect::<Vec<_>>();
7333 for lease in leases {
7334 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7335 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7336 _ => {
7337 self.mark_managed_recovery_required(
7338 &lease.lease_id,
7339 ManagedWorktreeCleanupFailure::OwnershipConflict,
7340 );
7341 continue;
7342 }
7343 };
7344 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7345 self.mark_managed_recovery_required(
7346 &lease.lease_id,
7347 ManagedWorktreeCleanupFailure::OwnershipConflict,
7348 );
7349 continue;
7350 }
7351 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7352 Ok(root) => root,
7353 Err(_) => {
7354 self.mark_managed_recovery_required(
7355 &lease.lease_id,
7356 ManagedWorktreeCleanupFailure::OwnershipConflict,
7357 );
7358 continue;
7359 }
7360 };
7361 let worktrees = match list_git_worktrees(&source_root).await {
7362 Ok(worktrees) => worktrees,
7363 Err(_) => {
7364 self.mark_managed_recovery_required(
7365 &lease.lease_id,
7366 ManagedWorktreeCleanupFailure::Backend,
7367 );
7368 continue;
7369 }
7370 };
7371 let listed = worktrees.iter()
7372 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7373 let Some(listed) = listed else {
7374 match std::fs::symlink_metadata(&lease.target_root) {
7375 Err(error) if error.kind() == io::ErrorKind::NotFound
7376 && lease.state == ManagedWorktreeLeaseState::Allocating
7377 && !lease.has_holders() => {
7378 let removed = self.managed_worktrees.lock()
7379 .unwrap_or_else(|poisoned| poisoned.into_inner())
7380 .tombstone(&lease.lease_id, unix_time_ms());
7381 if self.persist_state().is_ok() && removed.is_some() {
7382 self.publish(NodeEvent::ManagedWorktreeRemoved {
7383 lease_id: lease.lease_id.clone(),
7384 });
7385 }
7386 }
7387 _ => self.mark_managed_recovery_required(
7388 &lease.lease_id,
7389 ManagedWorktreeCleanupFailure::OwnershipConflict,
7390 ),
7391 }
7392 continue;
7393 };
7394 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7395 &lease,
7396 &listed.path,
7397 listed.branch.as_deref(),
7398 &listed.head,
7399 ) {
7400 self.mark_managed_recovery_required(
7401 &lease.lease_id,
7402 ManagedWorktreeCleanupFailure::OwnershipConflict,
7403 );
7404 continue;
7405 }
7406 if !self.workspaces.read()
7407 .unwrap_or_else(|poisoned| poisoned.into_inner())
7408 .contains_key(&lease.workspace_id)
7409 {
7410 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7411 self.mark_managed_recovery_required(
7412 &lease.lease_id,
7413 ManagedWorktreeCleanupFailure::Backend,
7414 );
7415 continue;
7416 }
7417 }
7418 let snapshot = {
7419 let mut registry = self.managed_worktrees.lock()
7420 .unwrap_or_else(|poisoned| poisoned.into_inner());
7421 let current = registry.get_mut(&lease.lease_id)
7422 .expect("reconciled managed lease remains present");
7423 current.expected_head = Some(listed.head.clone());
7424 current.cleanup_failure = None;
7425 current.state = if current.has_holders() {
7426 ManagedWorktreeLeaseState::InUse
7427 } else if current.retention == ManagedWorktreeRetention::Retain {
7428 ManagedWorktreeLeaseState::Retained
7429 } else {
7430 ManagedWorktreeLeaseState::Ready
7431 };
7432 current.updated_at_unix_ms = unix_time_ms();
7433 current.snapshot()
7434 };
7435 if self.persist_state().is_err() {
7436 self.mark_managed_recovery_required(
7437 &lease.lease_id,
7438 ManagedWorktreeCleanupFailure::Backend,
7439 );
7440 continue;
7441 }
7442 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7443 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7444 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7445 }
7446 }
7447 }
7448
7449 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7469 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7470 .session_records
7471 .lock()
7472 .unwrap_or_else(|poisoned| poisoned.into_inner())
7473 .records
7474 .values()
7475 .filter(|record| {
7476 record.state == ManagedSessionState::Live
7477 && record.exported_context.is_none()
7478 && record.provider_session.is_some()
7479 })
7480 .filter_map(|record| {
7481 record
7482 .active_session
7483 .clone()
7484 .map(|session| (record.record_id.clone(), session))
7485 })
7486 .collect();
7487 for (record_id, session) in candidates {
7488 let shared = Arc::clone(self);
7489 tokio::spawn(async move {
7490 shared
7491 .reconcile_context_pack_export_for_record(&record_id, session)
7492 .await;
7493 });
7494 }
7495 }
7496
7497 async fn reconcile_context_pack_export_for_record(
7516 &self,
7517 record_id: &SessionRecordId,
7518 session: SessionAddress,
7519 ) {
7520 let Ok(record) = self.record(record_id) else {
7521 return;
7522 };
7523 if !matches!(
7524 record.state,
7525 ManagedSessionState::Live | ManagedSessionState::Dormant
7526 ) || record.exported_context.is_some()
7527 || record.provider_session.is_none()
7528 {
7529 return;
7530 }
7531 let Ok(receipt) = self
7538 .export_context_pack_for_session_record(&record.record_id, &session, true)
7539 .await
7540 else {
7541 return;
7542 };
7543 self.set_exported_context(&record.record_id, receipt);
7544 }
7545
7546 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7551 let transaction = self
7552 .state_transaction
7553 .lock()
7554 .unwrap_or_else(|poisoned| poisoned.into_inner());
7555 let (previous, updated) = {
7556 let mut records = self
7557 .session_records
7558 .lock()
7559 .unwrap_or_else(|poisoned| poisoned.into_inner());
7560 let Some(record) = records.records.get_mut(record_id) else {
7561 return;
7562 };
7563 if record.exported_context.is_some() {
7564 return;
7565 }
7566 let previous = record.clone();
7567 record.exported_context = Some(receipt);
7568 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7569 (previous, record.clone())
7570 };
7571 if self.persist_state_locked().is_err() {
7572 self.session_records
7573 .lock()
7574 .unwrap_or_else(|poisoned| poisoned.into_inner())
7575 .records
7576 .insert(record_id.clone(), previous);
7577 return;
7578 }
7579 drop(transaction);
7580 self.publish_record(updated);
7581 }
7582
7583 fn managed_record_export_target_for_instance(
7593 &self,
7594 instance_id: AgentInstanceId,
7595 generation: SessionGeneration,
7596 ) -> Option<(SessionRecordId, SessionAddress)> {
7597 let bindings = self
7598 .session_bindings
7599 .lock()
7600 .unwrap_or_else(|poisoned| poisoned.into_inner());
7601 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7602 let record_id = binding.record_id.clone()?;
7603 Some((
7604 record_id,
7605 SessionAddress {
7606 workspace_id: binding.workspace_id.clone(),
7607 session: SessionKey { instance_id, generation },
7608 },
7609 ))
7610 }
7611
7612 fn mark_managed_recovery_required(
7613 &self,
7614 lease_id: &ManagedWorktreeLeaseId,
7615 failure_kind: ManagedWorktreeCleanupFailure,
7616 ) {
7617 let snapshot = {
7618 let mut registry = self.managed_worktrees.lock()
7619 .unwrap_or_else(|poisoned| poisoned.into_inner());
7620 let Some(lease) = registry.get_mut(lease_id) else { return };
7621 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7622 lease.cleanup_failure = Some(failure_kind);
7623 lease.updated_at_unix_ms = unix_time_ms();
7624 lease.snapshot()
7625 };
7626 let _ = self.persist_state();
7627 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7628 }
7629
7630 fn set_persistence_error(&self, error: Option<String>) {
7631 *self
7632 .persistence_error
7633 .write()
7634 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7635 .map(|message| session_registry::sanitized_persistence_summary(&message));
7636 }
7637
7638 fn persist_state(&self) -> io::Result<()> {
7639 let _transaction = self
7640 .state_transaction
7641 .lock()
7642 .unwrap_or_else(|poisoned| poisoned.into_inner());
7643 self.persist_state_locked()
7644 }
7645
7646 fn persist_state_locked(&self) -> io::Result<()> {
7647 let workspaces = self
7648 .workspaces
7649 .read()
7650 .unwrap_or_else(|poisoned| poisoned.into_inner())
7651 .clone();
7652 let records = self
7653 .session_records
7654 .lock()
7655 .unwrap_or_else(|poisoned| poisoned.into_inner())
7656 .records
7657 .values()
7658 .cloned()
7659 .collect::<Vec<_>>();
7660 let mut managed = self.managed_worktrees
7661 .lock()
7662 .unwrap_or_else(|poisoned| poisoned.into_inner());
7663 managed.reattach_record_holders(
7664 &managed_worktree_record_holders(&records),
7665 unix_time_ms(),
7666 );
7667 let materializations = self
7668 .materializations
7669 .lock()
7670 .unwrap_or_else(|poisoned| poisoned.into_inner())
7671 .values()
7672 .cloned()
7673 .collect::<Vec<_>>();
7674 let managed_spawn_replays = self.managed_spawn_replays
7675 .lock()
7676 .unwrap_or_else(|poisoned| poisoned.into_inner())
7677 .values()
7678 .cloned()
7679 .collect::<Vec<_>>();
7680 let result = session_registry::save_v10(
7681 self.state_path.as_deref(),
7682 &self.node_id,
7683 &workspaces,
7684 &records,
7685 &managed.records(),
7686 &managed.tombstones(),
7687 &materializations,
7688 &managed_spawn_replays,
7689 );
7690 match &result {
7691 Ok(warning) => self.set_persistence_error(warning.clone()),
7692 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7693 }
7694 result.map(|_| ())
7695 }
7696
7697 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7698 let _mutation_guard = self.mutation_gate.lock().await;
7699 self.begin_shutdown_locked().await
7700 }
7701
7702 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7703 if self.shutdown.swap(true, Ordering::AcqRel) {
7704 return Ok(());
7705 }
7706 SessionLifecycleHook::on_shutdown(self);
7707 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7708 let mut first_error = None;
7709 for session in &self.handle.snapshot().sessions {
7710 let remaining = deadline.saturating_duration_since(Instant::now());
7711 if remaining.is_zero() {
7712 first_error.get_or_insert_with(|| {
7713 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7714 });
7715 break;
7716 }
7717 if let Err(error) = self
7718 .dispatch_bounded(
7719 ControlCommand::Stop {
7720 instance_id: session.instance_id,
7721 force: true,
7722 },
7723 remaining,
7724 )
7725 .await
7726 {
7727 first_error.get_or_insert(error);
7728 }
7729 }
7730 self.shutdown_notify.notify_waiters();
7731 match first_error {
7732 Some(error) => Err(error),
7733 None => Ok(()),
7734 }
7735 }
7736
7737 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7738 let bindings = self
7739 .session_bindings
7740 .lock()
7741 .unwrap_or_else(|poisoned| poisoned.into_inner());
7742 if bindings.len() >= CONTROL_SESSIONS_MAX {
7743 Err(failure(
7744 NodeFailureCode::BackendBusy,
7745 "node session-binding capacity is full; remove an existing session first",
7746 ))
7747 } else {
7748 Ok(())
7749 }
7750 }
7751
7752 fn ensure_workspace_environment_profile_compatible(
7759 &self,
7760 workspace_id: &WorkspaceId,
7761 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7762 ) -> Result<(), NodeFailure> {
7763 let bindings = self
7764 .session_bindings
7765 .lock()
7766 .unwrap_or_else(|poisoned| poisoned.into_inner());
7767 for binding in bindings.values() {
7768 if &binding.workspace_id != workspace_id {
7769 continue;
7770 }
7771 if binding.environment_profile.as_ref() != environment_profile {
7772 return Err(failure(
7773 NodeFailureCode::WorkspaceBusy,
7774 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7775 ));
7776 }
7777 }
7778 Ok(())
7779 }
7780
7781 #[cfg(test)]
7782 fn bind_session(
7783 &self,
7784 instance_id: AgentInstanceId,
7785 workspace_id: WorkspaceId,
7786 generation: SessionGeneration,
7787 ) {
7788 self.bind_session_with_policy(
7789 &SessionAddress {
7790 workspace_id,
7791 session: SessionKey { instance_id, generation },
7792 },
7793 ProviderRuntimePolicy::raw_pty(),
7794 None,
7795 );
7796 }
7797
7798 #[cfg(test)]
7799 fn bind_session_with_policy(
7800 &self,
7801 address: &SessionAddress,
7802 runtime_policy: ProviderRuntimePolicy,
7803 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7804 ) {
7805 self.bind_session_with_materialization(
7806 address,
7807 runtime_policy,
7808 environment_profile,
7809 None,
7810 None,
7811 None,
7812 );
7813 }
7814
7815 fn bind_session_with_materialization(
7816 &self,
7817 address: &SessionAddress,
7818 runtime_policy: ProviderRuntimePolicy,
7819 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7820 bundle: Option<ResolvedBundleReceipt>,
7821 context: Option<ResolvedContextPackReceipt>,
7822 materialization_id: Option<MaterializationId>,
7823 ) {
7824 let mut bindings = self
7825 .session_bindings
7826 .lock()
7827 .unwrap_or_else(|poisoned| poisoned.into_inner());
7828 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7829 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7830 bindings.insert(address.session.instance_id, SessionBinding {
7831 workspace_id: address.workspace_id.clone(),
7832 generation: address.session.generation,
7833 runtime_policy,
7834 pending_resume: None,
7835 record_id: None,
7836 managed_worktree_lease_id: None,
7837 environment_profile,
7838 bundle,
7839 context,
7840 materialization_id,
7841 });
7842 }
7843
7844 #[cfg(test)]
7845 fn bind_managed_session(
7846 &self,
7847 address: &SessionAddress,
7848 record_id: SessionRecordId,
7849 runtime_policy: ProviderRuntimePolicy,
7850 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7851 ) {
7852 self.bind_managed_session_with_materialization(
7853 address,
7854 record_id,
7855 runtime_policy,
7856 environment_profile,
7857 None,
7858 None,
7859 None,
7860 );
7861 }
7862
7863 fn bind_managed_session_with_materialization(
7864 &self,
7865 address: &SessionAddress,
7866 record_id: SessionRecordId,
7867 runtime_policy: ProviderRuntimePolicy,
7868 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7869 bundle: Option<ResolvedBundleReceipt>,
7870 context: Option<ResolvedContextPackReceipt>,
7871 materialization_id: Option<MaterializationId>,
7872 ) {
7873 let mut bindings = self
7874 .session_bindings
7875 .lock()
7876 .unwrap_or_else(|poisoned| poisoned.into_inner());
7877 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7878 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7879 bindings.insert(
7880 address.session.instance_id,
7881 SessionBinding {
7882 workspace_id: address.workspace_id.clone(),
7883 generation: address.session.generation,
7884 runtime_policy,
7885 pending_resume: None,
7886 record_id: Some(record_id),
7887 managed_worktree_lease_id: None,
7888 environment_profile,
7889 bundle,
7890 context,
7891 materialization_id,
7892 },
7893 );
7894 }
7895
7896 #[cfg(test)]
7897 fn bind_spawn_session(
7898 &self,
7899 address: &SessionAddress,
7900 provider: AgentId,
7901 mode: SessionMode,
7902 runtime_policy: ProviderRuntimePolicy,
7903 record_policy: SpawnRecordPolicy,
7904 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7905 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7906 self.bind_spawn_session_with_materialization(
7907 address,
7908 provider,
7909 mode,
7910 runtime_policy,
7911 record_policy,
7912 environment_profile,
7913 None,
7914 None,
7915 None,
7916 )
7917 }
7918
7919 fn bind_spawn_session_with_materialization(
7920 &self,
7921 address: &SessionAddress,
7922 provider: AgentId,
7923 mode: SessionMode,
7924 runtime_policy: ProviderRuntimePolicy,
7925 record_policy: SpawnRecordPolicy,
7926 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7927 bundle: Option<ResolvedBundleReceipt>,
7928 context: Option<ResolvedContextPackReceipt>,
7929 materialization_id: Option<MaterializationId>,
7930 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7931 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7932 && !runtime_policy.provider_session_identity
7933 {
7934 self.bind_session_with_materialization(
7935 address,
7936 runtime_policy,
7937 environment_profile,
7938 bundle,
7939 context,
7940 materialization_id,
7941 );
7942 return Ok(None);
7943 }
7944 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7945 let record = self.new_record(
7946 address,
7947 provider,
7948 mode,
7949 active_record_state(runtime_policy.provider_session_identity, false),
7950 None,
7951 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7952 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7953 record_owns_lifecycle.then(|| context.clone()).flatten(),
7954 )?;
7955 let record_id = record.record_id.clone();
7956 let transaction = self
7957 .state_transaction
7958 .lock()
7959 .unwrap_or_else(|poisoned| poisoned.into_inner());
7960 self.insert_record(record.clone())?;
7961 let previous_materialization = if record_owns_lifecycle {
7962 if let Some(materialization_id) = materialization_id.as_ref() {
7963 let mut materializations = self
7964 .materializations
7965 .lock()
7966 .unwrap_or_else(|poisoned| poisoned.into_inner());
7967 let Some(ownership) = materializations.get_mut(materialization_id) else {
7968 drop(materializations);
7969 self.remove_record_memory(&record_id);
7970 return Err(failure(
7971 NodeFailureCode::BackendOperationFailed,
7972 "session environment ownership disappeared before binding",
7973 ));
7974 };
7975 let previous = ownership.clone();
7976 if ownership
7977 .transfer_to_record(record_id.clone(), unix_time_ms())
7978 .is_err()
7979 {
7980 drop(materializations);
7981 self.remove_record_memory(&record_id);
7982 return Err(failure(
7983 NodeFailureCode::BackendOperationFailed,
7984 "session environment ownership transfer failed",
7985 ));
7986 }
7987 Some(previous)
7988 } else {
7989 None
7990 }
7991 } else {
7992 None
7993 };
7994 self.bind_managed_session_with_materialization(
7995 address,
7996 record_id.clone(),
7997 runtime_policy,
7998 environment_profile,
7999 bundle,
8000 context,
8001 materialization_id.clone(),
8002 );
8003 if let Err(error) = self.persist_state_locked() {
8004 self.remove_binding(address);
8005 self.remove_record_memory(&record_id);
8006 if let (Some(materialization_id), Some(previous)) =
8007 (materialization_id.as_ref(), previous_materialization)
8008 {
8009 self.materializations
8010 .lock()
8011 .unwrap_or_else(|poisoned| poisoned.into_inner())
8012 .insert(materialization_id.clone(), previous);
8013 }
8014 return Err(persistence_failure(error));
8015 }
8016 drop(transaction);
8017 self.publish_record(record);
8018 Ok(Some(record_id))
8019 }
8020
8021 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8022 for _ in 0..8 {
8023 let bytes = random_nonce().map_err(|error| {
8024 failure(
8025 NodeFailureCode::BackendOperationFailed,
8026 &format!("session record identity generation failed: {error}"),
8027 )
8028 })?;
8029 let mut value = String::with_capacity(27);
8030 value.push_str("sr-");
8031 for byte in &bytes[..12] {
8032 use std::fmt::Write as _;
8033 let _ = write!(&mut value, "{byte:02x}");
8034 }
8035 let record_id = SessionRecordId::new(value).map_err(|error| {
8036 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8037 })?;
8038 let records = self
8039 .session_records
8040 .lock()
8041 .unwrap_or_else(|poisoned| poisoned.into_inner());
8042 if !records.records.contains_key(&record_id) {
8043 return Ok(record_id);
8044 }
8045 }
8046 Err(failure(
8047 NodeFailureCode::BackendBusy,
8048 "could not allocate a unique session record ID",
8049 ))
8050 }
8051
8052 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8053 for _ in 0..8 {
8054 let bytes = random_nonce().map_err(|error| {
8055 failure(
8056 NodeFailureCode::BackendOperationFailed,
8057 &format!("task identity generation failed: {error}"),
8058 )
8059 })?;
8060 let mut nonce = [0_u8; 12];
8061 nonce.copy_from_slice(&bytes[..12]);
8062 let task_id = TaskId::from_nonce(nonce);
8063 let records = self
8064 .session_records
8065 .lock()
8066 .unwrap_or_else(|poisoned| poisoned.into_inner());
8067 if !records.records.values().any(|record| {
8068 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8069 == Some(&task_id)
8070 }) {
8071 return Ok(task_id);
8072 }
8073 }
8074 Err(failure(
8075 NodeFailureCode::BackendBusy,
8076 "could not allocate a unique task ID",
8077 ))
8078 }
8079
8080 fn default_record_name(&self, provider: &AgentId) -> String {
8081 let records = self
8082 .session_records
8083 .lock()
8084 .unwrap_or_else(|poisoned| poisoned.into_inner());
8085 let ordinal = records
8086 .records
8087 .values()
8088 .filter(|record| &record.provider == provider)
8089 .count()
8090 .saturating_add(1);
8091 format!("{} #{ordinal}", provider.as_str())
8092 }
8093
8094 fn new_record(
8095 &self,
8096 address: &SessionAddress,
8097 provider: AgentId,
8098 mode: SessionMode,
8099 state: ManagedSessionState,
8100 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8101 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8102 bundle: Option<ResolvedBundleReceipt>,
8103 context: Option<ResolvedContextPackReceipt>,
8104 ) -> Result<ManagedSessionRecord, NodeFailure> {
8105 let canonical_root = self.workspace_root(&address.workspace_id)?;
8106 let now = unix_time_ms();
8107 Ok(ManagedSessionRecord {
8108 record_id: self.allocate_record_id()?,
8109 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8110 provider,
8111 mode,
8112 state,
8113 workspace_id: address.workspace_id.clone(),
8114 canonical_root: opaque_windows_path(canonical_root),
8115 provider_session,
8116 active_session: Some(address.clone()),
8117 environment_profile,
8118 bundle,
8119 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8120 context,
8121 exported_context: None,
8122 task_binding: None,
8123 created_at_unix_ms: now,
8124 updated_at_unix_ms: now,
8125 last_error: None,
8126 })
8127 }
8128
8129 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8130 let mut records = self
8131 .session_records
8132 .lock()
8133 .unwrap_or_else(|poisoned| poisoned.into_inner());
8134 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8135 return Err(failure(
8136 NodeFailureCode::BackendBusy,
8137 "managed session record capacity is full; forget an inactive record first",
8138 ));
8139 }
8140 if let Some(identity) = record.provider_session.as_ref() {
8141 if records.records.values().any(|candidate| {
8142 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8143 session_registry::same_provider_session(
8144 &candidate.provider,
8145 candidate_identity,
8146 &record.provider,
8147 identity,
8148 )
8149 })
8150 }) {
8151 return Err(failure(
8152 NodeFailureCode::SessionRecordConflict,
8153 "provider session is already represented by another managed record",
8154 ));
8155 }
8156 }
8157 match records.records.entry(record.record_id.clone()) {
8158 std::collections::btree_map::Entry::Vacant(entry) => {
8159 entry.insert(record);
8160 }
8161 std::collections::btree_map::Entry::Occupied(_) => {
8162 return Err(failure(
8163 NodeFailureCode::SessionRecordConflict,
8164 "session record ID collision",
8165 ));
8166 }
8167 }
8168 Ok(())
8169 }
8170
8171 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8172 self.session_records
8173 .lock()
8174 .unwrap_or_else(|poisoned| poisoned.into_inner())
8175 .records
8176 .get(record_id)
8177 .cloned()
8178 .ok_or_else(|| {
8179 failure(
8180 NodeFailureCode::UnknownSessionRecord,
8181 "managed session record does not exist",
8182 )
8183 })
8184 }
8185
8186 fn revalidate_session_record_preview(
8187 &self,
8188 expected: &ManagedSessionRecord,
8189 identity: &ProviderSessionIdentity,
8190 ) -> Result<(), NodeFailure> {
8191 let current = self.record(&expected.record_id)?;
8192 let current_root = self.workspace_root(¤t.workspace_id)?;
8193 if current.provider != expected.provider
8194 || current.workspace_id != expected.workspace_id
8195 || current.canonical_root != expected.canonical_root
8196 || current.provider_session.as_ref() != Some(identity)
8197 || !platform::roots_equal(
8198 ¤t_root,
8199 windows_path_text(¤t.canonical_root),
8200 )
8201 {
8202 return Err(failure(
8203 NodeFailureCode::SessionRecordConflict,
8204 "managed session changed while its preview was loading",
8205 ));
8206 }
8207 Ok(())
8208 }
8209
8210 fn revalidate_session_record_context_export(
8211 &self,
8212 expected: &ManagedSessionRecord,
8213 identity: Option<&ProviderSessionIdentity>,
8214 session: &SessionAddress,
8215 allow_clean_detachment: bool,
8216 ) -> Result<(), NodeFailure> {
8217 let current = self.record(&expected.record_id)?;
8218 let current_root = self.workspace_root(¤t.workspace_id)?;
8219 let snapshot = self.internal_session_snapshot(session)?;
8220 let binding = self
8221 .session_bindings
8222 .lock()
8223 .unwrap_or_else(|poisoned| poisoned.into_inner())
8224 .get(&session.session.instance_id)
8225 .cloned()
8226 .ok_or_else(|| {
8227 failure(
8228 NodeFailureCode::SessionRecordConflict,
8229 "managed session runtime binding disappeared during context export",
8230 )
8231 })?;
8232 if !session_record_context_export_binding_is_exact(
8233 expected,
8234 ¤t,
8235 identity,
8236 session,
8237 &binding,
8238 &snapshot.agent_id,
8239 ¤t_root,
8240 allow_clean_detachment,
8241 ) || !session_record_context_export_source_is_usable(
8242 ¤t,
8243 &snapshot.status,
8244 allow_clean_detachment,
8245 ) {
8246 return Err(failure(
8247 NodeFailureCode::SessionRecordConflict,
8248 "managed session binding changed during context export",
8249 ));
8250 }
8251 Ok(())
8252 }
8253
8254 fn revalidate_loaded_history(
8255 &self,
8256 session: &SessionAddress,
8257 candidate_id: &str,
8258 loaded: &HistorySessionRecord,
8259 ) -> Result<(), NodeFailure> {
8260 let snapshot = self.internal_session_snapshot(session)?;
8261 if snapshot.history.pending.is_some()
8262 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8263 || snapshot.history.loaded.as_ref() != Some(loaded)
8264 {
8265 return Err(failure(
8266 NodeFailureCode::SessionRecordConflict,
8267 "managed session history changed during context export",
8268 ));
8269 }
8270 Ok(())
8271 }
8272
8273 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8274 self.session_records
8275 .lock()
8276 .unwrap_or_else(|poisoned| poisoned.into_inner())
8277 .records
8278 .remove(record_id)
8279 }
8280
8281 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8282 if self.destroy_record_materialization(record_id)? {
8283 self.publish(NodeEvent::SessionRecordRemoved {
8284 record_id: record_id.clone(),
8285 });
8286 return Ok(());
8287 }
8288 let transaction = self
8289 .state_transaction
8290 .lock()
8291 .unwrap_or_else(|poisoned| poisoned.into_inner());
8292 let Some(removed) = self.remove_record_memory(record_id) else {
8293 return Ok(());
8294 };
8295 if let Err(error) = self.persist_state_locked() {
8296 let _ = self.insert_record(removed);
8297 return Err(persistence_failure(error));
8298 }
8299 drop(transaction);
8300 self.publish(NodeEvent::SessionRecordRemoved {
8301 record_id: record_id.clone(),
8302 });
8303 Ok(())
8304 }
8305
8306 fn publish_record(&self, record: ManagedSessionRecord) {
8307 self.publish(NodeEvent::SessionRecordUpserted { record });
8308 }
8309
8310 fn mark_record_error(
8311 &self,
8312 record_id: &SessionRecordId,
8313 message: &str,
8314 ) -> Result<(), NodeFailure> {
8315 let transaction = self
8316 .state_transaction
8317 .lock()
8318 .unwrap_or_else(|poisoned| poisoned.into_inner());
8319 let previous = {
8320 let mut records = self
8321 .session_records
8322 .lock()
8323 .unwrap_or_else(|poisoned| poisoned.into_inner());
8324 let Some(record) = records.records.get_mut(record_id) else {
8325 return Ok(());
8326 };
8327 let previous = record.clone();
8328 record.state = ManagedSessionState::Unavailable;
8329 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8330 record.updated_at_unix_ms = unix_time_ms();
8331 previous
8332 };
8333 if let Err(error) = self.persist_state_locked() {
8334 self.session_records
8335 .lock()
8336 .unwrap_or_else(|poisoned| poisoned.into_inner())
8337 .records
8338 .insert(record_id.clone(), previous);
8339 return Err(persistence_failure(error));
8340 }
8341 let record = self.record(record_id)?;
8342 drop(transaction);
8343 self.publish_record(record);
8344 Ok(())
8345 }
8346
8347 fn rename_session_record(
8348 &self,
8349 record_id: &SessionRecordId,
8350 display_name: String,
8351 ) -> Result<ManagedSessionRecord, NodeFailure> {
8352 validate_display_name(&display_name)
8353 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8354 let _transaction = self
8355 .state_transaction
8356 .lock()
8357 .unwrap_or_else(|poisoned| poisoned.into_inner());
8358 let previous = {
8359 let mut records = self
8360 .session_records
8361 .lock()
8362 .unwrap_or_else(|poisoned| poisoned.into_inner());
8363 let record = records.records.get_mut(record_id).ok_or_else(|| {
8364 failure(
8365 NodeFailureCode::UnknownSessionRecord,
8366 "managed session record does not exist",
8367 )
8368 })?;
8369 let previous = record.clone();
8370 record.display_name = display_name;
8371 record.updated_at_unix_ms = unix_time_ms();
8372 previous
8373 };
8374 if let Err(error) = self.persist_state_locked() {
8375 self.session_records
8376 .lock()
8377 .unwrap_or_else(|poisoned| poisoned.into_inner())
8378 .records
8379 .insert(record_id.clone(), previous);
8380 return Err(persistence_failure(error));
8381 }
8382 let updated = self.record(record_id)?;
8383 self.publish_record(updated.clone());
8384 Ok(updated)
8385 }
8386
8387 fn set_session_task(
8388 &self,
8389 record_id: &SessionRecordId,
8390 expected_revision: u64,
8391 target: SessionTaskTargetV1,
8392 ) -> Result<ManagedSessionRecord, NodeFailure> {
8393 let transaction = self
8394 .state_transaction
8395 .lock()
8396 .unwrap_or_else(|poisoned| poisoned.into_inner());
8397 let current = self.record(record_id)?;
8398 let current_task_id = current
8399 .task_binding
8400 .as_ref()
8401 .and_then(|binding| binding.task_id.as_ref());
8402 let current_revision = current
8403 .task_binding
8404 .as_ref()
8405 .map_or(0, |binding| binding.revision);
8406 if expected_revision != current_revision {
8407 return Err(failure(
8408 NodeFailureCode::SessionRecordConflict,
8409 "managed session task binding revision changed",
8410 ));
8411 }
8412 let exact = match &target {
8413 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8414 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8415 SessionTaskTargetV1::New => false,
8416 };
8417 if exact {
8418 return Ok(current);
8419 }
8420 let revision = current_revision.checked_add(1).ok_or_else(|| {
8421 failure(
8422 NodeFailureCode::SessionRecordConflict,
8423 "managed session task binding revision is exhausted",
8424 )
8425 })?;
8426 let task_id = match target {
8427 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8428 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8429 SessionTaskTargetV1::Clear => None,
8430 };
8431 let changed_at_unix_ms = unix_time_ms()
8432 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8433 failure(
8434 NodeFailureCode::SessionRecordConflict,
8435 "managed session record timestamp is exhausted",
8436 )
8437 })?);
8438 let updated = {
8439 let mut records = self
8440 .session_records
8441 .lock()
8442 .unwrap_or_else(|poisoned| poisoned.into_inner());
8443 let record = records.records.get_mut(record_id).ok_or_else(|| {
8444 failure(
8445 NodeFailureCode::UnknownSessionRecord,
8446 "managed session record does not exist",
8447 )
8448 })?;
8449 record.task_binding = Some(SessionTaskBindingV1 {
8450 revision,
8451 task_id,
8452 changed_at_unix_ms,
8453 });
8454 record.updated_at_unix_ms = changed_at_unix_ms;
8455 record.clone()
8456 };
8457 if let Err(error) = self.persist_state_locked() {
8458 self.session_records
8459 .lock()
8460 .unwrap_or_else(|poisoned| poisoned.into_inner())
8461 .records
8462 .insert(record_id.clone(), current);
8463 return Err(persistence_failure(error));
8464 }
8465 drop(transaction);
8466 self.publish_record(updated.clone());
8467 Ok(updated)
8468 }
8469
8470 fn index_provider_session(
8471 &self,
8472 workspace_id: WorkspaceId,
8473 provider: AgentId,
8474 identity: ProviderSessionIdentity,
8475 display_name: String,
8476 ) -> Result<ManagedSessionRecord, NodeFailure> {
8477 self.index_provider_session_with_policy(
8478 workspace_id,
8479 provider,
8480 identity,
8481 display_name,
8482 false,
8483 )
8484 }
8485
8486 fn index_provider_session_with_policy(
8487 &self,
8488 workspace_id: WorkspaceId,
8489 provider: AgentId,
8490 identity: ProviderSessionIdentity,
8491 display_name: String,
8492 allow_validated_transcript_path: bool,
8493 ) -> Result<ManagedSessionRecord, NodeFailure> {
8494 validate_display_name(&display_name)
8495 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8496 identity
8497 .validate()
8498 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8499 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8500 return Err(failure(
8501 NodeFailureCode::InvalidRequest,
8502 "provider session reference index v1 does not accept transcript paths",
8503 ));
8504 }
8505 let canonical_root = self.workspace_root(&workspace_id)?;
8506 if !self.enabled_providers.contains(&provider) {
8507 return Err(failure(
8508 NodeFailureCode::InvalidRequest,
8509 "provider is not enabled on this node",
8510 ));
8511 }
8512
8513 let transaction = self
8514 .state_transaction
8515 .lock()
8516 .unwrap_or_else(|poisoned| poisoned.into_inner());
8517 {
8518 let records = self
8519 .session_records
8520 .lock()
8521 .unwrap_or_else(|poisoned| poisoned.into_inner());
8522 if let Some(existing) = records.records.values().find(|candidate| {
8523 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8524 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8525 })
8526 }) {
8527 if existing.provider == provider && existing.workspace_id == workspace_id {
8528 return Ok(existing.clone());
8529 }
8530 return Err(failure(
8531 NodeFailureCode::SessionRecordConflict,
8532 "provider session reference conflicts with another provider or workspace",
8533 ));
8534 }
8535 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8536 return Err(failure(
8537 NodeFailureCode::BackendBusy,
8538 "managed session record capacity is full; forget an inactive record first",
8539 ));
8540 }
8541 }
8542
8543 let now = unix_time_ms();
8544 let record = ManagedSessionRecord {
8545 record_id: self.allocate_record_id()?,
8546 display_name,
8547 provider,
8548 mode: SessionMode::Pty,
8549 state: ManagedSessionState::Dormant,
8550 workspace_id,
8551 canonical_root: opaque_windows_path(canonical_root),
8552 provider_session: Some(identity),
8553 active_session: None,
8554 environment_profile: None,
8555 bundle: None,
8556 context_id: None,
8557 context: None,
8558 exported_context: None,
8559 task_binding: None,
8560 created_at_unix_ms: now,
8561 updated_at_unix_ms: now,
8562 last_error: None,
8563 };
8564 self.insert_record(record.clone())?;
8565 if let Err(error) = self.persist_state_locked() {
8566 self.remove_record_memory(&record.record_id);
8567 return Err(persistence_failure(error));
8568 }
8569 drop(transaction);
8570 self.publish_record(record.clone());
8571 Ok(record)
8572 }
8573
8574 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8575 self.session_bindings
8576 .lock()
8577 .unwrap_or_else(|poisoned| poisoned.into_inner())
8578 .iter()
8579 .find_map(|(instance_id, binding)| {
8580 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8581 workspace_id: binding.workspace_id.clone(),
8582 session: SessionKey {
8583 instance_id: *instance_id,
8584 generation: binding.generation,
8585 },
8586 })
8587 })
8588 }
8589
8590 async fn forget_session_record(
8591 &self,
8592 record_id: &SessionRecordId,
8593 ) -> Result<(), NodeFailure> {
8594 let record = self.record(record_id)?;
8595 if record.active_session.is_some()
8596 || matches!(
8597 record.state,
8598 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8599 )
8600 {
8601 return Err(failure(
8602 NodeFailureCode::SessionRecordBusy,
8603 "stop the managed session before forgetting it",
8604 ));
8605 }
8606 if let Some(address) = self.bound_address_for_record(record_id) {
8607 self.remove_session(&address).await?;
8608 }
8609 self.remove_managed_session_record(record_id, &record.workspace_id).await
8610 }
8611
8612 async fn remove_managed_session_record(
8627 &self,
8628 record_id: &SessionRecordId,
8629 workspace_id: &WorkspaceId,
8630 ) -> Result<(), NodeFailure> {
8631 let managed_lease_id = self.managed_worktrees.lock()
8632 .unwrap_or_else(|poisoned| poisoned.into_inner())
8633 .lease_for_workspace(workspace_id);
8634 if !self.destroy_record_materialization(record_id)? {
8635 let transaction = self
8636 .state_transaction
8637 .lock()
8638 .unwrap_or_else(|poisoned| poisoned.into_inner());
8639 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8640 failure(
8641 NodeFailureCode::UnknownSessionRecord,
8642 "managed session record does not exist",
8643 )
8644 })?;
8645 if let Err(error) = self.persist_state_locked() {
8646 let _ = self.insert_record(removed);
8647 return Err(persistence_failure(error));
8648 }
8649 drop(transaction);
8650 }
8651 self.publish(NodeEvent::SessionRecordRemoved {
8652 record_id: record_id.clone(),
8653 });
8654 if let Some(lease_id) = managed_lease_id {
8655 if let Some(snapshot) = self.managed_worktrees.lock()
8656 .unwrap_or_else(|poisoned| poisoned.into_inner())
8657 .get(&lease_id)
8658 .map(ManagedWorktreeLeaseRecord::snapshot)
8659 {
8660 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8661 }
8662 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8663 }
8664 Ok(())
8665 }
8666
8667 async fn retire_unavailable_session_records(&self) {
8680 let policy = self.session_record_retention;
8681 let age_enabled = policy.age_ms > 0;
8682 let keep_enabled = policy.keep_per_workspace > 0;
8683 if !age_enabled && !keep_enabled {
8684 return;
8685 }
8686 let now = unix_time_ms();
8687 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8688 self.session_records
8689 .lock()
8690 .unwrap_or_else(|poisoned| poisoned.into_inner())
8691 .records
8692 .values()
8693 .filter(|record| record.state == ManagedSessionState::Unavailable)
8694 .map(|record| {
8695 (
8696 record.record_id.clone(),
8697 record.workspace_id.clone(),
8698 record.provider.clone(),
8699 record.updated_at_unix_ms,
8700 )
8701 })
8702 .collect()
8703 };
8704 if candidates.is_empty() {
8705 return;
8706 }
8707 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8708 BTreeMap::new();
8709 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8710 by_workspace
8711 .entry(workspace_id)
8712 .or_default()
8713 .push((record_id, provider, updated_at_unix_ms));
8714 }
8715 let reason = if age_enabled && keep_enabled {
8716 "age+keep-n"
8717 } else if age_enabled {
8718 "age"
8719 } else {
8720 "keep-n"
8721 };
8722 let mut retired: u64 = 0;
8723 for (workspace_id, mut records) in by_workspace {
8724 records.sort_by(|left, right| right.2.cmp(&left.2));
8727 for (index, (record_id, provider, updated_at_unix_ms)) in
8728 records.into_iter().enumerate()
8729 {
8730 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8731 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8732 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8733 let should_retire = if age_enabled && keep_enabled {
8734 age_hit && keep_hit
8735 } else {
8736 age_hit || keep_hit
8737 };
8738 if !should_retire {
8739 continue;
8740 }
8741 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8742 Ok(()) => {
8743 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8744 retired += 1;
8745 tracing::info!(
8746 node_id = %self.node_id,
8747 record_id = %record_id,
8748 workspace_id = %workspace_id,
8749 provider = %provider,
8750 age_ms = record_age_ms,
8751 reason,
8752 "managed session record retired"
8753 );
8754 }
8755 Err(error) => tracing::warn!(
8756 node_id = %self.node_id,
8757 record_id = %record_id,
8758 workspace_id = %workspace_id,
8759 code = ?error.code,
8760 cause = %error.message,
8761 "session record retention sweep failed to retire a record"
8762 ),
8763 }
8764 }
8765 }
8766 if retired > 0 {
8767 let remaining_unavailable = self
8768 .session_records
8769 .lock()
8770 .unwrap_or_else(|poisoned| poisoned.into_inner())
8771 .records
8772 .values()
8773 .filter(|record| record.state == ManagedSessionState::Unavailable)
8774 .count();
8775 tracing::info!(
8776 node_id = %self.node_id,
8777 retired,
8778 remaining_unavailable,
8779 "session record retention sweep completed"
8780 );
8781 }
8782 }
8783
8784 async fn resume_session_record(
8785 &self,
8786 record_id: &SessionRecordId,
8787 terminal_size: gate4agent_types::TerminalSize,
8788 initial_prompt: Option<String>,
8789 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8790 let mut record = self.record(record_id)?;
8791 if record.mode == SessionMode::Acp
8794 && record.provider.as_str() == "claude"
8795 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8796 {
8797 record.mode = SessionMode::Pty;
8798 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8799 r.mode = SessionMode::Pty;
8800 }
8801 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8802 }
8803 if record.active_session.is_some()
8804 || matches!(
8805 record.state,
8806 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8807 )
8808 {
8809 return Err(failure(
8810 NodeFailureCode::SessionRecordBusy,
8811 "managed session already has a live runtime binding",
8812 ));
8813 }
8814 let identity = record.provider_session.clone().ok_or_else(|| {
8815 failure(
8816 NodeFailureCode::SessionRecordNotResumable,
8817 "managed session has no verified provider session identity",
8818 )
8819 })?;
8820 let working_directory = self.workspace_root(&record.workspace_id)?;
8821 if !platform::roots_equal(
8822 &working_directory,
8823 windows_path_text(&record.canonical_root),
8824 ) {
8825 return Err(failure(
8826 NodeFailureCode::SessionRecordConflict,
8827 "managed session workspace root changed; refusing to resume in another directory",
8828 ));
8829 }
8830 {
8831 let records = self
8832 .session_records
8833 .lock()
8834 .unwrap_or_else(|poisoned| poisoned.into_inner());
8835 if records.records.values().any(|candidate| {
8836 candidate.record_id != *record_id
8837 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8838 session_registry::same_provider_session(
8839 &candidate.provider,
8840 candidate_identity,
8841 &record.provider,
8842 &identity,
8843 )
8844 })
8845 && (candidate.active_session.is_some()
8846 || matches!(
8847 candidate.state,
8848 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8849 ))
8850 }) {
8851 return Err(failure(
8852 NodeFailureCode::SessionRecordConflict,
8853 "provider session is already active through another managed record",
8854 ));
8855 }
8856 }
8857 let runtime_requirement = if initial_prompt.is_some() {
8858 ProviderRuntimeRequirement::ResumeWithPrompt
8859 } else {
8860 ProviderRuntimeRequirement::Resume
8861 };
8862 let runtime_policy = self.admit_provider_runtime(
8863 &record.provider,
8864 runtime_requirement,
8865 )
8866 .await?;
8867 let working_directory = match self.m4a_accounts.as_ref() {
8869 Some(_) => {
8870 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8871 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8872 }
8873 None => working_directory,
8874 };
8875 let request = ResumeLaunchRequest {
8876 working_directory,
8877 terminal_size,
8878 initial_prompt,
8879 };
8880 request
8881 .validate()
8882 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8883 self.ensure_binding_capacity()?;
8884 let resolved_materialization = match self.resolve_record_materialization(
8885 record_id,
8886 &record.provider,
8887 record.mode,
8888 record.environment_profile.as_ref(),
8889 record.bundle.as_ref(),
8890 record.context.as_ref(),
8891 ) {
8892 Ok(materialization) => materialization,
8893 Err(error) => {
8894 let label = match error.code {
8895 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8896 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8897 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8898 _ => "environment-profile-unavailable",
8899 };
8900 self.mark_record_error(record_id, label)?;
8901 return Err(error);
8902 }
8903 };
8904 let (materialization_id, environment_overlay) = match resolved_materialization {
8905 Some((id, overlay)) => (Some(id), overlay),
8906 None => (None, None),
8907 };
8908 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8909 let environment_overlay = {
8911 let mut args: Vec<OsString> = Vec::new();
8912 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8913 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8914 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8915 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8916 }
8917 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8918 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8919 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8920 if let Some(sel) = options.as_ref() {
8921 args.push("--model".into()); args.push(sel.model.clone().into());
8922 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8923 args.push("--effort".into()); args.push(effort.to_owned().into());
8924 }
8925 }
8926 }
8927 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8928 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8929 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8930 instance_id: instance_id.0,
8931 provider: record.provider.as_str().to_owned(),
8932 workspace_id: record.workspace_id.as_str().to_owned(),
8933 provider_session_id: identity.id.clone(),
8934 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8935 cwd: String::new(),
8936 acp: record.mode == SessionMode::Acp,
8937 options,
8938 cols: 0,
8939 rows: 0,
8940 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8941 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8942 });
8943 }
8944 }
8945 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8946 };
8947 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8949 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8950 if let Some(old) = self.bound_address_for_record(record_id) {
8951 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8952 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8953 }
8954 }
8955 let session_key = self.m4a_session_key(instance_id);
8956 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8957 }
8958 _ => environment_overlay,
8959 };
8960 let mut environment_selection = match SpawnPreparer::select_environment(self,
8961 instance_id,
8962 &record.provider,
8963 record.mode,
8964 record.environment_profile.as_ref(),
8965 ) {
8966 Ok(selection) => selection,
8967 Err(error) => {
8968 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8969 return Err(error);
8970 }
8971 };
8972 let mut instance_overlay = environment_overlay
8973 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8974 .transpose()?
8975 .flatten();
8976 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8977 self.remove_session(&stale_address).await?;
8978 }
8979 let address = SessionAddress {
8980 workspace_id: record.workspace_id.clone(),
8981 session: SessionKey {
8982 instance_id,
8983 generation: SessionGeneration(1),
8984 },
8985 };
8986 let transaction = self
8987 .state_transaction
8988 .lock()
8989 .unwrap_or_else(|poisoned| poisoned.into_inner());
8990 let current = self.record(record_id)?;
8991 if current.active_session.is_some()
8992 || matches!(
8993 current.state,
8994 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8995 )
8996 {
8997 return Err(failure(
8998 NodeFailureCode::SessionRecordBusy,
8999 "managed session became active while resume was being prepared",
9000 ));
9001 }
9002 self.bind_managed_session_with_materialization(
9003 &address,
9004 record_id.clone(),
9005 runtime_policy,
9006 record.environment_profile.clone(),
9007 record.bundle.clone(),
9008 record.context.clone(),
9009 materialization_id,
9010 );
9011 if let Some(selection) = environment_selection.take() {
9012 selection.retain();
9013 }
9014 if let Some(overlay) = instance_overlay.take() {
9015 overlay.retain();
9016 }
9017 drop(transaction);
9018
9019 let transport = match record.mode {
9020 SessionMode::Pty => TransportKind::Pty,
9021 SessionMode::Inline => TransportKind::Pipe,
9022 SessionMode::Acp => TransportKind::Acp,
9023 };
9024 let agent_id = record.provider.clone();
9025 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9026 if let Err(error) = self
9027 .dispatch_bounded(
9028 ControlCommand::Register {
9029 instance_id,
9030 agent_id,
9031 transport,
9032 },
9033 dispatch_timeout,
9034 )
9035 .await
9036 {
9037 if let Some(binding) = self.remove_binding(&address) {
9038 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9039 }
9040 return Err(error);
9041 }
9042 let command = self.prepare_command(ControlCommand::Resume {
9043 instance_id,
9044 target: ResumeTarget::ProviderSession { identity },
9045 runtime_policy,
9046 request,
9047 });
9048 let started_after = self.current_sequence();
9049 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9050 self.rollback_spawn(&address, dispatch_timeout).await?;
9051 return Err(error);
9052 }
9053 if let Err(error) = self.dispatch_envelope(command) {
9054 self.clear_armed_resume(&address);
9055 self.rollback_spawn(&address, dispatch_timeout).await?;
9056 return Err(error);
9057 }
9058 match self
9059 .wait_for_record_resume(&address, started_after)
9060 .await
9061 {
9062 Ok(settled_address) => {
9063 let managed_lease_id = {
9064 self.managed_worktrees.lock()
9065 .unwrap_or_else(|poisoned| poisoned.into_inner())
9066 .lease_for_workspace(&record.workspace_id)
9067 };
9068 if let Some(lease_id) = managed_lease_id {
9069 {
9070 let mut bindings = self.session_bindings.lock()
9071 .unwrap_or_else(|poisoned| poisoned.into_inner());
9072 let binding = bindings.get_mut(&settled_address.session.instance_id)
9073 .ok_or_else(|| failure(
9074 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9075 "resumed managed session lost its exact binding",
9076 ))?;
9077 if binding.generation != settled_address.session.generation {
9078 return Err(failure(
9079 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9080 "resumed managed session generation diverged",
9081 ));
9082 }
9083 binding.managed_worktree_lease_id = Some(lease_id.clone());
9084 }
9085 let snapshot = self.managed_worktrees.lock()
9086 .unwrap_or_else(|poisoned| poisoned.into_inner())
9087 .bind_session(
9088 &lease_id,
9089 ManagedWorktreeSessionHolder {
9090 incarnation_id: self.incarnation_id,
9091 instance_id: settled_address.session.instance_id,
9092 generation: settled_address.session.generation,
9093 },
9094 Some(record_id.clone()),
9095 unix_time_ms(),
9096 ).map_err(|message| failure(
9097 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9098 &message,
9099 ))?;
9100 if let Err(error) = self.persist_state() {
9101 let _ = self.remove_session(&settled_address).await;
9102 return Err(persistence_failure(error));
9103 }
9104 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9105 }
9106 Ok((self.record(record_id)?, settled_address))
9107 }
9108 Err(error) => {
9109 if matches!(
9110 error.code,
9111 NodeFailureCode::SessionRecordNotResumable
9112 | NodeFailureCode::BackendOperationFailed
9113 ) {
9114 if let Some(bound) = self.bound_address_for_record(record_id) {
9115 let _ = self.remove_session(&bound).await;
9116 }
9117 }
9118 Err(error)
9119 }
9120 }
9121 }
9122
9123 async fn wait_for_record_resume(
9124 &self,
9125 initial_address: &SessionAddress,
9126 after_sequence: u64,
9127 ) -> Result<SessionAddress, NodeFailure> {
9128 let deadline = Instant::now()
9129 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9130 let mut scan_after = after_sequence;
9131 loop {
9132 let events = self
9133 .history
9134 .lock()
9135 .unwrap_or_else(|poisoned| poisoned.into_inner())
9136 .events
9137 .iter()
9138 .filter(|event| event.sequence > scan_after)
9139 .cloned()
9140 .collect::<Vec<_>>();
9141 for envelope in events {
9142 scan_after = scan_after.max(envelope.sequence);
9143 let NodeEvent::Control { address, event } = envelope.event else {
9144 continue;
9145 };
9146 if address.session.instance_id != initial_address.session.instance_id {
9147 continue;
9148 }
9149 match event.event {
9150 ControlEventKind::Resumed { .. } => return Ok(address),
9151 ControlEventKind::ResumeDenied { reason } => {
9152 return Err(failure(
9153 NodeFailureCode::SessionRecordNotResumable,
9154 &reason,
9155 ));
9156 }
9157 ControlEventKind::ResumeFailed { message }
9158 | ControlEventKind::CommandRejected { message } => {
9159 return Err(failure(
9160 NodeFailureCode::BackendOperationFailed,
9161 &message,
9162 ));
9163 }
9164 _ => {}
9165 }
9166 }
9167 if Instant::now() >= deadline {
9168 return Err(failure(
9169 NodeFailureCode::BackendBusy,
9170 "managed resume did not settle before the bounded deadline",
9171 ));
9172 }
9173 sleep(Duration::from_millis(5)).await;
9174 }
9175 }
9176
9177 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9178 self.workspaces
9179 .read()
9180 .unwrap_or_else(|poisoned| poisoned.into_inner())
9181 .get(workspace_id)
9182 .cloned()
9183 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9184 }
9185
9186 fn workspace_roots(&self) -> Vec<PathBuf> {
9187 self.workspaces
9188 .read()
9189 .unwrap_or_else(|poisoned| poisoned.into_inner())
9190 .values()
9191 .map(PathBuf::from)
9192 .collect()
9193 }
9194
9195 async fn inspect_workspace(
9196 &self,
9197 workspace_id: WorkspaceId,
9198 ) -> Result<WorkspaceInspection, NodeFailure> {
9199 let _permit = self
9200 .inspection_slots
9201 .clone()
9202 .try_acquire_owned()
9203 .map_err(|_| {
9204 tracing::warn!(
9205 workspace_id = %workspace_id,
9206 "workspace inspection rejected: inspection capacity is busy",
9207 );
9208 failure(
9209 NodeFailureCode::BackendBusy,
9210 "workspace inspection capacity is busy",
9211 )
9212 })?;
9213 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9214 tracing::warn!(
9215 workspace_id = %workspace_id,
9216 code = ?error.code,
9217 "workspace inspection rejected: workspace is not registered",
9218 );
9219 error
9220 })?;
9221 let tree_root = canonical_root.clone();
9222 let started_at = Instant::now();
9223 let time_budget_ms = workspace_inspection_time_budget_ms();
9224 let entry_cap = workspace_inspection_entry_cap();
9225 let deadline = started_at + Duration::from_millis(time_budget_ms);
9226 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9227 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9228 })
9229 .await
9230 .map_err(|error| {
9231 tracing::warn!(
9232 workspace_id = %workspace_id,
9233 error = %error,
9234 "workspace inspection rejected: walk task failed",
9235 );
9236 failure(
9237 NodeFailureCode::BackendOperationFailed,
9238 &format!("workspace inspection task failed: {error}"),
9239 )
9240 })?;
9241 let (mut git, git_time_budget_exceeded) =
9242 inspect_git_workspace(&canonical_root, deadline).await;
9243 if !git.worktrees.is_empty() {
9244 let registered = self
9245 .workspaces
9246 .read()
9247 .unwrap_or_else(|poisoned| poisoned.into_inner())
9248 .iter()
9249 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9250 .collect::<Vec<_>>();
9251 for worktree in &mut git.worktrees {
9252 worktree.workspace_id = registered
9253 .iter()
9254 .find(|(_, root)| {
9255 worktree_paths_equal(root, windows_path_text(&worktree.path))
9256 })
9257 .map(|(workspace_id, _)| workspace_id.clone());
9258 }
9259 }
9260 if git.is_repository {
9261 git.managed_worktree = self
9262 .managed_worktrees
9263 .lock()
9264 .unwrap_or_else(|poisoned| poisoned.into_inner())
9265 .git_scope(&workspace_id, git.branch.as_deref());
9266 }
9267 let truncation = workspace_inspection_truncation(
9268 &walk_budget,
9269 git_time_budget_exceeded,
9270 started_at.elapsed(),
9271 );
9272 if let Some(truncation) = &truncation {
9273 tracing::info!(
9274 workspace_id = %workspace_id,
9275 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9276 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9277 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9278 entries_visited = truncation.entries_visited,
9279 elapsed_ms = truncation.elapsed_ms,
9280 "workspace inspection truncated by its inner budget",
9281 );
9282 }
9283 Ok(WorkspaceInspection {
9284 workspace_id,
9285 entries,
9286 tree_truncated,
9287 git,
9288 truncation,
9289 })
9290 }
9291
9292 async fn browse_host_directories(
9293 &self,
9294 directory: Option<OpaqueHostPath>,
9295 after: Option<OpaqueHostPath>,
9296 ) -> Result<HostDirectoryListing, NodeFailure> {
9297 let permit = self
9298 .inspection_slots
9299 .clone()
9300 .try_acquire_owned()
9301 .map_err(|_| {
9302 failure(
9303 NodeFailureCode::BackendBusy,
9304 "host directory browse capacity is busy",
9305 )
9306 })?;
9307 let task = tokio::task::spawn_blocking(move || {
9308 let _permit = permit;
9309 browse_host_directories(directory, after)
9310 });
9311 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9312 .await
9313 .map_err(|_| {
9314 failure(
9315 NodeFailureCode::HostDirectoryReadTimedOut,
9316 "host directory browse exceeded its bounded deadline",
9317 )
9318 })?
9319 .map_err(|_| {
9320 failure(
9321 NodeFailureCode::HostDirectoryReadFailed,
9322 "host directory browse task failed",
9323 )
9324 })?
9325 .map_err(host_directory_failure)
9326 }
9327
9328 async fn read_workspace_file(
9329 &self,
9330 workspace_id: WorkspaceId,
9331 path: RepositoryPath,
9332 ) -> Result<WorkspaceFileRead, NodeFailure> {
9333 let canonical_root = self.workspace_root(&workspace_id)?;
9334 let permit = self
9335 .inspection_slots
9336 .clone()
9337 .try_acquire_owned()
9338 .map_err(|_| {
9339 failure(
9340 NodeFailureCode::BackendBusy,
9341 "workspace file read capacity is busy",
9342 )
9343 })?;
9344 let path_to_read = path.clone();
9345 let task = tokio::task::spawn_blocking(move || {
9346 let _permit = permit;
9347 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9348 });
9349 let content = timeout(
9350 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9351 task,
9352 )
9353 .await
9354 .map_err(|_| {
9355 failure(
9356 NodeFailureCode::RepositoryFileReadTimedOut,
9357 "repository file read exceeded its bounded deadline",
9358 )
9359 })?
9360 .map_err(|_| {
9361 failure(
9362 NodeFailureCode::RepositoryFileReadFailed,
9363 "repository file read task failed",
9364 )
9365 })?
9366 .map_err(workspace_file_failure)?;
9367 let content = match content {
9368 WorkspaceFileBytes::Utf8(text) => {
9369 let revision = Some(workspace_file_revision(text.as_bytes()));
9370 return Ok(WorkspaceFileRead {
9371 workspace_id,
9372 path,
9373 content: WorkspaceFileContent::Utf8 {
9374 byte_len: u32::try_from(text.len())
9375 .expect("bounded workspace text length must fit u32"),
9376 text,
9377 },
9378 revision,
9379 });
9380 }
9381 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9382 WorkspaceFileContent::NonUtf8 {
9383 byte_len: u32::try_from(byte_length)
9384 .expect("bounded workspace file length must fit u32"),
9385 }
9386 }
9387 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9388 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9389 },
9390 };
9391 Ok(WorkspaceFileRead {
9392 workspace_id,
9393 path,
9394 content,
9395 revision: None,
9396 })
9397 }
9398
9399 async fn write_workspace_file(
9400 &self,
9401 workspace_id: WorkspaceId,
9402 path: RepositoryPath,
9403 expected_revision: WorkspaceFileRevision,
9404 text: String,
9405 ) -> Result<WorkspaceFileRead, NodeFailure> {
9406 let canonical_root = self.workspace_root(&workspace_id)?;
9407 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9408 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9409 })?;
9410 let path_to_write = path.clone();
9411 let expected = expected_revision.as_str().to_owned();
9412 let text_to_write = text.clone();
9413 let task = tokio::task::spawn_blocking(move || {
9414 let _permit = permit;
9415 write_workspace_file_to_disk(
9416 Path::new(&canonical_root),
9417 &path_to_write,
9418 &expected,
9419 &text_to_write,
9420 )
9421 });
9422 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9423 .await
9424 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9425 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9426 .map_err(workspace_file_write_failure)?;
9427 Ok(WorkspaceFileRead {
9428 workspace_id,
9429 path,
9430 content: WorkspaceFileContent::Utf8 {
9431 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9432 text,
9433 },
9434 revision: Some(WorkspaceFileRevision::new(revision)
9435 .expect("workspace writer returns a valid SHA-256 revision")),
9436 })
9437 }
9438
9439 async fn create_workspace_file(
9440 &self,
9441 workspace_id: WorkspaceId,
9442 path: RepositoryPath,
9443 ) -> Result<WorkspaceFileRead, NodeFailure> {
9444 let canonical_root = self.workspace_root(&workspace_id)?;
9445 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9446 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9447 })?;
9448 let path_to_create = path.clone();
9449 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9450 let task_commit_state = Arc::clone(&commit_state);
9451 let task = tokio::task::spawn_blocking(move || {
9452 let _permit = permit;
9453 create_workspace_file_on_disk(
9454 Path::new(&canonical_root),
9455 &path_to_create,
9456 &task_commit_state,
9457 )
9458 });
9459 let revision = settle_workspace_entry_create(
9460 task,
9461 commit_state,
9462 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9463 "repository file create exceeded its bounded deadline",
9464 "repository file create task failed",
9465 )
9466 .await?;
9467 Ok(WorkspaceFileRead {
9468 workspace_id,
9469 path,
9470 content: WorkspaceFileContent::Utf8 {
9471 text: String::new(),
9472 byte_len: 0,
9473 },
9474 revision: Some(WorkspaceFileRevision::new(revision)
9475 .expect("workspace creator returns a valid SHA-256 revision")),
9476 })
9477 }
9478
9479 async fn create_workspace_directory(
9480 &self,
9481 workspace_id: WorkspaceId,
9482 path: RepositoryPath,
9483 ) -> Result<WorkspaceEntry, NodeFailure> {
9484 let canonical_root = self.workspace_root(&workspace_id)?;
9485 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9486 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9487 })?;
9488 let path_to_create = path.clone();
9489 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9490 let task_commit_state = Arc::clone(&commit_state);
9491 let task = tokio::task::spawn_blocking(move || {
9492 let _permit = permit;
9493 create_workspace_directory_on_disk(
9494 Path::new(&canonical_root),
9495 &path_to_create,
9496 &task_commit_state,
9497 )
9498 });
9499 settle_workspace_entry_create(
9500 task,
9501 commit_state,
9502 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9503 "repository directory create exceeded its bounded deadline",
9504 "repository directory create task failed",
9505 )
9506 .await?;
9507 Ok(WorkspaceEntry {
9508 relative_path: path,
9509 kind: WorkspaceEntryKind::Directory,
9510 })
9511 }
9512
9513 async fn read_git_history(
9514 &self,
9515 workspace_id: WorkspaceId,
9516 path: Option<RepositoryPath>,
9517 before: Option<GitObjectId>,
9518 limit: u16,
9519 ) -> Result<GitHistoryPage, NodeFailure> {
9520 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9521 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9522 })?;
9523 let root = self.workspace_root(&workspace_id)?;
9524 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9525 }
9526
9527 async fn read_git_diff(
9528 &self,
9529 workspace_id: WorkspaceId,
9530 request: GitDiffRequest,
9531 ) -> Result<GitDiff, NodeFailure> {
9532 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9533 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9534 })?;
9535 let root = self.workspace_root(&workspace_id)?;
9536 read_git_diff_bounded(&root, request).await
9537 }
9538
9539 fn native_session_route_root(
9540 &self,
9541 route: &NativeSessionCatalogRoute,
9542 ) -> Result<Option<String>, NodeFailure> {
9543 route
9544 .validate()
9545 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9546 match route.scope {
9547 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9548 .workspace_id
9549 .as_ref()
9550 .map(|workspace_id| self.workspace_root(workspace_id))
9551 .transpose(),
9552 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9553 }
9554 }
9555
9556 fn project_native_session_entries(
9557 &self,
9558 route: &NativeSessionCatalogRoute,
9559 entries: Vec<ScopedNativeSessionCatalogEntry>,
9560 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9561 let current_root = route
9562 .workspace_id
9563 .as_ref()
9564 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9565 let records = self
9566 .session_records
9567 .lock()
9568 .unwrap_or_else(|poisoned| poisoned.into_inner());
9569 let mut seen_records = std::collections::HashSet::new();
9570 entries
9571 .into_iter()
9572 .map(|entry| {
9573 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9574 let matches = records
9575 .records
9576 .values()
9577 .filter(|record| {
9578 record.provider == route.provider
9579 && record.workspace_id == *workspace_id
9580 && current_root.as_deref().is_some_and(|root| {
9581 platform::roots_equal(
9582 root,
9583 windows_path_text(&record.canonical_root),
9584 )
9585 })
9586 && record.provider_session.as_ref().is_some_and(|identity| {
9587 session_registry::same_provider_session(
9588 &record.provider,
9589 identity,
9590 &route.provider,
9591 &entry.provider_session,
9592 )
9593 })
9594 })
9595 .map(|record| record.record_id.clone())
9596 .collect::<Vec<_>>();
9597 (matches.len() == 1
9598 && seen_records.insert(matches[0].clone()))
9599 .then(|| matches[0].clone())
9600 });
9601 let metadata = entry.metadata;
9602 crate::protocol::NativeSessionCatalogEntry {
9603 selection_id: metadata.selection_id,
9604 title: metadata.title,
9605 modified_at_unix_ms: metadata.modified_at_unix_ms,
9606 model: metadata.model,
9607 message_count: metadata.message_count,
9608 completed_turn_count: metadata.completed_turn_count,
9609 external_group: entry.external_group,
9610 record_id,
9611 }
9612 })
9613 .collect()
9614 }
9615
9616 async fn catalog_native_sessions(
9617 &self,
9618 route: NativeSessionCatalogRoute,
9619 limit: u16,
9620 ) -> Result<(
9621 Vec<crate::protocol::NativeSessionCatalogEntry>,
9622 crate::protocol::NativeSessionCatalogSummary,
9623 ), NodeFailure> {
9624 let canonical_root = self.native_session_route_root(&route)?;
9625 let registered_workspace_roots = self.workspace_roots();
9626 let provider = route.provider.clone();
9627 let scope = route.scope;
9628 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9629 failure(
9630 NodeFailureCode::InvalidRequest,
9631 "native session catalog is not configured",
9632 )
9633 })?;
9634 let _permit = Arc::clone(&self.inspection_slots)
9635 .try_acquire_owned()
9636 .map_err(|_| {
9637 failure(
9638 NodeFailureCode::BackendBusy,
9639 "native session catalog capacity is busy",
9640 )
9641 })?;
9642 let page = run_native_session_catalog_operation(
9643 catalog,
9644 "native session catalog",
9645 move |catalog| {
9646 catalog.catalog_initial_for_scope(
9647 &provider,
9648 scope,
9649 canonical_root.as_deref().map(Path::new),
9650 ®istered_workspace_roots,
9651 limit,
9652 )
9653 },
9654 )
9655 .await?;
9656 let summary = crate::protocol::NativeSessionCatalogSummary {
9657 catalog_revision: page.revision,
9658 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9659 recent_total_count: page.recent_total_count,
9660 older_total_count: page.older_total_count,
9661 recent_next_after_selection_id: page.next_after_selection_id,
9662 recent_has_more: page.remaining_count > 0,
9663 };
9664 Ok((self.project_native_session_entries(&route, page.entries), summary))
9665 }
9666
9667 async fn page_native_sessions(
9668 &self,
9669 route: NativeSessionCatalogRoute,
9670 window: crate::protocol::NativeSessionCatalogWindow,
9671 catalog_revision: u64,
9672 recent_cutoff_unix_ms: u64,
9673 after_selection_id: Option<String>,
9674 limit: u16,
9675 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9676 let canonical_root = self.native_session_route_root(&route)?;
9677 let registered_workspace_roots = self.workspace_roots();
9678 let provider = route.provider.clone();
9679 let scope = route.scope;
9680 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9681 failure(
9682 NodeFailureCode::InvalidRequest,
9683 "native session catalog is not configured",
9684 )
9685 })?;
9686 let _permit = Arc::clone(&self.inspection_slots)
9687 .try_acquire_owned()
9688 .map_err(|_| {
9689 failure(
9690 NodeFailureCode::BackendBusy,
9691 "native session catalog capacity is busy",
9692 )
9693 })?;
9694 let page = run_native_session_catalog_operation(
9695 catalog,
9696 "native session catalog page",
9697 move |catalog| {
9698 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9699 &provider,
9700 scope,
9701 canonical_root.as_deref().map(Path::new),
9702 ®istered_workspace_roots,
9703 window,
9704 catalog_revision,
9705 recent_cutoff_unix_ms,
9706 after_selection_id.as_deref(),
9707 limit,
9708 ))
9709 },
9710 )
9711 .await?;
9712 let page = page.map_err(|error| match error {
9713 NativeSessionCatalogError::StaleCatalog => failure(
9714 NodeFailureCode::StaleNativeSessionCatalog,
9715 "native session catalog revision or cursor is stale",
9716 ),
9717 _ => failure(
9718 NodeFailureCode::BackendOperationFailed,
9719 "native session catalog page failed",
9720 ),
9721 })?;
9722 let entries = self.project_native_session_entries(&route, page.entries);
9723 Ok(crate::protocol::NativeSessionCatalogPage {
9724 window: page.window,
9725 revision: page.revision,
9726 entries,
9727 next_after_selection_id: page.next_after_selection_id,
9728 remaining_count: page.remaining_count,
9729 has_more: page.remaining_count > 0,
9730 })
9731 }
9732
9733 async fn preview_native_session(
9734 &self,
9735 selection: NativeSessionSelection,
9736 message_limit: u16,
9737 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9738 selection
9739 .validate()
9740 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9741 let canonical_root = self.native_session_route_root(&selection.route)?;
9742 let registered_workspace_roots = self.workspace_roots();
9743 let provider = selection.route.provider.clone();
9744 let scope = selection.route.scope;
9745 let catalog_revision = selection.catalog_revision;
9746 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9747 let selection_id = selection.selection_id.clone();
9748 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9749 failure(
9750 NodeFailureCode::InvalidRequest,
9751 "native session preview is not configured",
9752 )
9753 })?;
9754 let _permit = Arc::clone(&self.inspection_slots)
9755 .try_acquire_owned()
9756 .map_err(|_| {
9757 failure(
9758 NodeFailureCode::BackendBusy,
9759 "native session preview capacity is busy",
9760 )
9761 })?;
9762 let preview = run_native_session_catalog_operation(
9763 catalog,
9764 "native session preview",
9765 move |catalog| {
9766 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9767 &provider,
9768 scope,
9769 canonical_root.as_deref().map(Path::new),
9770 ®istered_workspace_roots,
9771 catalog_revision,
9772 recent_cutoff_unix_ms,
9773 &selection_id,
9774 message_limit,
9775 ))
9776 },
9777 )
9778 .await?;
9779 preview
9780 .map(Into::into)
9781 .map_err(native_session_preview_failure)
9782 }
9783
9784 async fn index_native_session(
9785 &self,
9786 selection: NativeSessionSelection,
9787 display_name: String,
9788 ) -> Result<ManagedSessionRecord, NodeFailure> {
9789 selection
9790 .validate()
9791 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9792 if selection.route.scope
9793 != gate4agent_types::NativeSessionCatalogScope::Workspace
9794 {
9795 return Err(failure(
9796 NodeFailureCode::WorkspaceRegistrationRequired,
9797 "register the external project as a workspace before indexing or resuming it",
9798 ));
9799 }
9800 let workspace_id = selection
9801 .route
9802 .workspace_id
9803 .clone()
9804 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9805 let canonical_root = self
9806 .native_session_route_root(&selection.route)?
9807 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9808 let registered_workspace_roots = self.workspace_roots();
9809 let provider = selection.route.provider.clone();
9810 let catalog_revision = selection.catalog_revision;
9811 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9812 let selection_id = selection.selection_id.clone();
9813 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9814 failure(
9815 NodeFailureCode::InvalidRequest,
9816 "native session index is not configured",
9817 )
9818 })?;
9819 let _permit = Arc::clone(&self.inspection_slots)
9820 .try_acquire_owned()
9821 .map_err(|_| {
9822 failure(
9823 NodeFailureCode::BackendBusy,
9824 "native session index capacity is busy",
9825 )
9826 })?;
9827 let resolved = run_native_session_catalog_operation(
9828 catalog,
9829 "native session index resolution",
9830 move |catalog| {
9831 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9832 &provider,
9833 gate4agent_types::NativeSessionCatalogScope::Workspace,
9834 Some(Path::new(&canonical_root)),
9835 ®istered_workspace_roots,
9836 catalog_revision,
9837 recent_cutoff_unix_ms,
9838 &selection_id,
9839 ))
9840 },
9841 )
9842 .await?
9843 .map_err(native_session_preview_failure)?;
9844 self.index_provider_session_with_policy(
9845 workspace_id,
9846 selection.route.provider,
9847 resolved.identity,
9848 display_name,
9849 true,
9850 )
9851 }
9852
9853 async fn preview_native_session_by_session_id(
9854 &self,
9855 workspace_id: WorkspaceId,
9856 provider: AgentId,
9857 session_id: String,
9858 message_limit: u16,
9859 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9860 let canonical_root = self.workspace_root(&workspace_id)?;
9861 let registered_workspace_roots = self.workspace_roots();
9862 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9863 failure(
9864 NodeFailureCode::InvalidRequest,
9865 "native session preview is not configured",
9866 )
9867 })?;
9868 let _permit = Arc::clone(&self.inspection_slots)
9869 .try_acquire_owned()
9870 .map_err(|_| {
9871 failure(
9872 NodeFailureCode::BackendBusy,
9873 "native session preview capacity is busy",
9874 )
9875 })?;
9876 run_native_session_catalog_operation(
9877 catalog,
9878 "native session preview",
9879 move |catalog| {
9880 catalog.preview_session_id_for_workspace(
9881 &provider,
9882 Path::new(&canonical_root),
9883 ®istered_workspace_roots,
9884 &session_id,
9885 message_limit,
9886 )
9887 },
9888 )
9889 .await
9890 }
9891
9892 async fn register_workspace(
9893 &self,
9894 workspace_id: WorkspaceId,
9895 root: String,
9896 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9897 validate_workspace_request_root(&root)?;
9898 let canonicalize_workspace_id = workspace_id.clone();
9899 let workspace = tokio::task::spawn_blocking(move || {
9900 WorkspaceConfig::new(canonicalize_workspace_id, root)
9901 })
9902 .await
9903 .map_err(|error| {
9904 failure(
9905 NodeFailureCode::BackendOperationFailed,
9906 &format!("workspace canonicalization task failed: {error}"),
9907 )
9908 })?
9909 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9910 let transaction = self
9911 .state_transaction
9912 .lock()
9913 .unwrap_or_else(|poisoned| poisoned.into_inner());
9914 let mut workspaces = self
9915 .workspaces
9916 .write()
9917 .unwrap_or_else(|poisoned| poisoned.into_inner());
9918 if workspaces.contains_key(&workspace_id) {
9919 return Err(failure(
9920 NodeFailureCode::DuplicateWorkspaceId,
9921 "workspace ID is already registered",
9922 ));
9923 }
9924 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9925 platform::roots_equal(existing_root, workspace.canonical_root())
9926 }) {
9927 return Err(failure(
9928 NodeFailureCode::DuplicateWorkspaceRoot,
9929 &format!("workspace root is already registered as '{existing_id}'"),
9930 ));
9931 }
9932 {
9933 let records = self
9934 .session_records
9935 .lock()
9936 .unwrap_or_else(|poisoned| poisoned.into_inner());
9937 if let Some(record) = records.records.values().find(|record| {
9938 record.workspace_id == workspace_id
9939 && !record
9940 .canonical_root
9941 .as_utf8()
9942 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9943 }) {
9944 return Err(failure(
9945 NodeFailureCode::SessionRecordConflict,
9946 &format!(
9947 "workspace ID is retained by session record '{}' at another root",
9948 record.record_id,
9949 ),
9950 ));
9951 }
9952 }
9953 let snapshot = WorkspaceSnapshot {
9954 workspace_id: workspace_id.clone(),
9955 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9956 sessions: Vec::new(),
9957 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9958 managed_worktree_profiles: Some(WorktreeProfileInventory {
9959 profiles: Vec::new(),
9960 }),
9961 };
9962 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9963 drop(workspaces);
9964 let previous_records = {
9965 let mut records = self
9966 .session_records
9967 .lock()
9968 .unwrap_or_else(|poisoned| poisoned.into_inner());
9969 let previous = records.records.clone();
9970 for record in records.records.values_mut().filter(|record| {
9971 record.workspace_id == workspace_id
9972 && record
9973 .canonical_root
9974 .as_utf8()
9975 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9976 }) {
9977 if record.active_session.is_none() {
9978 record.state = detached_record_state(record);
9979 }
9980 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9981 record.last_error = None;
9982 }
9983 record.updated_at_unix_ms = unix_time_ms();
9984 }
9985 previous
9986 };
9987 if let Err(error) = self.persist_state_locked() {
9988 self.workspaces
9989 .write()
9990 .unwrap_or_else(|poisoned| poisoned.into_inner())
9991 .remove(&workspace_id);
9992 self.session_records
9993 .lock()
9994 .unwrap_or_else(|poisoned| poisoned.into_inner())
9995 .records = previous_records;
9996 return Err(persistence_failure(error));
9997 }
9998 let restored = self
9999 .session_records
10000 .lock()
10001 .unwrap_or_else(|poisoned| poisoned.into_inner())
10002 .records
10003 .values()
10004 .filter(|record| record.workspace_id == workspace_id)
10005 .cloned()
10006 .collect::<Vec<_>>();
10007 drop(transaction);
10008 self.publish(NodeEvent::WorkspaceAdded {
10009 workspace: snapshot.clone(),
10010 });
10011 for record in restored {
10012 self.publish_record(record);
10013 }
10014 Ok(snapshot)
10015 }
10016
10017 async fn create_standalone_workspace(
10018 &self,
10019 workspace_id: WorkspaceId,
10020 root: String,
10021 initial_branch: Option<String>,
10022 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10023 validate_workspace_request_root(&root)?;
10024 validate_git_revision("initial branch", initial_branch.as_deref())?;
10025 let preflight_workspace_id = workspace_id.clone();
10026 let preflight_root = root.clone();
10027 let canonical_candidate = tokio::task::spawn_blocking(move || {
10028 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10029 })
10030 .await
10031 .map_err(|_| failure(
10032 NodeFailureCode::BackendOperationFailed,
10033 "standalone workspace root preflight task failed",
10034 ))?
10035 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10036 {
10037 let workspaces = self
10038 .workspaces
10039 .read()
10040 .unwrap_or_else(|poisoned| poisoned.into_inner());
10041 if workspaces.contains_key(&workspace_id) {
10042 return Err(failure(
10043 NodeFailureCode::DuplicateWorkspaceId,
10044 "workspace ID is already registered",
10045 ));
10046 }
10047 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10048 platform::roots_equal(existing_root, &canonical_candidate)
10049 }) {
10050 return Err(failure(
10051 NodeFailureCode::DuplicateWorkspaceRoot,
10052 &format!("workspace root is already registered as '{existing_id}'"),
10053 ));
10054 }
10055 }
10056 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10057 .await
10058 .map_err(standalone_workspace_failure)?;
10059 prepared
10060 .verify_for_registration()
10061 .await
10062 .map_err(standalone_workspace_failure)?;
10063 match self
10064 .register_workspace(workspace_id, prepared.root().to_owned())
10065 .await
10066 {
10067 Ok(workspace) => Ok(workspace),
10068 Err(registration_error) => {
10069 if prepared.compensate_registration_failure().await {
10070 Err(registration_error)
10071 } else {
10072 Err(failure(
10073 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10074 "standalone workspace initialization succeeded but registration failed; recovery is required",
10075 ))
10076 }
10077 }
10078 }
10079 }
10080
10081 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10082 let bindings = self
10083 .session_bindings
10084 .lock()
10085 .unwrap_or_else(|poisoned| poisoned.into_inner());
10086 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10087 return Err(failure(
10088 NodeFailureCode::WorkspaceBusy,
10089 "workspace retains a bound session; remove every session before unregistering it",
10090 ));
10091 }
10092 drop(bindings);
10093 let transaction = self
10094 .state_transaction
10095 .lock()
10096 .unwrap_or_else(|poisoned| poisoned.into_inner());
10097 let mut workspaces = self
10098 .workspaces
10099 .write()
10100 .unwrap_or_else(|poisoned| poisoned.into_inner());
10101 if !workspaces.contains_key(workspace_id) {
10102 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10103 }
10104 if workspaces.len() == 1 {
10105 return Err(failure(
10106 NodeFailureCode::LastWorkspace,
10107 "node must retain at least one workspace",
10108 ));
10109 }
10110 let removed_root = workspaces
10111 .remove(workspace_id)
10112 .expect("workspace presence was validated");
10113 drop(workspaces);
10114 let previous_records = {
10115 let mut records = self
10116 .session_records
10117 .lock()
10118 .unwrap_or_else(|poisoned| poisoned.into_inner());
10119 let previous = records.records.clone();
10120 for record in records
10121 .records
10122 .values_mut()
10123 .filter(|record| &record.workspace_id == workspace_id)
10124 {
10125 record.active_session = None;
10126 record.state = ManagedSessionState::Unavailable;
10127 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10128 record.updated_at_unix_ms = unix_time_ms();
10129 }
10130 previous
10131 };
10132 if let Err(error) = self.persist_state_locked() {
10133 self.workspaces
10134 .write()
10135 .unwrap_or_else(|poisoned| poisoned.into_inner())
10136 .insert(workspace_id.clone(), removed_root);
10137 self.session_records
10138 .lock()
10139 .unwrap_or_else(|poisoned| poisoned.into_inner())
10140 .records = previous_records;
10141 return Err(persistence_failure(error));
10142 }
10143 let unavailable = self
10144 .session_records
10145 .lock()
10146 .unwrap_or_else(|poisoned| poisoned.into_inner())
10147 .records
10148 .values()
10149 .filter(|record| &record.workspace_id == workspace_id)
10150 .cloned()
10151 .collect::<Vec<_>>();
10152 drop(transaction);
10153 self.publish(NodeEvent::WorkspaceRemoved {
10154 workspace_id: workspace_id.clone(),
10155 });
10156 for record in unavailable {
10157 self.publish_record(record);
10158 }
10159 Ok(())
10160 }
10161
10162 async fn create_worktree(
10163 &self,
10164 source_workspace_id: WorkspaceId,
10165 workspace_id: WorkspaceId,
10166 target_root: String,
10167 branch: String,
10168 base: Option<String>,
10169 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10170 self.require_worktree_service(&source_workspace_id)?;
10171 validate_workspace_request_root(&target_root)?;
10172 validate_git_revision("worktree base", base.as_deref())?;
10173 let source_root = self.workspace_root(&source_workspace_id)?;
10174 {
10175 let workspaces = self
10176 .workspaces
10177 .read()
10178 .unwrap_or_else(|poisoned| poisoned.into_inner());
10179 if workspaces.contains_key(&workspace_id) {
10180 return Err(failure(
10181 NodeFailureCode::DuplicateWorkspaceId,
10182 "workspace ID is already registered",
10183 ));
10184 }
10185 if let Some((existing_id, _)) = workspaces
10186 .iter()
10187 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10188 {
10189 return Err(failure(
10190 NodeFailureCode::DuplicateWorkspaceRoot,
10191 &format!("workspace root is already registered as '{existing_id}'"),
10192 ));
10193 }
10194 }
10195 let mut worktree = create_git_worktree(
10196 &source_root,
10197 &target_root,
10198 &branch,
10199 base.as_deref(),
10200 )
10201 .await
10202 .map_err(git_worktree_failure)?;
10203 let workspace = match self
10204 .register_workspace(workspace_id, worktree.path.clone())
10205 .await
10206 {
10207 Ok(workspace) => workspace,
10208 Err(registration_error) => {
10209 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10210 return Err(failure(
10211 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10212 "worktree registration failed and compensation requires recovery",
10213 ));
10214 }
10215 return Err(failure(
10216 registration_error.code,
10217 ®istration_error.message,
10218 ));
10219 }
10220 };
10221 worktree.workspace_id = Some(workspace.workspace_id.clone());
10222 Ok((protocol_worktree(worktree), workspace))
10223 }
10224
10225 async fn remove_worktree(
10226 &self,
10227 source_workspace_id: WorkspaceId,
10228 target_root: String,
10229 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10230 self.require_worktree_service(&source_workspace_id)?;
10231 validate_workspace_request_root(&target_root)?;
10232 let source_root = self.workspace_root(&source_workspace_id)?;
10233 let target_root = normalize_worktree_removal_target(&target_root)
10234 .map_err(git_worktree_failure)?;
10235 let target = list_git_worktrees(&source_root)
10236 .await
10237 .map_err(git_worktree_failure)?
10238 .into_iter()
10239 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10240 .ok_or_else(|| {
10241 failure(
10242 NodeFailureCode::WorktreeProtected,
10243 "refusing to remove a path that is not in Git's worktree listing",
10244 )
10245 })?;
10246 let registered_workspace_id = {
10247 let workspaces = self
10248 .workspaces
10249 .read()
10250 .unwrap_or_else(|poisoned| poisoned.into_inner());
10251 let matched = workspaces
10252 .iter()
10253 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10254 .map(|(workspace_id, _)| workspace_id.clone());
10255 if matched.is_some() && workspaces.len() == 1 {
10256 return Err(failure(
10257 NodeFailureCode::LastWorkspace,
10258 "node must retain at least one workspace",
10259 ));
10260 }
10261 matched
10262 };
10263 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10264 let bindings = self
10265 .session_bindings
10266 .lock()
10267 .unwrap_or_else(|poisoned| poisoned.into_inner());
10268 if bindings
10269 .values()
10270 .any(|binding| &binding.workspace_id == workspace_id)
10271 {
10272 return Err(failure(
10273 NodeFailureCode::WorkspaceBusy,
10274 "worktree workspace retains a bound session; remove every session first",
10275 ));
10276 }
10277 }
10278 remove_git_worktree(&source_root, &target.path)
10279 .await
10280 .map_err(git_worktree_failure)?;
10281 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10282 self.unregister_workspace(workspace_id)
10283 .map_err(|error| failure(error.code, &error.message))?;
10284 }
10285 Ok(registered_workspace_id)
10286 }
10287
10288 async fn cleanup_managed_worktree(
10289 &self,
10290 lease_id: &ManagedWorktreeLeaseId,
10291 explicit: bool,
10292 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10293 let lease = self.managed_worktrees.lock()
10294 .unwrap_or_else(|poisoned| poisoned.into_inner())
10295 .get(lease_id)
10296 .cloned()
10297 .ok_or_else(|| failure(
10298 NodeFailureCode::UnknownManagedWorktreeLease,
10299 "managed worktree lease does not exist",
10300 ))?;
10301 if lease.state == ManagedWorktreeLeaseState::Removed {
10302 return Ok(lease.snapshot());
10303 }
10304 if lease.has_holders() {
10305 return Err(failure(
10306 NodeFailureCode::ManagedWorktreeBusy,
10307 "managed worktree lease still has a session or durable-record holder",
10308 ));
10309 }
10310 if self
10311 .materializations
10312 .lock()
10313 .unwrap_or_else(|poisoned| poisoned.into_inner())
10314 .values()
10315 .any(|record| record.managed_lease_id() == Some(lease_id))
10316 {
10317 return Err(failure(
10318 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10319 "managed worktree cleanup is blocked by session environment ownership",
10320 ));
10321 }
10322 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10323 let snapshot = {
10324 let mut registry = self.managed_worktrees.lock()
10325 .unwrap_or_else(|poisoned| poisoned.into_inner());
10326 let current = registry.get_mut(lease_id)
10327 .expect("managed worktree lease remains present");
10328 current.state = ManagedWorktreeLeaseState::Retained;
10329 current.cleanup_failure = None;
10330 current.updated_at_unix_ms = unix_time_ms();
10331 current.snapshot()
10332 };
10333 if self.persist_state().is_err() {
10334 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10335 }
10336 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10337 return Ok(snapshot);
10338 }
10339 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10340 if profile.revision() != &lease.profile_revision {
10341 self.mark_managed_recovery_required(
10342 lease_id,
10343 ManagedWorktreeCleanupFailure::OwnershipConflict,
10344 );
10345 return Err(failure(
10346 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10347 "managed worktree profile revision changed",
10348 ));
10349 }
10350 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10351 self.mark_managed_recovery_required(
10352 lease_id,
10353 ManagedWorktreeCleanupFailure::OwnershipConflict,
10354 );
10355 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10356 })?;
10357 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10358 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10359 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10360 managed_git_worktree_failure(error)
10361 })?;
10362 let target = listed.iter()
10363 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10364 if let Some(target) = target {
10365 if target.is_main || target.is_bare || !exact_owned_worktree(
10366 &lease,
10367 &target.path,
10368 target.branch.as_deref(),
10369 &target.head,
10370 ) {
10371 self.mark_managed_recovery_required(
10372 lease_id,
10373 ManagedWorktreeCleanupFailure::OwnershipConflict,
10374 );
10375 return Err(failure(
10376 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10377 "Git worktree identity no longer matches the managed lease",
10378 ));
10379 }
10380 if target.prunable {
10381 self.mark_managed_cleanup_blocked(
10382 lease_id,
10383 ManagedWorktreeCleanupFailure::Prunable,
10384 );
10385 return Err(failure(
10386 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10387 "managed worktree is prunable and requires recovery",
10388 ));
10389 }
10390 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10391 let failure_kind = match error.kind {
10392 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10393 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10394 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10395 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10396 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10397 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10398 };
10399 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10400 self.mark_managed_recovery_required(lease_id, failure_kind);
10401 } else {
10402 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10403 }
10404 return Err(managed_git_worktree_failure(error));
10405 }
10406 } else {
10407 match std::fs::symlink_metadata(&lease.target_root) {
10408 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10409 Ok(_) => {
10410 self.mark_managed_recovery_required(
10411 lease_id,
10412 ManagedWorktreeCleanupFailure::OwnershipConflict,
10413 );
10414 return Err(failure(
10415 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10416 "managed target exists but is not owned by Git",
10417 ));
10418 }
10419 Err(_) => {
10420 self.mark_managed_cleanup_blocked(
10421 lease_id,
10422 ManagedWorktreeCleanupFailure::Backend,
10423 );
10424 return Err(failure(
10425 NodeFailureCode::BackendOperationFailed,
10426 "managed target identity could not be inspected",
10427 ));
10428 }
10429 }
10430 }
10431 let registered = self.workspaces.read()
10432 .unwrap_or_else(|poisoned| poisoned.into_inner())
10433 .contains_key(&lease.workspace_id);
10434 if registered {
10435 self.unregister_workspace(&lease.workspace_id)?;
10436 }
10437 let removed = {
10438 self.managed_worktrees.lock()
10439 .unwrap_or_else(|poisoned| poisoned.into_inner())
10440 .tombstone(lease_id, unix_time_ms())
10441 .expect("managed worktree lease remains present during cleanup")
10442 };
10443 self.persist_state().map_err(persistence_failure)?;
10444 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10445 Ok(removed.snapshot())
10446 }
10447
10448 fn mark_managed_cleanup_blocked(
10449 &self,
10450 lease_id: &ManagedWorktreeLeaseId,
10451 failure_kind: ManagedWorktreeCleanupFailure,
10452 ) {
10453 let snapshot = {
10454 let mut registry = self.managed_worktrees.lock()
10455 .unwrap_or_else(|poisoned| poisoned.into_inner());
10456 let Some(lease) = registry.get_mut(lease_id) else { return };
10457 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10458 lease.cleanup_failure = Some(failure_kind);
10459 lease.updated_at_unix_ms = unix_time_ms();
10460 lease.snapshot()
10461 };
10462 let _ = self.persist_state();
10463 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10464 }
10465
10466 fn require_worktree_service(
10467 &self,
10468 source_workspace_id: &WorkspaceId,
10469 ) -> Result<(), NodeFailure> {
10470 if self.managed_worktrees.lock()
10471 .unwrap_or_else(|poisoned| poisoned.into_inner())
10472 .lease_for_workspace(source_workspace_id)
10473 .is_some()
10474 {
10475 return Err(failure(
10476 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10477 "managed worktree targets are internal-only workspaces",
10478 ));
10479 }
10480 match self
10481 .worktree_service_modes
10482 .get(source_workspace_id)
10483 .copied()
10484 .unwrap_or(WorktreeServiceMode::Manual)
10485 {
10486 WorktreeServiceMode::Manual => Ok(()),
10487 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10488 NodeFailureCode::UnsupportedSpawnCapability,
10489 "Git worktree service mode is unavailable in this node build",
10490 )),
10491 }
10492 }
10493
10494 fn managed_reservation_conflict(
10495 &self,
10496 workspace_id: Option<&WorkspaceId>,
10497 root: Option<&str>,
10498 ) -> bool {
10499 self.managed_worktrees.lock()
10500 .unwrap_or_else(|poisoned| poisoned.into_inner())
10501 .active_records()
10502 .any(|lease| {
10503 workspace_id.is_some_and(|workspace_id| {
10504 workspace_id == &lease.source_workspace_id
10505 || workspace_id == &lease.workspace_id
10506 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10507 })
10508 }
10509
10510 fn reject_managed_reservation(
10511 &self,
10512 workspace_id: Option<&WorkspaceId>,
10513 root: Option<&str>,
10514 ) -> Result<(), NodeFailure> {
10515 if self.managed_reservation_conflict(workspace_id, root) {
10516 Err(failure(
10517 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10518 "managed worktree ownership may only be mutated through its lease cleanup",
10519 ))
10520 } else {
10521 Ok(())
10522 }
10523 }
10524
10525 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10526 self.validate_address(address)?;
10527 self.workspace_root(&address.workspace_id)
10528 }
10529
10530 fn require_session_runtime_policy(
10564 &self,
10565 address: &SessionAddress,
10566 requirement: ProviderRuntimeRequirement,
10567 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10568 let runtime_policy = {
10569 let bindings = self
10570 .session_bindings
10571 .lock()
10572 .unwrap_or_else(|poisoned| poisoned.into_inner());
10573 let binding = bindings
10574 .get(&address.session.instance_id)
10575 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10576 if binding.workspace_id != address.workspace_id {
10577 return Err(failure(
10578 NodeFailureCode::SessionWorkspaceMismatch,
10579 "session belongs to another workspace",
10580 ));
10581 }
10582 if binding.generation != address.session.generation {
10583 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10584 }
10585 binding.runtime_policy
10586 };
10587 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10588 let snapshot = self.internal_session_snapshot(address)?;
10589 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10590 && snapshot.provider.lead_activity != ProviderActivity::Idle
10591 {
10592 return Err(failure(
10593 NodeFailureCode::TurnInFlight,
10594 "session already has a turn in flight; wait for it to complete before sending another prompt",
10595 ));
10596 }
10597 }
10598 require_policy(runtime_policy, false, false, requirement)
10599 .map(|()| runtime_policy)
10600 .map_err(|_| failure(
10601 NodeFailureCode::UnsupportedCapability,
10602 "session runtime policy does not admit this semantic operation",
10603 ))
10604 }
10605
10606 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10607 let mut bindings = self
10608 .session_bindings
10609 .lock()
10610 .unwrap_or_else(|poisoned| poisoned.into_inner());
10611 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10612 binding.workspace_id == address.workspace_id
10613 && binding.generation == address.session.generation
10614 }) {
10615 let removed = bindings.remove(&address.session.instance_id);
10616 self.clear_terminal_frame_watermark(address);
10617 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10618 return removed;
10619 }
10620 None
10621 }
10622
10623 fn cleanup_session_owned_materialization(
10624 &self,
10625 address: &SessionAddress,
10626 binding: &SessionBinding,
10627 ) -> Result<(), NodeFailure> {
10628 let Some(id) = binding.materialization_id.as_ref() else {
10629 return Ok(());
10630 };
10631 let owner = self
10632 .materializations
10633 .lock()
10634 .unwrap_or_else(|poisoned| poisoned.into_inner())
10635 .get(id)
10636 .map(|record| record.owner().clone());
10637 match owner {
10638 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10639 Some(MaterializationOwner::Session {
10640 incarnation_id,
10641 instance_id,
10642 generation,
10643 }) if incarnation_id == self.incarnation_id
10644 && instance_id == address.session.instance_id
10645 && generation == address.session.generation =>
10646 {
10647 self.cleanup_materialization(id)
10648 }
10649 Some(MaterializationOwner::Session { .. }) => Err(failure(
10650 NodeFailureCode::BackendOperationFailed,
10651 "session environment ownership requires recovery",
10652 )),
10653 }
10654 }
10655
10656 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10657 let mut watermarks = self
10658 .terminal_frame_watermarks
10659 .lock()
10660 .unwrap_or_else(|poisoned| poisoned.into_inner());
10661 if watermarks
10662 .get(&address.session.instance_id)
10663 .is_some_and(|(current, _)| current == address)
10664 {
10665 watermarks.remove(&address.session.instance_id);
10666 }
10667 }
10668
10669 #[cfg(test)]
10670 fn address_for(
10671 &self,
10672 instance_id: AgentInstanceId,
10673 generation: SessionGeneration,
10674 ) -> Option<SessionAddress> {
10675 let binding = self.session_bindings
10676 .lock()
10677 .unwrap_or_else(|poisoned| poisoned.into_inner())
10678 .get(&instance_id)
10679 .cloned()?;
10680 (binding.generation == generation).then_some(SessionAddress {
10681 workspace_id: binding.workspace_id,
10682 session: SessionKey { instance_id, generation },
10683 })
10684 }
10685
10686 fn arm_resume(
10687 &self,
10688 address: &SessionAddress,
10689 command_id: CommandId,
10690 runtime_policy: ProviderRuntimePolicy,
10691 ) -> Result<(), NodeFailure> {
10692 let mut bindings = self
10693 .session_bindings
10694 .lock()
10695 .unwrap_or_else(|poisoned| poisoned.into_inner());
10696 let binding = bindings
10697 .get_mut(&address.session.instance_id)
10698 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10699 if binding.workspace_id != address.workspace_id {
10700 return Err(failure(
10701 NodeFailureCode::SessionWorkspaceMismatch,
10702 "session belongs to another workspace",
10703 ));
10704 }
10705 if binding.generation != address.session.generation {
10706 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10707 }
10708 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10709 Ok(())
10710 }
10711
10712 fn clear_armed_resume(&self, address: &SessionAddress) {
10713 let mut bindings = self
10714 .session_bindings
10715 .lock()
10716 .unwrap_or_else(|poisoned| poisoned.into_inner());
10717 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10718 if binding.workspace_id == address.workspace_id
10719 && binding.generation == address.session.generation
10720 {
10721 binding.pending_resume = None;
10722 }
10723 }
10724 }
10725
10726 fn publish_control(&self, event: ControlEvent) {
10727 let revokes_harness_mcp = matches!(
10728 &event.event,
10729 ControlEventKind::Exited { .. }
10730 | ControlEventKind::Failed { .. }
10731 | ControlEventKind::Removed
10732 );
10733 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10734 let mut bindings = self
10735 .session_bindings
10736 .lock()
10737 .unwrap_or_else(|poisoned| poisoned.into_inner());
10738 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10739 return;
10740 };
10741 let previous_address = SessionAddress {
10742 workspace_id: binding.workspace_id.clone(),
10743 session: SessionKey {
10744 instance_id: event.instance_id,
10745 generation: binding.generation,
10746 },
10747 };
10748 let mut resume_command_rejected = false;
10749 if binding.generation == event.generation {
10750 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10751 let rejected_resume = matches!(
10752 &event.event,
10753 ControlEventKind::CommandRejected { .. }
10754 ) && event.command_id == Some(pending_command_id);
10755 let resume_failed = matches!(
10756 &event.event,
10757 ControlEventKind::ResumeDenied { .. }
10758 | ControlEventKind::ResumeFailed { .. }
10759 );
10760 if pending_generation == binding.generation
10761 && (rejected_resume || resume_failed)
10762 {
10763 resume_command_rejected = rejected_resume;
10764 binding.pending_resume = None;
10765 }
10766 }
10767 } else {
10768 let expected = binding.generation.0.checked_add(1);
10769 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10770 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10771 generation == binding.generation
10772 })
10773 || expected != Some(event.generation.0)
10774 || !authorized
10775 {
10776 return;
10777 }
10778 let (_, _, runtime_policy) = binding
10779 .pending_resume
10780 .expect("authorized resume retains its pending runtime policy");
10781 binding.generation = event.generation;
10782 binding.runtime_policy = runtime_policy;
10783 binding.pending_resume = None;
10784 }
10785 (
10786 SessionAddress {
10787 workspace_id: binding.workspace_id.clone(),
10788 session: SessionKey {
10789 instance_id: event.instance_id,
10790 generation: event.generation,
10791 },
10792 },
10793 (previous_address.session.generation != event.generation)
10794 .then_some(previous_address),
10795 binding.record_id.clone(),
10796 binding.runtime_policy.provider_session_identity,
10797 resume_command_rejected,
10798 )
10799 };
10800 if let Some(previous_address) = previous_address.as_ref() {
10801 self.clear_terminal_frame_watermark(previous_address);
10802 }
10803 if let Some(record_id) = record_id {
10804 self.reconcile_managed_record(
10805 &record_id,
10806 &address,
10807 &event,
10808 identity_admitted,
10809 resume_command_rejected,
10810 );
10811 }
10812 match &event.event {
10820 ControlEventKind::Running { process_id } => {
10821 if let Some(pid) = process_id {
10822 crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10823 }
10824 #[cfg(unix)]
10825 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10826 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10827 }
10828 tracing::info!(
10829 node_id = %self.node_id,
10830 workspace_id = %address.workspace_id,
10831 session = ?address.session,
10832 pid = ?process_id,
10833 "session process is running",
10834 );
10835 }
10836 ControlEventKind::Exited { exit_code, forced } => {
10837 crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10838 #[cfg(unix)]
10839 if let Some(proxy) = self.signer_proxy.get().cloned() {
10840 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10841 if proxy.has_session(&key) {
10842 std::thread::spawn(move || proxy.release(&key));
10844 }
10845 }
10846 tracing::info!(
10847 node_id = %self.node_id,
10848 workspace_id = %address.workspace_id,
10849 session = ?address.session,
10850 exit_code = ?exit_code,
10851 forced = forced,
10852 "session process exited",
10853 );
10854 }
10855 ControlEventKind::Failed { message } => {
10856 tracing::warn!(
10857 node_id = %self.node_id,
10858 workspace_id = %address.workspace_id,
10859 session = ?address.session,
10860 cause = %message,
10861 "session failed",
10862 );
10863 }
10864 ControlEventKind::CommandRejected { message } => {
10865 tracing::warn!(
10866 node_id = %self.node_id,
10867 workspace_id = %address.workspace_id,
10868 session = ?address.session,
10869 cause = %message,
10870 "session command rejected",
10871 );
10872 }
10873 ControlEventKind::Removed => {
10874 tracing::info!(
10875 node_id = %self.node_id,
10876 workspace_id = %address.workspace_id,
10877 session = ?address.session,
10878 "session removed",
10879 );
10880 }
10881 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10882 tracing::info!(
10883 node_id = %self.node_id,
10884 workspace_id = %address.workspace_id,
10885 session = ?address.session,
10886 provider_session = ?provider_session,
10887 "session resume authorized",
10888 );
10889 }
10890 ControlEventKind::Resumed { session: provider_session, process_id } => {
10891 tracing::info!(
10892 node_id = %self.node_id,
10893 workspace_id = %address.workspace_id,
10894 session = ?address.session,
10895 provider_session = ?provider_session,
10896 pid = ?process_id,
10897 "session resumed",
10898 );
10899 }
10900 ControlEventKind::ResumeDenied { reason } => {
10901 tracing::warn!(
10902 node_id = %self.node_id,
10903 workspace_id = %address.workspace_id,
10904 session = ?address.session,
10905 cause = %reason,
10906 "session resume denied",
10907 );
10908 }
10909 ControlEventKind::ResumeFailed { message } => {
10910 tracing::warn!(
10911 node_id = %self.node_id,
10912 workspace_id = %address.workspace_id,
10913 session = ?address.session,
10914 cause = %message,
10915 "session resume failed",
10916 );
10917 }
10918 _ => {}
10919 }
10920 self.apply_pending_acp_options(&address, &event);
10921 self.apply_acp_auto_permit(&address, &event);
10922 trace_acp_turn(&address, &event);
10923 let stream_chunk = agent_stream_chunk(&event);
10924 self.publish(NodeEvent::Control {
10925 address: address.clone(),
10926 event,
10927 });
10928 if let Some(chunk) = stream_chunk {
10929 self.publish_agent_stream_chunk(address.clone(), chunk);
10930 }
10931 if revokes_harness_mcp {
10932 SessionLifecycleHook::on_session_ended(self, &address);
10933 }
10934 }
10935
10936 fn reconcile_managed_record(
10937 &self,
10938 record_id: &SessionRecordId,
10939 address: &SessionAddress,
10940 event: &ControlEvent,
10941 identity_admitted: bool,
10942 resume_command_rejected: bool,
10943 ) {
10944 let _transaction = self
10945 .state_transaction
10946 .lock()
10947 .unwrap_or_else(|poisoned| poisoned.into_inner());
10948 let observed_identity = match &event.event {
10949 ControlEventKind::ProviderEvent {
10950 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10951 ..
10952 } if identity_admitted => Some(identity.clone()),
10953 _ => None,
10954 };
10955 let identity_was_observed = observed_identity.is_some();
10956 let replacement_id = observed_identity
10957 .as_ref()
10958 .and_then(|_| self.allocate_record_id().ok());
10959 let mut upserts = Vec::new();
10960 let mut removals = Vec::new();
10961 let mut rebind = None;
10962 let original_records;
10963 let original_materializations = self
10964 .materializations
10965 .lock()
10966 .unwrap_or_else(|poisoned| poisoned.into_inner())
10967 .clone();
10968 {
10969 let mut records = self
10970 .session_records
10971 .lock()
10972 .unwrap_or_else(|poisoned| poisoned.into_inner());
10973 original_records = records.records.clone();
10974 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10975 return;
10976 };
10977 if let Some(identity) = observed_identity {
10978 let existing_id = records
10979 .records
10980 .values()
10981 .find(|record| {
10982 record.record_id != *record_id
10983 && record.provider_session.as_ref().is_some_and(|record_identity| {
10984 session_registry::same_provider_session(
10985 &record.provider,
10986 record_identity,
10987 ¤t_snapshot.provider,
10988 &identity,
10989 )
10990 })
10991 })
10992 .map(|record| record.record_id.clone());
10993 if let Some(existing_id) = existing_id {
10994 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10995 record.mode == current_snapshot.mode
10996 && record.workspace_id == current_snapshot.workspace_id
10997 && record.environment_profile == current_snapshot.environment_profile
10998 && record.bundle == current_snapshot.bundle
10999 && record.context_id == current_snapshot.context_id
11000 && record.context == current_snapshot.context
11001 && record
11002 .canonical_root
11003 .as_utf8()
11004 .zip(current_snapshot.canonical_root.as_utf8())
11005 .is_some_and(|(left, right)| platform::roots_equal(left, right))
11006 });
11007 if !scope_matches {
11008 if let Some(current) = records.records.get_mut(record_id) {
11009 current.state = ManagedSessionState::Unavailable;
11010 current.last_error =
11011 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11012 current.updated_at_unix_ms = unix_time_ms();
11013 upserts.push(current.clone());
11014 }
11015 } else {
11016 let existing_is_active = records
11017 .records
11018 .get(&existing_id)
11019 .is_some_and(|record| record.active_session.is_some());
11020 if existing_is_active {
11021 if let Some(current) = records.records.get_mut(record_id) {
11022 current.state = ManagedSessionState::Unavailable;
11023 current.last_error =
11024 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11025 current.updated_at_unix_ms = unix_time_ms();
11026 upserts.push(current.clone());
11027 }
11028 } else {
11029 if current_snapshot.provider_session.is_some() {
11030 if let Some(current) = records.records.get_mut(record_id) {
11031 current.active_session = None;
11032 current.state = ManagedSessionState::Dormant;
11033 current.updated_at_unix_ms = unix_time_ms();
11034 upserts.push(current.clone());
11035 }
11036 } else {
11037 records.records.remove(record_id);
11038 removals.push(record_id.clone());
11039 }
11040 if let Some(existing) = records.records.get_mut(&existing_id) {
11041 existing.provider_session = Some(identity.clone());
11042 existing.active_session = Some(address.clone());
11043 existing.state = ManagedSessionState::Live;
11044 existing.updated_at_unix_ms = unix_time_ms();
11045 existing.last_error = None;
11046 upserts.push(existing.clone());
11047 }
11048 rebind = Some(existing_id);
11049 }
11050 }
11051 } else if current_snapshot
11052 .provider_session
11053 .as_ref()
11054 .is_some_and(|current| {
11055 !session_registry::same_provider_session(
11056 ¤t_snapshot.provider,
11057 current,
11058 ¤t_snapshot.provider,
11059 &identity,
11060 )
11061 })
11062 {
11063 if let Some(current) = records.records.get_mut(record_id) {
11064 current.active_session = None;
11065 if current.bundle.is_some() || current.context.is_some() {
11066 let had_bundle = current.bundle.is_some();
11067 current.provider_session = None;
11068 current.bundle = None;
11069 current.context_id = None;
11070 current.context = None;
11071 current.state = ManagedSessionState::Unavailable;
11072 current.last_error = Some(if had_bundle {
11073 "bundle-unavailable".to_owned()
11074 } else {
11075 "context-unavailable".to_owned()
11076 });
11077 } else {
11078 current.state = ManagedSessionState::Dormant;
11079 }
11080 current.updated_at_unix_ms = unix_time_ms();
11081 upserts.push(current.clone());
11082 }
11083 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11084 if let Some(current) = records.records.get_mut(record_id) {
11085 current.state = ManagedSessionState::Unavailable;
11086 current.last_error =
11087 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11088 current.updated_at_unix_ms = unix_time_ms();
11089 upserts.push(current.clone());
11090 }
11091 } else if let Some(new_record_id) = replacement_id {
11092 let now = unix_time_ms();
11093 let replacement = ManagedSessionRecord {
11094 record_id: new_record_id.clone(),
11095 display_name: format!(
11096 "{} #{}",
11097 current_snapshot.provider.as_str(),
11098 records
11099 .records
11100 .values()
11101 .filter(|record| {
11102 record.provider == current_snapshot.provider
11103 })
11104 .count()
11105 .saturating_add(1),
11106 ),
11107 provider: current_snapshot.provider.clone(),
11108 mode: current_snapshot.mode,
11109 state: ManagedSessionState::Live,
11110 workspace_id: current_snapshot.workspace_id,
11111 canonical_root: current_snapshot.canonical_root,
11112 provider_session: Some(identity),
11113 active_session: Some(address.clone()),
11114 environment_profile: current_snapshot.environment_profile,
11115 bundle: current_snapshot.bundle,
11116 context_id: current_snapshot.context_id,
11117 context: current_snapshot.context,
11118 exported_context: None,
11124 task_binding: current_snapshot.task_binding,
11125 created_at_unix_ms: now,
11126 updated_at_unix_ms: now,
11127 last_error: None,
11128 };
11129 records
11130 .records
11131 .insert(new_record_id.clone(), replacement.clone());
11132 upserts.push(replacement);
11133 rebind = Some(new_record_id);
11134 } else if let Some(current) = records.records.get_mut(record_id) {
11135 current.state = ManagedSessionState::Unavailable;
11136 current.last_error =
11137 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11138 upserts.push(current.clone());
11139 }
11140 } else if let Some(current) = records.records.get_mut(record_id) {
11141 current.provider_session = Some(identity);
11142 current.active_session = Some(address.clone());
11143 current.state = ManagedSessionState::Live;
11144 current.updated_at_unix_ms = unix_time_ms();
11145 current.last_error = None;
11146 upserts.push(current.clone());
11147 }
11148 } else if let Some(current) = records.records.get_mut(record_id) {
11149 if resume_command_rejected {
11150 current.active_session = None;
11151 current.state = detached_record_state(current);
11152 current.updated_at_unix_ms = unix_time_ms();
11153 current.last_error =
11154 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11155 upserts.push(current.clone());
11156 } else {
11157 match &event.event {
11158 ControlEventKind::Running { .. }
11159 | ControlEventKind::ResumeAuthorized { .. }
11160 | ControlEventKind::Resumed { .. } => {
11161 current.active_session = Some(address.clone());
11162 current.state = active_record_state(
11163 identity_admitted,
11164 current.provider_session.is_some(),
11165 );
11166 current.updated_at_unix_ms = unix_time_ms();
11167 current.last_error = None;
11168 upserts.push(current.clone());
11169 }
11170 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11171 current.active_session = None;
11172 current.state = detached_record_state(current);
11173 current.updated_at_unix_ms = unix_time_ms();
11174 upserts.push(current.clone());
11175 }
11176 ControlEventKind::Failed { message }
11177 | ControlEventKind::ResumeFailed { message } => {
11178 current.active_session = None;
11179 current.state = detached_record_state(current);
11180 current.updated_at_unix_ms = unix_time_ms();
11181 current.last_error = Some(
11182 session_registry::sanitized_record_error_summary(message),
11183 );
11184 upserts.push(current.clone());
11185 }
11186 ControlEventKind::ResumeDenied { reason } => {
11187 current.active_session = None;
11188 current.state = detached_record_state(current);
11189 current.updated_at_unix_ms = unix_time_ms();
11190 current.last_error = Some(
11191 session_registry::sanitized_record_error_summary(reason),
11192 );
11193 upserts.push(current.clone());
11194 }
11195 _ => {}
11196 }
11197 }
11198 }
11199 }
11200 let materialization_owner_record = identity_was_observed.then(|| {
11201 rebind.clone().unwrap_or_else(|| record_id.clone())
11202 }).and_then(|candidate| {
11203 self.session_records
11204 .lock()
11205 .unwrap_or_else(|poisoned| poisoned.into_inner())
11206 .records
11207 .get(&candidate)
11208 .filter(|record| {
11209 record.provider_session.is_some()
11210 && record.active_session.as_ref() == Some(address)
11211 })
11212 .map(|_| candidate)
11213 });
11214 if let Some(owner_record_id) = materialization_owner_record {
11215 let materialization_id = self
11216 .session_bindings
11217 .lock()
11218 .unwrap_or_else(|poisoned| poisoned.into_inner())
11219 .get(&address.session.instance_id)
11220 .and_then(|binding| binding.materialization_id.clone());
11221 if let Some(materialization_id) = materialization_id {
11222 let mut transfer_failed = false;
11223 {
11224 let mut materializations = self
11225 .materializations
11226 .lock()
11227 .unwrap_or_else(|poisoned| poisoned.into_inner());
11228 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11229 match ownership.owner() {
11230 MaterializationOwner::Session {
11231 incarnation_id,
11232 instance_id,
11233 generation,
11234 } if *incarnation_id == self.incarnation_id
11235 && *instance_id == address.session.instance_id
11236 && *generation == address.session.generation =>
11237 {
11238 transfer_failed = ownership
11239 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11240 .is_err();
11241 }
11242 MaterializationOwner::Record { record_id }
11243 if record_id == &owner_record_id => {}
11244 MaterializationOwner::Record { record_id } => {
11245 let expected = record_id.clone();
11246 transfer_failed = ownership
11247 .transfer_record_owner(
11248 &expected,
11249 owner_record_id.clone(),
11250 unix_time_ms(),
11251 )
11252 .is_err();
11253 }
11254 _ => transfer_failed = true,
11255 }
11256 if transfer_failed {
11257 let _ = ownership.mark_recovery_required(unix_time_ms());
11258 }
11259 } else {
11260 transfer_failed = true;
11261 }
11262 }
11263 if transfer_failed {
11264 if let Some(record) = self
11265 .session_records
11266 .lock()
11267 .unwrap_or_else(|poisoned| poisoned.into_inner())
11268 .records
11269 .get_mut(&owner_record_id)
11270 {
11271 record.state = ManagedSessionState::Unavailable;
11272 record.last_error = Some("environment-profile-unavailable".to_owned());
11273 record.updated_at_unix_ms = unix_time_ms();
11274 upserts.push(record.clone());
11275 }
11276 }
11277 }
11278 }
11279 if let Some(new_record_id) = rebind.clone() {
11280 if let Some(binding) = self
11281 .session_bindings
11282 .lock()
11283 .unwrap_or_else(|poisoned| poisoned.into_inner())
11284 .get_mut(&address.session.instance_id)
11285 {
11286 binding.record_id = Some(new_record_id);
11287 }
11288 }
11289 if upserts.is_empty() && removals.is_empty() {
11290 return;
11291 }
11292 if self.persist_state_locked().is_err() {
11293 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11294 let mut records = self
11295 .session_records
11296 .lock()
11297 .unwrap_or_else(|poisoned| poisoned.into_inner());
11298 records.records = original_records;
11299 *self
11300 .materializations
11301 .lock()
11302 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11303 if let Some(current) = records.records.get_mut(record_id) {
11304 current.state = ManagedSessionState::Unavailable;
11305 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11306 current.updated_at_unix_ms = unix_time_ms();
11307 upserts = vec![current.clone()];
11308 } else {
11309 upserts.clear();
11310 }
11311 removals.clear();
11312 drop(records);
11313 if let Some(binding) = self
11314 .session_bindings
11315 .lock()
11316 .unwrap_or_else(|poisoned| poisoned.into_inner())
11317 .get_mut(&address.session.instance_id)
11318 {
11319 binding.record_id = Some(record_id.clone());
11320 }
11321 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11322 }
11323 for record in upserts {
11324 self.publish_record(record);
11325 }
11326 for record_id in removals {
11327 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11328 }
11329 }
11330
11331 fn snapshot(&self) -> NodeSnapshot {
11332 let control = self.handle.snapshot();
11333 let bindings = self
11334 .session_bindings
11335 .lock()
11336 .unwrap_or_else(|poisoned| poisoned.into_inner())
11337 .clone();
11338 let workspace_roots = self
11339 .workspaces
11340 .read()
11341 .unwrap_or_else(|poisoned| poisoned.into_inner())
11342 .clone();
11343 let environment_profiles = self
11344 .environment_profiles
11345 .read()
11346 .unwrap_or_else(|poisoned| poisoned.into_inner());
11347 let mut workspaces = workspace_roots
11348 .iter()
11349 .map(|(workspace_id, canonical_root)| {
11350 (
11351 workspace_id.clone(),
11352 WorkspaceSnapshot {
11353 workspace_id: workspace_id.clone(),
11354 canonical_root: opaque_windows_path(canonical_root.clone()),
11355 sessions: Vec::new(),
11356 worktree_service_mode: Some(
11357 self.worktree_service_modes
11358 .get(workspace_id)
11359 .copied()
11360 .unwrap_or(WorktreeServiceMode::Manual),
11361 ),
11362 managed_worktree_profiles: Some(WorktreeProfileInventory {
11363 profiles: self
11364 .managed_worktree_profiles
11365 .get(workspace_id)
11366 .into_iter()
11367 .flat_map(BTreeMap::values)
11368 .map(|profile| ManagedWorktreeProfileSummary {
11369 id: profile.profile_id().clone(),
11370 revision: profile.revision().clone(),
11371 retention: profile.retention(),
11372 })
11373 .collect(),
11374 }),
11375 },
11376 )
11377 })
11378 .collect::<BTreeMap<_, _>>();
11379 let mut agent_progress = Vec::new();
11380 for session in &control.sessions {
11381 let Some(binding) = bindings.get(&session.instance_id) else {
11382 continue;
11383 };
11384 if binding.generation != session.generation {
11385 continue;
11386 }
11387 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11388 let address = SessionAddress {
11389 workspace_id: binding.workspace_id.clone(),
11390 session: SessionKey {
11391 instance_id: session.instance_id,
11392 generation: session.generation,
11393 },
11394 };
11395 if let Some(progress) =
11396 agent_progress_from_provider_snapshot(address, &session.provider)
11397 {
11398 agent_progress.push(progress);
11399 }
11400 workspace.sessions.push(session.clone());
11401 }
11402 }
11403 NodeSnapshot {
11404 node_id: self.node_id.clone(),
11405 enabled_providers: self.enabled_providers.clone(),
11406 provider_runtime_statuses: {
11407 let updates = self
11408 .provider_runtime_status_updates
11409 .read()
11410 .unwrap_or_else(|poisoned| poisoned.into_inner());
11411 ProviderRuntimeStatuses::new(
11412 self.provider_runtime_statuses
11413 .iter()
11414 .filter(|status| !updates.contains_key(status.provider()))
11415 .cloned()
11416 .chain(updates.values().cloned()),
11417 )
11418 .expect("refreshed provider runtime inventory remains bounded and unique")
11419 },
11420 workspaces: workspaces.into_values().collect(),
11421 session_records: self
11422 .session_records
11423 .lock()
11424 .unwrap_or_else(|poisoned| poisoned.into_inner())
11425 .records
11426 .values()
11427 .cloned()
11428 .collect(),
11429 managed_worktrees: self.managed_worktrees
11430 .lock()
11431 .unwrap_or_else(|poisoned| poisoned.into_inner())
11432 .snapshots(),
11433 launch_inventory: Some(LaunchInventory {
11434 spawn_profiles: Some(
11435 self.spawn_profiles
11436 .iter()
11437 .filter_map(|profile| {
11438 resolved_spawn_profile_summary(profile, &environment_profiles)
11439 })
11440 .collect(),
11441 ),
11442 bundles: Some(
11443 self.bundle_catalog
11444 .read()
11445 .unwrap_or_else(|poisoned| poisoned.into_inner())
11446 .iter()
11447 .map(NodeBundle::receipt)
11448 .collect(),
11449 ),
11450 network_allowlists: {
11453 let catalog = self
11454 .network_allowlist_catalog
11455 .read()
11456 .unwrap_or_else(|poisoned| poisoned.into_inner());
11457 if catalog.is_empty() {
11458 None
11459 } else {
11460 Some(catalog.ids().cloned().collect())
11461 }
11462 },
11463 }),
11464 agent_progress,
11465 }
11466 }
11467
11468 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11469 assert!(
11470 !matches!(
11471 &event,
11472 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11473 ),
11474 "terminal frame and agent stream events must use their own dedicated live channel",
11475 );
11476 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11477 let sequence = history
11478 .last_sequence
11479 .checked_add(1)
11480 .expect("node event sequence exhausted");
11481 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11482 if let Some(evicted) = history.events.pop_front() {
11483 history.replay_floor_sequence = evicted
11484 .sequence
11485 .checked_add(1)
11486 .expect("node replay floor sequence exhausted");
11487 history.removed_record_providers.remove(&evicted.sequence);
11488 }
11489 }
11490 match &event {
11491 NodeEvent::HarnessMcpReadCall { .. } => {
11492 panic!("transient harness MCP calls must not enter durable event history")
11493 }
11494 NodeEvent::SessionRecordUpserted { record } => {
11495 history
11496 .record_providers
11497 .insert(record.record_id.clone(), record.provider.clone());
11498 }
11499 NodeEvent::SessionRecordRemoved { record_id } => {
11500 if let Some(provider) = history.record_providers.remove(record_id) {
11501 history.removed_record_providers.insert(sequence, provider);
11502 }
11503 }
11504 NodeEvent::ManagedWorktreeUpserted { .. }
11505 | NodeEvent::ManagedWorktreeRemoved { .. }
11506 | NodeEvent::Control { .. }
11507 | NodeEvent::ControllerChanged { .. }
11508 | NodeEvent::SessionRecordHistorySummarized { .. }
11509 | NodeEvent::WorkspaceAdded { .. }
11510 | NodeEvent::WorkspaceRemoved { .. }
11511 | NodeEvent::ResyncRequired { .. }
11512 | NodeEvent::AgentStream { .. }
11513 | NodeEvent::TerminalFrame { .. } => {}
11514 }
11515 let envelope = NodeEventEnvelope { sequence, event };
11516 history.events.push_back(envelope.clone());
11517 history.last_sequence = sequence;
11518 let _ = self.event_tx.send(envelope.clone());
11519 drop(history);
11520 envelope
11521 }
11522
11523 fn publish_transient(&self, event: NodeEvent) -> bool {
11534 assert!(event.requires_harness_mcp_proxy_capability());
11535 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11536 let envelope = NodeEventEnvelope { sequence: 0, event };
11537 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11538 Ok(_) => true,
11539 Err(broadcast::error::SendError(undelivered)) => {
11540 if let Some(NodeEventEnvelope {
11541 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11542 ..
11543 }) = undelivered.first() {
11544 tracing::warn!(
11550 call_id = call_id.as_str(),
11551 content_type = ?request.content_type,
11552 cause = "no-subscribers",
11553 "harness MCP proxy event could not be published",
11554 );
11555 }
11556 false
11557 }
11558 }
11559 }
11560
11561 fn publish_terminal_frames(&self) {
11562 let snapshot = self.handle.snapshot();
11563 let bindings = self
11564 .session_bindings
11565 .lock()
11566 .unwrap_or_else(|poisoned| poisoned.into_inner());
11567 let candidates = snapshot
11568 .sessions
11569 .iter()
11570 .filter_map(|session| {
11571 let binding = bindings.get(&session.instance_id)?;
11572 if binding.generation != session.generation {
11573 return None;
11574 }
11575 let frame = session.terminal_frame.clone()?;
11576 crate::session_signals::feed(
11577 binding.workspace_id.as_str(),
11578 session.instance_id.0,
11579 session.agent_id.as_str(),
11580 &frame.contents,
11581 );
11582 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11583 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11584 Some((
11585 SessionAddress {
11586 workspace_id: binding.workspace_id.clone(),
11587 session: SessionKey {
11588 instance_id: session.instance_id,
11589 generation: session.generation,
11590 },
11591 },
11592 frame,
11593 ))
11594 })
11595 .collect();
11596 drop(bindings);
11597 self.publish_terminal_frame_candidates(candidates);
11598 }
11599
11600 fn publish_terminal_frame_candidates(
11601 &self,
11602 candidates: Vec<(SessionAddress, TerminalFrame)>,
11603 ) {
11604 let mut watermarks = self
11605 .terminal_frame_watermarks
11606 .lock()
11607 .unwrap_or_else(|poisoned| poisoned.into_inner());
11608 let mut events = Vec::with_capacity(candidates.len());
11609 for (address, frame) in candidates {
11610 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11611 let advanced = watermarks
11612 .get(&address.session.instance_id)
11613 .map_or(true, |(current_address, sequence)| {
11614 current_address != &address || frame.sequence > *sequence
11615 });
11616 if !advanced {
11617 continue;
11618 }
11619 if is_first_frame {
11620 if self.controller_state().is_none() {
11634 tracing::warn!(
11635 node_id = %self.node_id,
11636 workspace_id = %address.workspace_id,
11637 session = ?address.session,
11638 terminal_size = ?frame.size,
11639 "session producing output with no controller attached",
11640 );
11641 } else {
11642 tracing::info!(
11643 node_id = %self.node_id,
11644 workspace_id = %address.workspace_id,
11645 session = ?address.session,
11646 terminal_size = ?frame.size,
11647 "session produced its first output",
11648 );
11649 }
11650 }
11651 watermarks.insert(
11652 address.session.instance_id,
11653 (address.clone(), frame.sequence),
11654 );
11655 if let Some((at, reported)) = self
11656 .pty_input_marks
11657 .lock()
11658 .unwrap_or_else(|poisoned| poisoned.into_inner())
11659 .get_mut(&address.session.instance_id)
11660 {
11661 if !*reported && at.elapsed() < Duration::from_secs(5) {
11663 *reported = true;
11664 tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11665 }
11666 }
11667 events.push(NodeEvent::TerminalFrame { address, frame });
11668 }
11669 drop(watermarks);
11670 if events.is_empty() {
11671 return;
11672 }
11673 let mut history = self
11674 .history
11675 .lock()
11676 .unwrap_or_else(|poisoned| poisoned.into_inner());
11677 let mut envelopes = Vec::with_capacity(events.len());
11678 for event in events {
11679 let sequence = history
11680 .last_sequence
11681 .checked_add(1)
11682 .expect("node event sequence exhausted");
11683 history.last_sequence = sequence;
11684 envelopes.push(NodeEventEnvelope { sequence, event });
11685 }
11686 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11687 drop(history);
11688 }
11689
11690 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11699 let mut history = self
11700 .history
11701 .lock()
11702 .unwrap_or_else(|poisoned| poisoned.into_inner());
11703 let sequence = history
11704 .last_sequence
11705 .checked_add(1)
11706 .expect("node event sequence exhausted");
11707 history.last_sequence = sequence;
11708 drop(history);
11709 tracing::debug!(
11717 source_sequence = chunk.source_sequence,
11718 kind = agent_stream_chunk_kind_label(&chunk.kind),
11719 envelope_sequence = sequence,
11720 "publishing agent stream chunk",
11721 );
11722 let envelope = NodeEventEnvelope {
11723 sequence,
11724 event: NodeEvent::AgentStream { address, chunk },
11725 };
11726 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11727 }
11728
11729 fn current_sequence(&self) -> u64 {
11730 self.history
11731 .lock()
11732 .unwrap_or_else(|poisoned| poisoned.into_inner())
11733 .last_sequence
11734 }
11735
11736 fn controller_state(&self) -> Option<ControllerState> {
11737 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11738 let lease = controller.as_ref().copied()?;
11739 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11740 if remaining.is_zero() {
11741 *controller = None;
11742 return None;
11743 }
11744 Some(ControllerState {
11745 connection_id: lease.connection_id,
11746 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11747 })
11748 }
11749
11750 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11751 if role != ClientRole::Operator {
11752 tracing::warn!(
11753 node_id = %self.node_id,
11754 connection_id,
11755 "controller lease rejected: observer connections cannot acquire control",
11756 );
11757 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11758 }
11759 let lease_ms = if lease_ms == 0 {
11760 DEFAULT_CONTROLLER_LEASE_MS
11761 } else {
11762 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11763 };
11764 let now = Instant::now();
11765 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11766 if let Some(active) = controller.as_ref() {
11767 if active.connection_id != connection_id && active.expires_at > now {
11768 tracing::warn!(
11769 node_id = %self.node_id,
11770 connection_id,
11771 holder_connection_id = active.connection_id,
11772 "controller lease rejected: another operator holds it",
11773 );
11774 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11775 }
11776 }
11777 let is_new_attach = match controller.as_ref() {
11784 Some(active) => active.connection_id != connection_id,
11785 None => true,
11786 };
11787 *controller = Some(ControllerLease {
11788 connection_id,
11789 expires_at: now + Duration::from_millis(lease_ms),
11790 });
11791 drop(controller);
11792 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11793 if is_new_attach {
11794 tracing::info!(
11795 node_id = %self.node_id,
11796 connection_id,
11797 lease_ms,
11798 "controller lease acquired",
11799 );
11800 } else {
11801 tracing::debug!(
11802 node_id = %self.node_id,
11803 connection_id,
11804 lease_ms,
11805 "controller lease renewed",
11806 );
11807 }
11808 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11809 Ok(state)
11810 }
11811
11812 fn release_controller(&self, connection_id: u64) -> bool {
11813 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11814 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11815 if released {
11816 *controller = None;
11817 }
11818 drop(controller);
11819 if released {
11820 tracing::info!(
11821 node_id = %self.node_id,
11822 connection_id,
11823 "controller lease released",
11824 );
11825 self.publish(NodeEvent::ControllerChanged { controller: None });
11826 }
11827 released
11828 }
11829
11830 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11839 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11840 return;
11841 };
11842 let id = address.session.instance_id;
11843 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11844 return;
11845 };
11846 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11847 .iter()
11848 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11849 .collect();
11850 let (writes, set_model, effort_gap) =
11851 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11852 if set_model {
11853 if let Some(model_id) = model.clone() {
11854 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11855 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11856 }
11857 }
11858 }
11859 for (option_id, value_json) in writes {
11860 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11861 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11862 }
11863 }
11864 if effort_gap {
11865 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11866 }
11867 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11868 }
11869
11870 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11873 let id = address.session.instance_id;
11874 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11875 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11876 crate::auto_permit::forget_acp(id.0);
11877 return;
11878 }
11879 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11880 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11881 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11882 }
11883 match provider_event {
11884 ProviderEvent::ModeChanged { mode_id, available } => {
11885 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11886 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11887 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11888 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11889 }
11890 }
11891 }
11892 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11893 match self.dispatch(ControlCommand::ResolveInteraction {
11894 instance_id: id,
11895 generation: address.session.generation,
11896 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11897 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11898 }) {
11899 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11900 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11901 }
11902 }
11903 _ => {}
11906 }
11907 }
11908
11909 fn mark_spawn_provisional(&self, address: SessionAddress) {
11910 let id = address.session.instance_id;
11911 self.provisional_spawns
11912 .lock()
11913 .unwrap_or_else(|poisoned| poisoned.into_inner())
11914 .insert(id, (address, Instant::now()));
11915 tracing::info!(
11916 node_id = %self.node_id,
11917 instance_id = id.0,
11918 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11919 "draft spawn; waiting for HQ view",
11920 );
11921 }
11922
11923 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11924 let removed = self
11925 .provisional_spawns
11926 .lock()
11927 .unwrap_or_else(|poisoned| poisoned.into_inner())
11928 .remove(&instance_id);
11929 if removed.is_some() {
11930 tracing::info!(
11931 node_id = %self.node_id,
11932 instance_id = instance_id.0,
11933 "draft spawn claimed by HQ view",
11934 );
11935 }
11936 }
11937
11938 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11939 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11940 let mut map = self
11941 .provisional_spawns
11942 .lock()
11943 .unwrap_or_else(|poisoned| poisoned.into_inner());
11944 let due: Vec<AgentInstanceId> = map
11945 .iter()
11946 .filter(|(_, (_, at))| at.elapsed() >= grace)
11947 .map(|(id, _)| *id)
11948 .collect();
11949 due.into_iter()
11950 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11951 .collect()
11952 }
11953
11954 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11955 let instance_id = address.session.instance_id;
11956 tracing::warn!(
11957 node_id = %self.node_id,
11958 instance_id = instance_id.0,
11959 workspace = %address.workspace_id,
11960 reason = "attach_never_arrived",
11961 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11962 "draft spawn never viewed; stopping PTY",
11963 );
11964 let _ = self.dispatch(ControlCommand::Stop {
11965 instance_id,
11966 force: true,
11967 });
11968 let _ = self.remove_session(&address).await;
11969 }
11970
11971 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11972 if role != ClientRole::Operator {
11973 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11974 }
11975 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11976 let now = Instant::now();
11977 match controller.as_ref().copied() {
11978 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11979 Some(active) if active.expires_at <= now => {
11980 *controller = None;
11981 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11982 }
11983 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11984 }
11985 }
11986
11987 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11988 if !self
11989 .workspaces
11990 .read()
11991 .unwrap_or_else(|poisoned| poisoned.into_inner())
11992 .contains_key(&address.workspace_id)
11993 {
11994 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11995 }
11996 let binding = self
11997 .session_bindings
11998 .lock()
11999 .unwrap_or_else(|poisoned| poisoned.into_inner())
12000 .get(&address.session.instance_id)
12001 .cloned()
12002 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12003 if binding.workspace_id != address.workspace_id {
12004 return Err(failure(
12005 NodeFailureCode::SessionWorkspaceMismatch,
12006 "session belongs to another workspace",
12007 ));
12008 }
12009 if binding.generation != address.session.generation {
12010 return Err(failure(
12011 NodeFailureCode::StaleGeneration,
12012 &format!(
12013 "stale session generation: expected {}, received {}",
12014 binding.generation.0,
12015 address.session.generation.0,
12016 ),
12017 ));
12018 }
12019 let snapshot = self.handle.snapshot();
12020 let Some(current) = snapshot
12021 .sessions
12022 .iter()
12023 .find(|item| item.instance_id == address.session.instance_id)
12024 else {
12025 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12026 };
12027 if current.generation != address.session.generation {
12028 return Err(failure(
12029 NodeFailureCode::StaleGeneration,
12030 &format!(
12031 "stale session generation: expected {}, received {}",
12032 current.generation.0,
12033 address.session.generation.0,
12034 ),
12035 ));
12036 }
12037 Ok(current.agent_id.clone())
12038 }
12039
12040 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12041 self.dispatch_envelope(self.prepare_command(command))
12042 }
12043
12044 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12045 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12046 CommandEnvelope {
12047 id: CommandId(id),
12048 command,
12049 }
12050 }
12051
12052 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12053 let command_id = envelope.id;
12054 self.handle
12055 .dispatch(envelope)
12056 .map_err(|error| match error {
12057 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12058 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12059 })?;
12060 Ok(command_id)
12061 }
12062
12063 fn record_rejected_command(
12072 &self,
12073 command_id: CommandId,
12074 message: String,
12075 unsupported_transport: bool,
12076 ) {
12077 let now = Instant::now();
12078 let mut reasons = self.rejected_command_reasons.lock()
12079 .unwrap_or_else(|poisoned| poisoned.into_inner());
12080 reasons.retain(|_, reason| reason.expires_at > now);
12081 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12082 if let Some(&oldest) = reasons.keys().next() {
12083 reasons.remove(&oldest);
12084 }
12085 }
12086 reasons.insert(
12087 command_id,
12088 RejectedCommandReason {
12089 message,
12090 unsupported_transport,
12091 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12092 },
12093 );
12094 }
12095
12096 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12103 let now = Instant::now();
12104 let mut reasons = self.rejected_command_reasons.lock()
12105 .unwrap_or_else(|poisoned| poisoned.into_inner());
12106 match reasons.remove(&command_id) {
12107 Some(reason) if reason.expires_at > now => Some(reason),
12108 _ => None,
12109 }
12110 }
12111
12112 async fn dispatch_bounded(
12113 &self,
12114 command: ControlCommand,
12115 timeout_duration: Duration,
12116 ) -> Result<CommandId, NodeFailure> {
12117 let deadline = Instant::now() + timeout_duration;
12118 loop {
12119 match self.dispatch(command.clone()) {
12120 Ok(command_id) => return Ok(command_id),
12121 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12122 sleep(Duration::from_millis(2)).await;
12123 }
12124 Err(error) => return Err(error),
12125 }
12126 }
12127 }
12128
12129 fn internal_session_snapshot(
12130 &self,
12131 address: &SessionAddress,
12132 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12133 self.validate_address(address)?;
12134 self.handle
12135 .snapshot()
12136 .sessions
12137 .clone()
12138 .into_iter()
12139 .find(|session| {
12140 session.instance_id == address.session.instance_id
12141 && session.generation == address.session.generation
12142 })
12143 .ok_or_else(|| {
12144 failure(
12145 NodeFailureCode::UnknownSession,
12146 "session snapshot is unavailable",
12147 )
12148 })
12149 }
12150
12151 fn require_acp_session(
12159 &self,
12160 address: &SessionAddress,
12161 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12162 let snapshot = self.internal_session_snapshot(address)?;
12163 if snapshot.transport != TransportKind::Acp {
12164 return Err(failure(
12165 NodeFailureCode::UnsupportedTransport,
12166 "this verb requires a session spawned over the ACP transport",
12167 ));
12168 }
12169 Ok(snapshot)
12170 }
12171
12172 async fn dispatch_history_bounded(
12173 &self,
12174 address: &SessionAddress,
12175 command: ControlCommand,
12176 operation: HistoryOperation,
12177 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12178 self.validate_address(address)?;
12179 let started_after = self.current_sequence();
12180 let command_id = self
12181 .dispatch_bounded(
12182 command,
12183 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12184 )
12185 .await?;
12186 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12187 let mut scan_after = started_after;
12188 let mut accepted_after = None;
12189 loop {
12190 let events = self
12191 .history
12192 .lock()
12193 .unwrap_or_else(|poisoned| poisoned.into_inner())
12194 .events
12195 .iter()
12196 .filter(|envelope| envelope.sequence > scan_after)
12197 .cloned()
12198 .collect::<Vec<_>>();
12199 for envelope in events {
12200 scan_after = scan_after.max(envelope.sequence);
12201 let NodeEvent::Control {
12202 address: event_address,
12203 event,
12204 } = envelope.event
12205 else {
12206 continue;
12207 };
12208 if event_address != *address {
12209 continue;
12210 }
12211 if event.command_id == Some(command_id) {
12212 match &event.event {
12213 ControlEventKind::CommandRejected { .. } => {
12214 return Err(failure(
12215 NodeFailureCode::InvalidRequest,
12216 "history request was rejected",
12217 ));
12218 }
12219 ControlEventKind::HistoryRequested {
12220 operation: requested,
12221 ..
12222 } if requested == &operation => {
12223 accepted_after = Some(envelope.sequence);
12224 continue;
12225 }
12226 _ => {}
12227 }
12228 }
12229 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12230 match (&operation, &event.event) {
12231 (
12232 HistoryOperation::Discover { .. },
12233 ControlEventKind::HistoryDiscovered { .. },
12234 )
12235 | (
12236 HistoryOperation::Load { .. },
12237 ControlEventKind::HistoryLoaded { .. },
12238 ) => return self.internal_session_snapshot(address),
12239 (_, ControlEventKind::HistoryFailed { .. }) => {
12240 return Err(failure(
12241 NodeFailureCode::BackendOperationFailed,
12242 "history operation failed",
12243 ));
12244 }
12245 _ => {}
12246 }
12247 }
12248 }
12249 if Instant::now() >= deadline {
12250 return Err(failure(
12251 NodeFailureCode::BackendBusy,
12252 if accepted_after.is_some() {
12253 "history operation did not settle before the bounded deadline"
12254 } else {
12255 "history operation was not accepted before the bounded deadline"
12256 },
12257 ));
12258 }
12259 sleep(Duration::from_millis(2)).await;
12260 }
12261 }
12262
12263 async fn discover_history(
12264 &self,
12265 address: &SessionAddress,
12266 limit: u16,
12267 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12268 let query = HistoryQuery {
12269 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12270 limit,
12271 };
12272 query
12273 .validate()
12274 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12275 let snapshot = self
12276 .dispatch_history_bounded(
12277 address,
12278 ControlCommand::DiscoverHistory {
12279 instance_id: address.session.instance_id,
12280 query: query.clone(),
12281 },
12282 HistoryOperation::Discover { query },
12283 )
12284 .await?;
12285 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12286 return Err(failure(
12287 NodeFailureCode::BackendOperationFailed,
12288 "history discovery settled into an invalid state",
12289 ));
12290 }
12291 Ok(snapshot.history.candidates)
12292 }
12293
12294 async fn load_history(
12295 &self,
12296 address: &SessionAddress,
12297 candidate_id: String,
12298 ) -> Result<HistorySessionRecord, NodeFailure> {
12299 validate_candidate_id(&candidate_id)
12300 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12301 let snapshot = self
12302 .dispatch_history_bounded(
12303 address,
12304 ControlCommand::LoadHistory {
12305 instance_id: address.session.instance_id,
12306 candidate_id: candidate_id.clone(),
12307 },
12308 HistoryOperation::Load {
12309 candidate_id: candidate_id.clone(),
12310 },
12311 )
12312 .await?;
12313 if snapshot.history.pending.is_some()
12314 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12315 {
12316 return Err(failure(
12317 NodeFailureCode::BackendOperationFailed,
12318 "history load settled into an invalid state",
12319 ));
12320 }
12321 snapshot.history.loaded.ok_or_else(|| {
12322 failure(
12323 NodeFailureCode::BackendOperationFailed,
12324 "history load produced no bounded session",
12325 )
12326 })
12327 }
12328
12329 async fn export_context_pack_for_session_record(
12336 &self,
12337 record_id: &SessionRecordId,
12338 session: &SessionAddress,
12339 allow_clean_detachment: bool,
12340 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12341 let outcome = self
12347 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12348 .await;
12349 if let Err(failure) = &outcome {
12350 tracing::warn!(
12351 record = %record_id,
12352 code = ?failure.code,
12353 reason = %failure.message,
12354 "session-record context-pack export refused",
12355 );
12356 }
12357 outcome
12358 }
12359
12360 async fn export_context_pack_for_session_record_inner(
12361 &self,
12362 record_id: &SessionRecordId,
12363 session: &SessionAddress,
12364 allow_clean_detachment: bool,
12365 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12366 let step = |name: &'static str| move |failure: NodeFailure| {
12371 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12372 failure
12373 };
12374 let record = self.record(record_id).map_err(step("record-lookup"))?;
12375 let identity = match record.provider_session.clone() {
12391 Some(identity) => {
12392 if identity.key != ProviderSessionKey::SessionId {
12393 return Err(step("provider-identity-key")(failure(
12394 NodeFailureCode::BackendOperationFailed,
12395 "managed session provider identity cannot bind history exactly",
12396 )));
12397 }
12398 Some(identity)
12399 }
12400 None if record.state == ManagedSessionState::IdentityPending => {
12401 return Err(step("provider-session-identity")(failure(
12402 NodeFailureCode::BackendOperationFailed,
12403 "managed session has not yet observed its provider session identity",
12404 )));
12405 }
12406 None => None,
12407 };
12408 self.revalidate_session_record_context_export(
12409 &record,
12410 identity.as_ref(),
12411 session,
12412 allow_clean_detachment,
12413 ).map_err(step("revalidate-before-discovery"))?;
12414 let candidates = self
12415 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12416 .await
12417 .map_err(step("discover-history"))?;
12418 let candidate_id = select_history_candidate(
12423 &candidates,
12424 identity.as_ref().map(|identity| identity.id.as_str()),
12425 ).map_err(step("unique-candidate"))?;
12426 let loaded = self.load_history(session, candidate_id.clone()).await
12427 .map_err(step("load-history"))?;
12428 if let Some(identity) = identity.as_ref() {
12429 if loaded.session_id != identity.id {
12430 return Err(failure(
12431 NodeFailureCode::SessionRecordConflict,
12432 "loaded history identity does not match the managed session record",
12433 ));
12434 }
12435 }
12436 self.revalidate_session_record_context_export(
12437 &record,
12438 identity.as_ref(),
12439 session,
12440 allow_clean_detachment,
12441 )?;
12442 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12443 .map_err(step("revalidate-loaded-history"))?;
12444 let pack = self.materialize_context_pack(session).await
12445 .map_err(step("materialize-context-pack"))?;
12446 self.commit_context_pack_for_session_record(
12447 &record,
12448 identity.as_ref(),
12449 session,
12450 &candidate_id,
12451 &loaded,
12452 pack,
12453 allow_clean_detachment,
12454 )
12455 }
12456
12457 async fn export_context_pack(
12458 &self,
12459 address: &SessionAddress,
12460 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12461 let pack = self.materialize_context_pack(address).await?;
12462 self.commit_context_pack(pack)
12463 }
12464
12465 async fn materialize_context_pack(
12466 &self,
12467 address: &SessionAddress,
12468 ) -> Result<NodeContextPack, NodeFailure> {
12469 let snapshot = self.internal_session_snapshot(address)?;
12470 if !context_pack_source_status_is_usable(&snapshot.status) {
12471 return Err(failure(
12472 NodeFailureCode::InvalidRequest,
12473 "context export requires a running or successfully exited source session",
12474 ));
12475 }
12476 if snapshot.history.pending.is_some()
12477 || snapshot.history.loaded_candidate_id.is_none()
12478 {
12479 return Err(failure(
12480 NodeFailureCode::InvalidRequest,
12481 "context export requires settled loaded history",
12482 ));
12483 }
12484 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12485 failure(
12486 NodeFailureCode::InvalidRequest,
12487 "context export requires settled loaded history",
12488 )
12489 })?;
12490 let source_provider = snapshot.agent_id.clone();
12491 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12492 let repository = self
12493 .context_pack_repository(&address.workspace_id)
12494 .await?;
12495 let current = self.internal_session_snapshot(address)?;
12496 if !context_pack_source_status_is_usable(¤t.status)
12497 || current.agent_id != source_provider
12498 || current.history.pending.is_some()
12499 || current.history.loaded_candidate_id != loaded_candidate_id
12500 || current.history.loaded.as_ref() != Some(&loaded)
12501 {
12502 return Err(failure(
12503 NodeFailureCode::ContextPackMaterializationFailed,
12504 "context source changed during bounded repository capture",
12505 ));
12506 }
12507 NodeContextPack::export_with_repository(
12508 ContextPackLineageReceipt {
12509 source_node_id: self.node_id.clone(),
12510 source_session: address.clone(),
12511 source_provider,
12512 },
12513 &loaded,
12514 Some(repository),
12515 )
12516 .map_err(|_| {
12517 failure(
12518 NodeFailureCode::ContextPackMaterializationFailed,
12519 "bounded context pack export failed",
12520 )
12521 })
12522 }
12523
12524 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12530 let mut store = self
12531 .context_pack_store
12532 .lock()
12533 .unwrap_or_else(|poisoned| poisoned.into_inner());
12534 let Some(store) = store.as_mut() else {
12535 return Ok(());
12536 };
12537 store.commit(pack).map_err(|_| {
12538 failure(
12539 NodeFailureCode::ContextPackMaterializationFailed,
12540 "durable context pack commit failed",
12541 )
12542 })
12543 }
12544
12545 fn commit_context_pack(
12546 &self,
12547 pack: NodeContextPack,
12548 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12549 self.commit_context_pack_durably(&pack)?;
12550 self.context_catalog
12551 .write()
12552 .unwrap_or_else(|poisoned| poisoned.into_inner())
12553 .insert(pack)
12554 .map_err(|_| {
12555 failure(
12556 NodeFailureCode::ContextPackMaterializationFailed,
12557 "context pack catalog is full",
12558 )
12559 })
12560 }
12561
12562 fn commit_context_pack_for_session_record(
12563 &self,
12564 expected: &ManagedSessionRecord,
12565 identity: Option<&ProviderSessionIdentity>,
12566 session: &SessionAddress,
12567 candidate_id: &str,
12568 loaded: &HistorySessionRecord,
12569 pack: NodeContextPack,
12570 allow_clean_detachment: bool,
12571 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12572 let current_root = self.workspace_root(&expected.workspace_id)?;
12573 let _transaction = self
12574 .state_transaction
12575 .lock()
12576 .unwrap_or_else(|poisoned| poisoned.into_inner());
12577 let records = self
12578 .session_records
12579 .lock()
12580 .unwrap_or_else(|poisoned| poisoned.into_inner());
12581 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12582 failure(
12583 NodeFailureCode::SessionRecordConflict,
12584 "managed session record disappeared during context export",
12585 )
12586 })?;
12587 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12588 return Err(failure(
12589 NodeFailureCode::SessionRecordConflict,
12590 "managed session record changed during context export",
12591 ));
12592 }
12593 let bindings = self
12594 .session_bindings
12595 .lock()
12596 .unwrap_or_else(|poisoned| poisoned.into_inner());
12597 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12598 failure(
12599 NodeFailureCode::SessionRecordConflict,
12600 "managed session runtime binding disappeared during context export",
12601 )
12602 })?;
12603 let runtime = self
12604 .handle
12605 .snapshot()
12606 .sessions
12607 .iter()
12608 .find(|snapshot| {
12609 snapshot.instance_id == session.session.instance_id
12610 && snapshot.generation == session.session.generation
12611 })
12612 .cloned()
12613 .ok_or_else(|| {
12614 failure(
12615 NodeFailureCode::SessionRecordConflict,
12616 "managed session runtime disappeared during context export",
12617 )
12618 })?;
12619 let receipt = pack.receipt();
12620 if !session_record_context_export_binding_is_exact(
12621 expected,
12622 current,
12623 identity,
12624 session,
12625 binding,
12626 &runtime.agent_id,
12627 ¤t_root,
12628 allow_clean_detachment,
12629 ) || !session_record_context_export_source_is_usable(
12630 current,
12631 &runtime.status,
12632 allow_clean_detachment,
12633 ) || runtime.history.pending.is_some()
12634 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12635 || runtime.history.loaded.as_ref() != Some(loaded)
12636 || receipt.lineage.source_node_id != self.node_id
12637 || receipt.lineage.source_session != *session
12638 || receipt.lineage.source_provider != current.provider
12639 || receipt.source_message_count != loaded.message_count
12640 {
12641 return Err(failure(
12642 NodeFailureCode::SessionRecordConflict,
12643 "managed session source changed before context catalog commit",
12644 ));
12645 }
12646 self.commit_context_pack_durably(&pack)?;
12647 self.context_catalog
12648 .write()
12649 .unwrap_or_else(|poisoned| poisoned.into_inner())
12650 .insert(pack)
12651 .map_err(|_| {
12652 failure(
12653 NodeFailureCode::ContextPackMaterializationFailed,
12654 "context pack catalog is full",
12655 )
12656 })
12657 }
12658
12659 async fn context_pack_repository(
12660 &self,
12661 workspace_id: &WorkspaceId,
12662 ) -> Result<ContextPackRepository, NodeFailure> {
12663 let first = self
12664 .context_pack_repository_observation(workspace_id)
12665 .await?;
12666 let second = self
12667 .context_pack_repository_observation(workspace_id)
12668 .await?;
12669 stable_context_pack_repository(first, second)
12670 }
12671
12672 async fn context_pack_repository_observation(
12673 &self,
12674 workspace_id: &WorkspaceId,
12675 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12676 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12677 let head = if git.is_repository {
12678 let root = self.workspace_root(workspace_id)?;
12679 match resolve_base_commit_with_timeout(
12680 &root,
12681 "HEAD",
12682 GIT_COMMAND_TIMEOUT_MS,
12683 )
12684 .await
12685 {
12686 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12687 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12688 ContextPackRepositoryHead::Unborn
12689 }
12690 Err(_) => {
12691 return Err(failure(
12692 NodeFailureCode::ContextPackMaterializationFailed,
12693 "context pack HEAD observation failed",
12694 ));
12695 }
12696 }
12697 } else {
12698 ContextPackRepositoryHead::NotRepository
12699 };
12700 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12701 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12702 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12703 failure(
12704 NodeFailureCode::InvalidRepositoryPath,
12705 "context pack selected file path is invalid",
12706 )
12707 })?;
12708 match self.read_workspace_file(workspace_id.clone(), path).await {
12709 Ok(WorkspaceFileRead {
12710 content: WorkspaceFileContent::Utf8 { text, byte_len },
12711 ..
12712 }) => files.push(ContextPackRepositoryFileSource::utf8(
12713 selected_path,
12714 text,
12715 byte_len,
12716 )),
12717 Ok(WorkspaceFileRead {
12718 content: WorkspaceFileContent::NonUtf8 { byte_len },
12719 ..
12720 }) => files.push(ContextPackRepositoryFileSource::skipped(
12721 selected_path,
12722 ContextPackSelectedFileSkipReason::NonUtf8,
12723 Some(byte_len),
12724 )),
12725 Ok(WorkspaceFileRead {
12726 content: WorkspaceFileContent::TooLarge { .. },
12727 ..
12728 }) => files.push(ContextPackRepositoryFileSource::skipped(
12729 selected_path,
12730 ContextPackSelectedFileSkipReason::TooLarge,
12731 None,
12732 )),
12733 Err(error) => {
12734 let reason = if matches!(
12735 error.code,
12736 NodeFailureCode::InvalidRepositoryPath
12737 | NodeFailureCode::RepositoryFileNotRegular
12738 | NodeFailureCode::RepositoryPathUnsafe
12739 ) {
12740 ContextPackSelectedFileSkipReason::Unsafe
12741 } else {
12742 ContextPackSelectedFileSkipReason::Unavailable
12743 };
12744 files.push(ContextPackRepositoryFileSource::skipped(
12745 selected_path,
12746 reason,
12747 None,
12748 ));
12749 }
12750 }
12751 }
12752 Ok(ContextPackRepositoryObservation { head, git, files })
12753 }
12754
12755 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12756 let referenced_by_binding = self
12757 .session_bindings
12758 .lock()
12759 .unwrap_or_else(|poisoned| poisoned.into_inner())
12760 .values()
12761 .any(|binding| {
12762 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12763 });
12764 let referenced_by_record = self
12765 .session_records
12766 .lock()
12767 .unwrap_or_else(|poisoned| poisoned.into_inner())
12768 .records
12769 .values()
12770 .any(|record| record.context_id.as_ref() == Some(context_id));
12771 let referenced_by_materialization = self
12772 .materializations
12773 .lock()
12774 .unwrap_or_else(|poisoned| poisoned.into_inner())
12775 .values()
12776 .any(|ownership| {
12777 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12778 });
12779 if referenced_by_binding
12780 || referenced_by_record
12781 || referenced_by_materialization
12782 {
12783 return Err(failure(
12784 NodeFailureCode::ContextPackBusy,
12785 "context pack is still referenced",
12786 ));
12787 }
12788 let mut catalog = self.context_catalog
12789 .write()
12790 .unwrap_or_else(|poisoned| poisoned.into_inner());
12791 if catalog.get(context_id).is_none() {
12792 return Err(failure(
12793 NodeFailureCode::UnknownContextPack,
12794 "context pack is unavailable",
12795 ));
12796 }
12797 self.spawn_idempotency
12798 .lock()
12799 .unwrap_or_else(|poisoned| poisoned.into_inner())
12800 .entries
12801 .retain(|_, entry| !entry.value.references_context(context_id));
12802 let removed = catalog.remove(context_id);
12803 debug_assert!(removed.is_some(), "validated context pack remains present");
12804 Ok(())
12805 }
12806
12807 async fn dispatch_input_bounded(
12808 &self,
12809 address: &SessionAddress,
12810 action: InputAction,
12811 ) -> Result<(), NodeFailure> {
12812 let started_after = self.current_sequence();
12813 self.pty_input_marks
12814 .lock()
12815 .unwrap_or_else(|poisoned| poisoned.into_inner())
12816 .insert(address.session.instance_id, (Instant::now(), false));
12817 self.pty_input_wake.notify_one();
12818 let command_id = self
12819 .dispatch_bounded(
12820 ControlCommand::SendInput {
12821 instance_id: address.session.instance_id,
12822 action,
12823 },
12824 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12825 )
12826 .await?;
12827 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12831 let mut scan_after = started_after;
12832 let mut accepted_after = None;
12833 loop {
12834 let events = self
12835 .history
12836 .lock()
12837 .unwrap_or_else(|poisoned| poisoned.into_inner())
12838 .events
12839 .iter()
12840 .filter(|envelope| envelope.sequence > scan_after)
12841 .cloned()
12842 .collect::<Vec<_>>();
12843 for envelope in events {
12844 scan_after = scan_after.max(envelope.sequence);
12845 let NodeEvent::Control {
12846 address: event_address,
12847 event,
12848 } = envelope.event
12849 else {
12850 continue;
12851 };
12852 if event_address != *address {
12853 continue;
12854 }
12855 if event.command_id == Some(command_id) {
12856 match &event.event {
12857 ControlEventKind::CommandRejected { message } => {
12858 return Err(failure(NodeFailureCode::InvalidRequest, message));
12859 }
12860 ControlEventKind::InputRequested { .. } => {
12861 accepted_after = Some(envelope.sequence);
12862 continue;
12863 }
12864 _ => {}
12865 }
12866 }
12867 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12868 match &event.event {
12869 ControlEventKind::InputCompleted { .. } => return Ok(()),
12870 ControlEventKind::InputFailed { message, .. } => {
12871 return Err(failure(
12872 NodeFailureCode::BackendOperationFailed,
12873 message,
12874 ));
12875 }
12876 _ => {}
12877 }
12878 }
12879 }
12880 if Instant::now() >= deadline {
12881 return Err(failure(
12882 NodeFailureCode::BackendBusy,
12883 if accepted_after.is_some() {
12884 "input was accepted but did not settle before the bounded deadline"
12885 } else {
12886 "input was not accepted before the bounded deadline"
12887 },
12888 ));
12889 }
12890 sleep(Duration::from_millis(2)).await;
12891 }
12892 }
12893
12894 async fn dispatch_resize_bounded(
12895 &self,
12896 address: &SessionAddress,
12897 size: gate4agent_types::TerminalSize,
12898 ) -> Result<(), NodeFailure> {
12899 let started_after = self.current_sequence();
12900 let command_id = self
12901 .dispatch_bounded(
12902 ControlCommand::Resize {
12903 instance_id: address.session.instance_id,
12904 size,
12905 },
12906 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12907 )
12908 .await?;
12909 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12910 let mut scan_after = started_after;
12911 let mut accepted_after = None;
12912 loop {
12913 let events = self
12914 .history
12915 .lock()
12916 .unwrap_or_else(|poisoned| poisoned.into_inner())
12917 .events
12918 .iter()
12919 .filter(|envelope| envelope.sequence > scan_after)
12920 .cloned()
12921 .collect::<Vec<_>>();
12922 for envelope in events {
12923 scan_after = scan_after.max(envelope.sequence);
12924 let NodeEvent::Control {
12925 address: event_address,
12926 event,
12927 } = envelope.event
12928 else {
12929 continue;
12930 };
12931 if event_address != *address {
12932 continue;
12933 }
12934 if event.command_id == Some(command_id) {
12935 match &event.event {
12936 ControlEventKind::CommandRejected { message } => {
12937 return Err(failure(NodeFailureCode::InvalidRequest, message));
12938 }
12939 ControlEventKind::ResizeRequested {
12940 size: requested_size,
12941 ..
12942 } if *requested_size == size => {
12943 accepted_after = Some(envelope.sequence);
12944 continue;
12945 }
12946 _ => {}
12947 }
12948 }
12949 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12950 match &event.event {
12951 ControlEventKind::Resized { size: settled_size }
12952 if *settled_size == size =>
12953 {
12954 return Ok(());
12955 }
12956 ControlEventKind::ResizeFailed { message } => {
12957 return Err(failure(
12958 NodeFailureCode::BackendOperationFailed,
12959 message,
12960 ));
12961 }
12962 _ => {}
12963 }
12964 }
12965 }
12966 if Instant::now() >= deadline {
12967 return Err(failure(
12968 NodeFailureCode::BackendBusy,
12969 if accepted_after.is_some() {
12970 "resize was accepted but did not settle before the bounded deadline"
12971 } else {
12972 "resize was not accepted before the bounded deadline"
12973 },
12974 ));
12975 }
12976 sleep(Duration::from_millis(2)).await;
12977 }
12978 }
12979
12980 async fn spawn_session(
12981 &self,
12982 workspace_id: WorkspaceId,
12983 provider: AgentId,
12984 mode: SessionMode,
12985 terminal_size: gate4agent_types::TerminalSize,
12986 initial_prompt: Option<String>,
12987 ) -> Result<SessionAddress, NodeFailure> {
12988 if self.managed_worktrees.lock()
12989 .unwrap_or_else(|poisoned| poisoned.into_inner())
12990 .lease_for_workspace(&workspace_id)
12991 .is_some()
12992 {
12993 return Err(failure(
12994 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12995 "managed worktree targets may only be spawned through their lease request",
12996 ));
12997 }
12998 self
12999 .spawn_session_with_deadline(
13000 workspace_id,
13001 provider.clone(),
13002 mode,
13003 terminal_size,
13004 initial_prompt,
13005 None,
13006 None,
13007 None,
13008 None,
13009 None,
13010 SpawnRecordPolicy::ProviderIdentityOnly,
13011 None,
13012 &[],
13013 None,
13014 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13021 None,
13022 None,
13023 )
13024 .await
13025 .map(|(session, _runtime_policy)| session)
13026 }
13027
13028 async fn spawn_session_with_deadline(
13029 &self,
13030 workspace_id: WorkspaceId,
13031 provider: AgentId,
13032 mode: SessionMode,
13033 terminal_size: gate4agent_types::TerminalSize,
13034 initial_prompt: Option<String>,
13035 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13036 bundle: Option<ResolvedBundleReceipt>,
13037 context: Option<ResolvedContextPackReceipt>,
13038 managed_authority: Option<ManagedWorktreeLeaseId>,
13039 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13040 record_policy: SpawnRecordPolicy,
13041 deadline: Option<Instant>,
13042 required_capabilities: &[ProviderRuntimeCapability],
13043 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13044 approval_level: ApprovalLevel,
13045 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13046 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13052 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13053 let reserved_lease = self.managed_worktrees.lock()
13054 .unwrap_or_else(|poisoned| poisoned.into_inner())
13055 .lease_for_workspace(&workspace_id);
13056 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13057 (Some(expected), Some(provided)) if expected == provided => {}
13058 (None, None) => {}
13059 _ => {
13060 return Err(failure(
13061 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13062 "spawn lacks exact managed worktree lease authority",
13063 ));
13064 }
13065 }
13066 self.ensure_workspace_environment_profile_compatible(
13070 &workspace_id,
13071 environment_profile.as_ref(),
13072 )?;
13073 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13074 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13075 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13076 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13077 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13078 };
13079 let transport = match mode {
13085 SessionMode::Pty => TransportKind::Pty,
13086 SessionMode::Inline => TransportKind::Pipe,
13087 SessionMode::Acp => TransportKind::Acp,
13088 };
13089 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13090 let acp_transport = self.provider_supports_acp_transport(&provider);
13091 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13092 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13093 NodeFailureCode::UnsupportedSpawnCapability,
13094 "pre-admitted provider runtime no longer satisfies spawn requirements",
13095 ))?;
13096 runtime_policy
13097 } else if let Some(deadline) = deadline {
13098 let remaining = spawn_deadline_remaining(deadline)?;
13099 timeout(
13100 remaining,
13101 self.admit_provider_runtime(&provider, runtime_requirement),
13102 )
13103 .await
13104 .map_err(|_| {
13105 failure(
13106 NodeFailureCode::SpawnDeadlineExceeded,
13107 "provider admission exceeded the spawn deadline",
13108 )
13109 })??
13110 } else {
13111 self.admit_provider_runtime(&provider, runtime_requirement).await?
13112 };
13113 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13114 let runtime_policy =
13126 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13127 if required_capabilities
13128 .iter()
13129 .any(|capability| !runtime_policy.admits(*capability))
13130 {
13131 return Err(failure(
13132 NodeFailureCode::UnsupportedSpawnCapability,
13133 "provider runtime does not admit a required spawn capability",
13134 ));
13135 }
13136 if let Some(deadline) = deadline {
13137 spawn_deadline_remaining(deadline)?;
13138 }
13139 self.ensure_binding_capacity()?;
13140 let mut working_directory = self.workspace_root(&workspace_id)?;
13141 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13142 let session = SessionKey {
13143 instance_id,
13144 generation: SessionGeneration(1),
13145 };
13146 let address = SessionAddress {
13147 workspace_id: workspace_id.clone(),
13148 session,
13149 };
13150 tracing::info!(
13158 node_id = %self.node_id,
13159 workspace_id = %workspace_id,
13160 provider = %provider,
13161 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13162 session = ?address.session,
13163 "session spawn request accepted",
13164 );
13165 let mut prepared_spawn = SpawnPreparer::prepare(
13166 self,
13167 SpawnPrepareRequest {
13168 instance_id,
13169 address: &address,
13170 provider: &provider,
13171 mode,
13172 environment_profile: environment_profile.as_ref(),
13173 bundle: bundle.as_ref(),
13174 context: context.as_ref(),
13175 managed_authority: managed_authority.as_ref(),
13176 harness_mcp,
13177 approval_level,
13178 network_allowlist: network_allowlist.as_ref(),
13179 browser_profile_id,
13180 },
13181 )?;
13182 let materialization_id = prepared_spawn.materialization_id.take();
13183 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13184 let record_id = self.bind_spawn_session_with_materialization(
13185 &address,
13186 provider.clone(),
13187 mode,
13188 runtime_policy,
13189 record_policy,
13190 environment_profile,
13191 bundle,
13192 context,
13193 materialization_id,
13194 )?;
13195 prepared_spawn.retain_session_guards();
13196 let session_provider = provider.clone();
13200 let agent_id = provider;
13201 let register_command_id = match self.dispatch_bounded(
13202 ControlCommand::Register {
13203 instance_id,
13204 agent_id,
13205 transport,
13206 },
13207 dispatch_timeout,
13208 )
13209 .await {
13210 Ok(command_id) => command_id,
13211 Err(error) => {
13212 if let Some(binding) = self.remove_binding(&address) {
13213 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13214 }
13215 if let Some(record_id) = record_id.as_ref() {
13216 self.discard_record(record_id)?;
13217 }
13218 return Err(spawn_dispatch_error(error, deadline));
13219 }
13220 };
13221 let start_timeout = match spawn_dispatch_timeout(deadline) {
13222 Ok(timeout) => timeout,
13223 Err(error) => {
13224 let recovery = self
13225 .rollback_spawn(
13226 &address,
13227 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13228 )
13229 .await;
13230 return match recovery {
13231 Ok(()) => {
13232 if let Some(record_id) = record_id.as_ref() {
13233 self.discard_record(record_id)?;
13234 }
13235 Err(error)
13236 }
13237 Err(recovery_error) => {
13238 if let Some(record_id) = record_id.as_ref() {
13239 self.mark_record_error(
13240 record_id,
13241 "spawn deadline elapsed and registration recovery failed",
13242 )?;
13243 }
13244 Err(recovery_error)
13245 }
13246 };
13247 }
13248 };
13249 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13250 if let Err(recovery_error) = self
13251 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13252 .await
13253 {
13254 return Err(recovery_error);
13255 }
13256 if let Some(record_id) = record_id.as_ref() {
13257 self.discard_record(record_id)?;
13258 }
13259 return Err(start_error);
13260 }
13261 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13263 working_directory = dir.to_string_lossy().into_owned();
13264 }
13265 let start_result = self
13266 .dispatch_bounded(
13267 ControlCommand::Start {
13268 instance_id,
13269 runtime_policy,
13270 request: StartRequest {
13271 working_directory,
13272 terminal_size,
13273 initial_prompt,
13274 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13275 approval_level,
13276 },
13277 },
13278 start_timeout,
13279 )
13280 .await;
13281 let start_command_id = match start_result {
13282 Ok(command_id) => command_id,
13283 Err(start_error) => {
13284 let start_error = spawn_dispatch_error(start_error, deadline);
13285 let recovery = self
13286 .rollback_spawn(
13287 &address,
13288 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13289 )
13290 .await;
13291 return match recovery {
13292 Ok(()) => {
13293 if let Some(record_id) = record_id.as_ref() {
13294 self.discard_record(record_id)?;
13295 }
13296 Err(start_error)
13297 }
13298 Err(recovery_error) => {
13299 let diagnostic = format!(
13300 "start failed and registration recovery failed: {}",
13301 recovery_error.message,
13302 );
13303 if let Some(record_id) = record_id.as_ref() {
13304 self.mark_record_error(record_id, &diagnostic)?;
13305 }
13306 Err(failure(recovery_error.code, &diagnostic))
13307 }
13308 };
13309 }
13310 };
13311
13312 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13313 loop {
13314 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13323 .or_else(|| self.take_rejected_command_reason(start_command_id))
13324 {
13325 self.rollback_spawn(
13326 &address,
13327 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13328 ).await.map_err(|error| {
13329 failure(
13330 error.code,
13331 &format!(
13332 "spawn was rejected ({}) and registration recovery failed: {}",
13333 reason.message, error.message
13334 ),
13335 )
13336 })?;
13337 if let Some(record_id) = record_id.as_ref() {
13338 self.discard_record(record_id)?;
13339 }
13340 let code = if reason.unsupported_transport {
13341 NodeFailureCode::UnsupportedTransport
13342 } else {
13343 NodeFailureCode::BackendOperationFailed
13344 };
13345 return Err(failure(code, &reason.message));
13346 }
13347 if let Some(current) = self
13348 .handle
13349 .snapshot()
13350 .sessions
13351 .iter()
13352 .find(|current| current.instance_id == instance_id)
13353 {
13354 if current.generation != session.generation {
13355 let divergence = failure(
13356 NodeFailureCode::StaleGeneration,
13357 "spawn generation diverged",
13358 );
13359 return match self
13360 .rollback_spawn(
13361 &address,
13362 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13363 )
13364 .await
13365 {
13366 Ok(()) => {
13367 if let Some(record_id) = record_id.as_ref() {
13368 self.discard_record(record_id)?;
13369 }
13370 Err(divergence)
13371 }
13372 Err(recovery_error) => {
13373 if let Some(record_id) = record_id.as_ref() {
13374 self.mark_record_error(
13375 record_id,
13376 "spawn generation diverged and recovery failed",
13377 )?;
13378 }
13379 Err(recovery_error)
13380 }
13381 };
13382 }
13383 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13384 prepared_spawn.retain_launch_guards();
13385 return Ok((address, runtime_policy));
13386 }
13387 }
13388 if Instant::now() >= commit_deadline {
13389 self.rollback_spawn(
13390 &address,
13391 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13392 ).await.map_err(|error| {
13393 failure(
13394 error.code,
13395 &format!(
13396 "start did not commit and registration recovery failed: {}",
13397 error.message
13398 ),
13399 )
13400 })?;
13401 if let Some(record_id) = record_id.as_ref() {
13402 self.discard_record(record_id)?;
13403 }
13404 return Err(failure(
13405 if deadline.is_some() {
13406 NodeFailureCode::SpawnDeadlineExceeded
13407 } else {
13408 NodeFailureCode::BackendBusy
13409 },
13410 "start did not commit before the bounded deadline; registration was removed",
13411 ));
13412 }
13413 sleep(Duration::from_millis(2)).await;
13414 }
13415 }
13416
13417 fn effective_spawn_runtime_policy(
13418 &self,
13419 runtime_policy: ProviderRuntimePolicy,
13420 ) -> ProviderRuntimePolicy {
13421 #[cfg(feature = "fixture")]
13422 let runtime_policy = if self.fixture_semantic_hook_policy {
13423 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13424 .expect("monitoring Hook fixture policy is internally valid")
13425 } else {
13426 runtime_policy
13427 };
13428 runtime_policy
13429 }
13430
13431 fn verify_harness_mcp_before_start(
13432 &self,
13433 prepared: Option<&PreparedHarnessMcpSpawn>,
13434 ) -> Result<(), NodeFailure> {
13435 prepared
13436 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13437 .transpose()
13438 .map(|_| ())
13439 .map_err(harness_mcp_failure)
13440 }
13441
13442 async fn rollback_spawn(
13443 &self,
13444 address: &SessionAddress,
13445 commit_timeout: Duration,
13446 ) -> Result<(), NodeFailure> {
13447 self.dispatch_bounded(
13448 ControlCommand::Remove {
13449 instance_id: address.session.instance_id,
13450 },
13451 commit_timeout,
13452 )
13453 .await?;
13454 self.wait_until_removed(address, commit_timeout).await?;
13455 if let Some(binding) = self.remove_binding(address) {
13456 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13457 }
13458 Ok(())
13459 }
13460
13461 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13462 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13463 self.dispatch_bounded(
13464 ControlCommand::Remove {
13465 instance_id: address.session.instance_id,
13466 },
13467 commit_timeout,
13468 )
13469 .await?;
13470 self.wait_until_removed(address, commit_timeout).await?;
13471 let removed = self.remove_binding(address);
13472 if let Some(binding) = removed.as_ref() {
13473 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13474 }
13475 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13476 let snapshot = self.managed_worktrees.lock()
13477 .unwrap_or_else(|poisoned| poisoned.into_inner())
13478 .release_session(
13479 &lease_id,
13480 self.incarnation_id,
13481 address.session.instance_id,
13482 address.session.generation,
13483 unix_time_ms(),
13484 );
13485 if self.persist_state().is_err() {
13486 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13487 }
13488 if let Some(snapshot) = snapshot {
13489 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13490 }
13491 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13492 }
13493 Ok(())
13494 }
13495
13496 async fn wait_until_removed(
13542 &self,
13543 address: &SessionAddress,
13544 commit_timeout: Duration,
13545 ) -> Result<(), NodeFailure> {
13546 let deadline = Instant::now() + commit_timeout;
13547 let mut next_dispatch = Instant::now();
13548 loop {
13549 let snapshot = self.handle.snapshot();
13550 let status = snapshot
13551 .sessions
13552 .iter()
13553 .find(|session| session.instance_id == address.session.instance_id)
13554 .map(|session| session.status.clone());
13555 let Some(status) = status else {
13556 return Ok(());
13557 };
13558 let now = Instant::now();
13559 if now >= deadline {
13560 return Err(failure(
13561 NodeFailureCode::BackendBusy,
13562 "remove did not commit before the bounded deadline; session binding was retained",
13563 ));
13564 }
13565 if status.allows_remove() && now >= next_dispatch {
13566 let _ = self.dispatch(ControlCommand::Remove {
13567 instance_id: address.session.instance_id,
13568 });
13569 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13570 }
13571 sleep(Duration::from_millis(2)).await;
13572 }
13573 }
13574
13575 fn resync(&self, after_sequence: u64) -> NodeResponse {
13576 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13577 let event_sequence = history.last_sequence;
13578 let oldest_available_sequence = history.replay_floor_sequence;
13579 let events = history
13580 .events
13581 .iter()
13582 .filter(|event| event.sequence > after_sequence)
13583 .cloned()
13584 .collect();
13585 drop(history);
13586 NodeResponse::Resync {
13587 event_sequence,
13588 oldest_available_sequence,
13589 snapshot: self.snapshot(),
13590 events,
13591 }
13592 }
13593}
13594
13595fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13600 match kind {
13601 AgentStreamChunkKindV1::Text { .. } => "text",
13602 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13603 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13604 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13605 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13606 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13607 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13608 }
13609}
13610
13611async fn accept_connections(
13625 endpoint: &str,
13626 call_home: Option<std::net::SocketAddr>,
13627 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13628 shared: Arc<NodeShared>,
13629) -> Result<(), NodeServerError> {
13630 let mut connections = JoinSet::new();
13631 let result = match call_home {
13632 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13633 Some(relay) => {
13634 let listener =
13635 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13636 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13637 tokio::pin!(listener);
13638 tokio::pin!(dialler);
13639 tokio::select! {
13640 result = &mut listener => result,
13641 result = &mut dialler => result,
13642 }
13643 }
13644 };
13645 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13646 while !connections.is_empty() {
13647 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13648 if remaining.is_zero()
13649 || timeout(remaining, connections.join_next()).await.is_err()
13650 {
13651 break;
13652 }
13653 }
13654 connections.abort_all();
13655 while connections.join_next().await.is_some() {}
13656 result
13657}
13658
13659const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13663
13664async fn call_home_loop(
13678 relay: std::net::SocketAddr,
13679 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13680 shared: Arc<NodeShared>,
13681) -> Result<(), NodeServerError> {
13682 let mut failures = 0_usize;
13683 loop {
13684 if shared.shutdown.load(Ordering::Acquire) {
13685 return Ok(());
13686 }
13687 let permit = tokio::select! {
13688 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13689 permit.map_err(|_| NodeServerError::PreauthClosed)?
13690 }
13691 _ = shared.shutdown_notify.notified() => return Ok(()),
13692 };
13693 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13694 Ok(()) => {
13695 failures = 0;
13699 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13700 }
13701 Err(error) => {
13702 failures = failures.saturating_add(1);
13703 tracing::warn!(
13704 node_id = %shared.node_id,
13705 %relay,
13706 cause = %error,
13707 attempt = failures,
13708 "call-home connection failed",
13709 );
13710 let backoff = call_home_backoff(failures);
13711 tokio::select! {
13712 _ = sleep(backoff) => {}
13713 _ = shared.shutdown_notify.notified() => return Ok(()),
13714 }
13715 }
13716 }
13717 }
13718}
13719
13720const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13722const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13724
13725fn call_home_keepalive() -> socket2::TcpKeepalive {
13726 let keepalive = socket2::TcpKeepalive::new()
13727 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13728 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13729 #[cfg(not(windows))]
13730 let keepalive = keepalive.with_retries(3);
13731 keepalive
13732}
13733
13734fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13735 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13736}
13737
13738pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13740 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13741}
13742
13743fn call_home_backoff(attempt: usize) -> Duration {
13745 Duration::from_millis(
13746 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13747 )
13748}
13749
13750
13751async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13754 #[cfg(feature = "wireguard")]
13755 {
13756 let std_stream = tokio::task::spawn_blocking(move || {
13757 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13758 })
13759 .await
13760 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13761 .map_err(NodeServerError::CallHomeTransport)?;
13762 std_stream
13763 .set_nonblocking(true)
13764 .map_err(NodeServerError::CallHomeTransport)?;
13765 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13766 }
13767 #[cfg(not(feature = "wireguard"))]
13768 {
13769 tokio::net::TcpStream::connect(relay)
13770 .await
13771 .map_err(NodeServerError::CallHomeTransport)
13772 }
13773}
13774
13775async fn dial_relay_and_serve(
13777 relay: std::net::SocketAddr,
13778 identity: Option<&hatchery_enroll::NodeIdentity>,
13779 shared: Arc<NodeShared>,
13780 preauth_permit: OwnedSemaphorePermit,
13781) -> Result<(), NodeServerError> {
13782 let mut stream = dial_call_home(relay).await?;
13783 let _ = stream.set_nodelay(true);
13787 if let Err(error) = enable_call_home_keepalive(&stream) {
13793 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13794 }
13795 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13796 .await
13797 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13798 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13799 let mut session_token = None;
13800 if let Some(identity) = identity {
13801 let mut magic = [0_u8; 4];
13805 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13806 .await
13807 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13808 .map_err(NodeServerError::CallHomeTransport)?;
13809 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13810 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13811 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13812 .await
13813 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13814 .map_err(NodeServerError::CallHomeTransport)?;
13815 let (answer, token) = identity
13816 .answer_challenge(shared.node_id.as_str(), &frame)
13817 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13818 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13819 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13820 session_token = Some(token);
13821 }
13822 }
13823 serve_connection(stream, shared, session_token, preauth_permit).await
13824}
13825
13826async fn accept_connections_inner(
13827 endpoint: &str,
13828 shared: Arc<NodeShared>,
13829 connections: &mut JoinSet<Result<(), NodeServerError>>,
13830) -> Result<(), NodeServerError> {
13831 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13832 loop {
13833 while connections.try_join_next().is_some() {}
13834 if shared.shutdown.load(Ordering::Acquire) {
13835 return Ok(());
13836 }
13837 let preauth_permit = tokio::select! {
13838 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13839 permit.map_err(|_| NodeServerError::PreauthClosed)?
13840 }
13841 _ = shared.shutdown_notify.notified() => return Ok(()),
13842 };
13843 let server = tokio::select! {
13844 result = listener.accept() => result?,
13845 _ = shared.shutdown_notify.notified() => return Ok(()),
13846 };
13847 let connection_shared = Arc::clone(&shared);
13848 connections.spawn(async move {
13849 serve_connection(server, connection_shared, None, preauth_permit).await
13850 });
13851 if shared.shutdown.load(Ordering::Acquire) {
13852 return Ok(());
13853 }
13854 }
13855}
13856
13857async fn serve_connection<S>(
13865 mut pipe: S,
13866 shared: Arc<NodeShared>,
13867 session_token: Option<String>,
13868 preauth_permit: OwnedSemaphorePermit,
13869) -> Result<(), NodeServerError>
13870where
13871 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13872{
13873 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13875 let hello_frame = timeout(
13876 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13877 read_json_frame_limited_body_timeout(
13878 &mut pipe,
13879 MAX_NODE_HELLO_FRAME_BYTES,
13880 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13881 ),
13882 )
13883 .await
13884 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13885 let ClientFrame::Hello(hello) = hello_frame else {
13886 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13887 };
13888 if hello.build_stamp != BUILD_STAMP {
13889 return Err(NodeServerError::Handshake(format!(
13890 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13891 hello.build_stamp,
13892 )));
13893 }
13894 let compatibility = match hello.compatibility.as_ref() {
13895 Some(offer) => {
13896 let mut selected = node_compatibility_support(&shared)?
13897 .negotiate(offer)
13898 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13899 project_negotiated_provider_ids(&mut selected);
13900 Some(selected)
13901 }
13902 None => None,
13903 };
13904 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13905 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13906 (Some(offer), Some(selected)) => negotiated_auth_proof(
13907 auth_token,
13908 AuthDirection::Server,
13909 hello.role,
13910 &hello.client_nonce,
13911 &server_nonce,
13912 offer,
13913 selected,
13914 ),
13915 (None, None) => auth_proof(
13916 auth_token,
13917 AuthDirection::Server,
13918 hello.role,
13919 &hello.client_nonce,
13920 &server_nonce,
13921 ),
13922 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13923 }
13924 .map_err(NodeServerError::Authentication)?;
13925 write_json_frame_limited(
13926 &mut pipe,
13927 &ServerFrame::Challenge(ServerChallenge {
13928 build_stamp: BUILD_STAMP.to_owned(),
13929 server_nonce,
13930 server_proof,
13931 compatibility: compatibility.clone(),
13932 }),
13933 MAX_NODE_HELLO_FRAME_BYTES,
13934 )
13935 .await?;
13936 let authentication = timeout(
13937 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13938 read_json_frame_limited_body_timeout(
13939 &mut pipe,
13940 MAX_NODE_HELLO_FRAME_BYTES,
13941 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13942 ),
13943 )
13944 .await
13945 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13946 let ClientFrame::Authenticate(authentication) = authentication else {
13947 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13948 };
13949 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13950 (Some(offer), Some(selected)) => negotiated_auth_proof(
13951 auth_token,
13952 AuthDirection::Client,
13953 hello.role,
13954 &hello.client_nonce,
13955 &server_nonce,
13956 offer,
13957 selected,
13958 ),
13959 (None, None) => auth_proof(
13960 auth_token,
13961 AuthDirection::Client,
13962 hello.role,
13963 &hello.client_nonce,
13964 &server_nonce,
13965 ),
13966 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13967 }
13968 .map_err(NodeServerError::Authentication)?;
13969 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13970 return Err(NodeServerError::Handshake("access denied".to_owned()));
13971 }
13972 let selected_capabilities = compatibility
13973 .as_ref()
13974 .map(|selected| selected.capabilities.clone())
13975 .unwrap_or_default();
13976 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13977 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13978 });
13979 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13980 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13981 });
13982 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13983 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13984 });
13985 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13986 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13987 });
13988 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13989 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13990 });
13991 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13992 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13993 }) && selected_capabilities.iter().any(|capability| {
13994 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13995 });
13996 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13997 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13998 });
13999 let include_session_bundles = selected_capabilities.iter().any(|capability| {
14000 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14001 });
14002 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14003 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14004 });
14005 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14006 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14007 });
14008 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14009 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14010 });
14011 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14012 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14013 });
14014 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14015 .try_acquire_owned()
14016 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14017 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14018 let _release_guard = ControllerReleaseGuard {
14019 shared: Arc::clone(&shared),
14020 connection_id,
14021 };
14022 drop(preauth_permit);
14023 let _authenticated_permit = authenticated_permit;
14024 let mut event_rx = shared.event_tx.subscribe();
14025 let mut terminal_event_rx = terminal_event_subscription(
14026 &shared,
14027 include_terminal_frame_events,
14028 );
14029 let mut agent_stream_event_rx = agent_stream_event_subscription(
14030 &shared,
14031 include_agent_stream_events,
14032 );
14033 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14034 &shared,
14035 include_harness_mcp_proxy,
14036 );
14037 write_json_frame(
14038 &mut pipe,
14039 &ServerFrame::Hello(NodeHello {
14040 build_stamp: BUILD_STAMP.to_owned(),
14041 incarnation_id: shared.incarnation_id,
14042 connection_id,
14043 role: hello.role,
14044 event_sequence: shared.current_sequence(),
14045 controller: shared.controller_state(),
14046 snapshot: snapshot_for_wire(
14047 &shared,
14048 include_provider_runtime_status,
14049 include_open_provider_ids,
14050 include_spawn_profiles,
14051 include_managed_worktrees,
14052 include_child_environment_profiles,
14053 include_session_bundles,
14054 include_history_context_packs,
14055 include_agent_progress,
14056 include_session_task_correlation,
14057 ),
14058 compatibility,
14059 }),
14060 )
14061 .await?;
14062 let (mut reader, mut writer) = tokio::io::split(pipe);
14063 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14064 let reader_task = tokio::spawn(async move {
14065 loop {
14066 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14067 &mut reader,
14068 MAX_NODE_CLIENT_FRAME_BYTES,
14069 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14070 )
14071 .await;
14072 let terminal = frame.is_err();
14073 if frame_tx.send(frame).await.is_err() || terminal {
14074 break;
14075 }
14076 }
14077 });
14078 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14079 let mut pending_events = Vec::new();
14080 let mut resync_required = false;
14081 let mut discard_events_through = 0_u64;
14082 loop {
14083 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14088 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14089 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14090 for _ in 0..durable_drain_budget {
14091 match event_rx.try_recv() {
14092 Ok(event) => queue_connection_event(
14093 &mut pending_events,
14094 event,
14095 discard_events_through,
14096 ),
14097 Err(broadcast::error::TryRecvError::Empty) => break,
14098 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14099 resync_required = true;
14100 break;
14101 }
14102 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14103 }
14104 }
14105 if let Some(receiver) = terminal_event_rx.as_mut() {
14106 match receiver.try_recv() {
14107 Ok(events) => queue_connection_event_batch(
14108 &mut pending_events,
14109 &events,
14110 discard_events_through,
14111 ),
14112 Err(broadcast::error::TryRecvError::Empty) => {}
14113 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14114 resync_required = true;
14115 }
14116 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14117 }
14118 }
14119 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14120 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14130 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14131 for _ in 0..agent_stream_drain_budget {
14132 match receiver.try_recv() {
14133 Ok(events) => queue_connection_event_batch(
14134 &mut pending_events,
14135 &events,
14136 discard_events_through,
14137 ),
14138 Err(broadcast::error::TryRecvError::Empty) => break,
14139 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14140 tracing::warn!(
14141 connection_id,
14142 dropped,
14143 "agent stream event subscription lagged; forcing a resync",
14144 );
14145 resync_required = true;
14146 break;
14147 }
14148 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14149 }
14150 }
14151 }
14152 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14153 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14159 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14160 for _ in 0..harness_mcp_proxy_drain_budget {
14161 match receiver.try_recv() {
14162 Ok(events) => queue_connection_event_batch(
14163 &mut pending_events,
14164 &events,
14165 discard_events_through,
14166 ),
14167 Err(broadcast::error::TryRecvError::Empty) => break,
14168 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14169 tracing::warn!(
14170 connection_id,
14171 dropped,
14172 "harness MCP proxy event subscription lagged; forcing a resync",
14173 );
14174 resync_required = true;
14175 break;
14176 }
14177 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14178 }
14179 }
14180 }
14181 if resync_required {
14182 let event = resync_required_event(&shared);
14183 apply_connection_resync_watermark(
14184 &mut pending_events,
14185 &mut discard_events_through,
14186 event.sequence,
14187 );
14188 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14189 resync_required = false;
14190 continue;
14191 }
14192 if !pending_events.is_empty() {
14193 pending_events.sort_unstable_by_key(|event| event.sequence);
14194 let burst_len = connection_event_burst_len(pending_events.len());
14195 shared
14196 .connection_events_sent
14197 .fetch_add(burst_len as u64, Ordering::Relaxed);
14198 for event in pending_events.drain(..burst_len) {
14199 let event = if include_managed_worktrees {
14200 Some(event)
14201 } else {
14202 project_event_without_managed_worktrees(event)
14203 };
14204 let event = event.and_then(|event| if include_open_provider_ids {
14205 Some(event)
14206 } else {
14207 project_event_legacy_provider_ids(&shared, event)
14208 });
14209 let event = event.map(|event| if include_child_environment_profiles {
14210 event
14211 } else {
14212 project_event_without_child_environment_profile(event)
14213 });
14214 let event = event.map(|event| if include_session_bundles {
14215 event
14216 } else {
14217 project_event_without_session_bundle(event)
14218 });
14219 let event = event.map(|event| {
14220 project_event_history_for_wire(event, include_history_context_packs)
14221 });
14222 let event = event.map(|event| if include_history_context_packs {
14223 event
14224 } else {
14225 project_event_without_context_pack(event)
14226 });
14227 let event = event.map(|event| if include_session_task_correlation {
14228 event
14229 } else {
14230 project_event_without_session_task_binding(event)
14231 });
14232 let exact_harness_controller = hello.role == ClientRole::Operator
14233 && shared.controller_state().is_some_and(|controller| {
14234 controller.connection_id == connection_id
14235 });
14236 let event = event.and_then(|event| {
14237 if include_harness_mcp_proxy && exact_harness_controller {
14238 Some(event)
14239 } else {
14240 project_event_without_harness_mcp_proxy(event)
14241 }
14242 });
14243 if let Some(event) = event {
14244 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14245 }
14246 }
14247 }
14248 tokio::select! {
14249 biased;
14250 frame = frame_rx.recv() => {
14251 let frame = match frame {
14252 Some(frame) => frame,
14253 None => break,
14254 };
14255 let frame = match frame {
14256 Ok(frame) => frame,
14257 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14258 Err(error) => return Err(error.into()),
14259 };
14260 let ClientFrame::Request(request) = frame else {
14261 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14262 };
14263 #[cfg(feature = "fixture")]
14264 let probe_spawn_managed_worktree_v2 = matches!(
14265 &request.request,
14266 NodeRequest::SpawnManagedWorktreeV2 { .. }
14267 );
14268 let requires_open_provider_ids =
14269 request_requires_open_provider_ids(&shared, &request.request);
14270 let requires_child_environment_profile =
14271 request_requires_child_environment_profile(&shared, &request.request);
14272 let requires_session_bundle =
14273 request_requires_session_bundle(&shared, &request.request);
14274 let requires_history_context_pack =
14275 request_requires_history_context_pack(&shared, &request.request);
14276 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14277 ResponseEnvelope {
14278 request_id: request.request_id,
14279 result: Err(failure(
14280 NodeFailureCode::UnsupportedCapability,
14281 "open provider IDs were not negotiated",
14282 )),
14283 }
14284 } else if requires_child_environment_profile
14285 && !include_child_environment_profiles
14286 {
14287 ResponseEnvelope {
14288 request_id: request.request_id,
14289 result: Err(failure(
14290 NodeFailureCode::UnsupportedCapability,
14291 "child environment profiles were not negotiated",
14292 )),
14293 }
14294 } else if requires_session_bundle && !include_session_bundles {
14295 ResponseEnvelope {
14296 request_id: request.request_id,
14297 result: Err(failure(
14298 NodeFailureCode::UnsupportedCapability,
14299 "session bundles were not negotiated",
14300 )),
14301 }
14302 } else if requires_history_context_pack && !include_history_context_packs {
14303 ResponseEnvelope {
14304 request_id: request.request_id,
14305 result: Err(failure(
14306 NodeFailureCode::UnsupportedCapability,
14307 "history context packs were not negotiated",
14308 )),
14309 }
14310 } else if request_uses_unnegotiated_capability(
14311 &request.request,
14312 &selected_capabilities,
14313 )
14314 {
14315 ResponseEnvelope {
14316 request_id: request.request_id,
14317 result: Err(failure(
14318 NodeFailureCode::UnsupportedCapability,
14319 "request capability was not negotiated",
14320 )),
14321 }
14322 } else {
14323 process_request(&shared, connection_id, hello.role, request).await
14324 };
14325 #[cfg(feature = "fixture")]
14326 if probe_spawn_managed_worktree_v2 {
14327 shared
14328 .fixture_spawn_managed_worktree_v2_failure_probe
14329 .record(reply.result.as_ref().err().map(|failure| failure.code));
14330 }
14331 if !include_provider_runtime_status {
14332 clear_response_provider_runtime_status(&mut reply);
14333 }
14334 if !include_managed_worktrees {
14335 project_response_without_managed_worktrees(&mut reply);
14336 }
14337 if !include_open_provider_ids {
14338 project_response_legacy_provider_ids(&shared, &mut reply);
14339 }
14340 if !include_child_environment_profiles {
14341 project_response_without_child_environment_profile(&mut reply);
14342 }
14343 if !include_session_bundles {
14344 project_response_without_session_bundle(&mut reply);
14345 }
14346 project_response_history_for_wire(
14347 &mut reply,
14348 include_history_context_packs,
14349 );
14350 if !include_history_context_packs {
14351 project_response_without_context_pack(&mut reply);
14352 }
14353 if !include_agent_progress {
14354 project_response_without_agent_progress(&mut reply);
14355 }
14356 if !include_session_task_correlation {
14357 project_response_without_session_task_binding(&mut reply);
14358 }
14359 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14360 }
14361 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14362 event = event_rx.recv() => {
14363 match event {
14364 Ok(event) => queue_connection_event(
14365 &mut pending_events,
14366 event,
14367 discard_events_through,
14368 ),
14369 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14370 Err(broadcast::error::RecvError::Closed) => break,
14371 }
14372 }
14373 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14374 match events {
14375 Ok(events) => queue_connection_event_batch(
14376 &mut pending_events,
14377 &events,
14378 discard_events_through,
14379 ),
14380 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14381 Err(broadcast::error::RecvError::Closed) => break,
14382 }
14383 }
14384 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14385 match events {
14386 Ok(events) => queue_connection_event_batch(
14387 &mut pending_events,
14388 &events,
14389 discard_events_through,
14390 ),
14391 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14392 Err(broadcast::error::RecvError::Closed) => break,
14393 }
14394 }
14395 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14396 match events {
14397 Ok(events) => queue_connection_event_batch(
14398 &mut pending_events,
14399 &events,
14400 discard_events_through,
14401 ),
14402 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14403 Err(broadcast::error::RecvError::Closed) => break,
14404 }
14405 }
14406 _ = shared.shutdown_notify.notified() => break,
14407 }
14408 }
14409 Ok(())
14410}
14411
14412async fn receive_terminal_event_batch(
14413 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14414) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14415 match receiver {
14416 Some(receiver) => receiver.recv().await,
14417 None => std::future::pending().await,
14418 }
14419}
14420
14421fn terminal_event_subscription(
14422 shared: &NodeShared,
14423 enabled: bool,
14424) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14425 enabled.then(|| shared.terminal_event_tx.subscribe())
14426}
14427
14428fn agent_stream_event_subscription(
14432 shared: &NodeShared,
14433 enabled: bool,
14434) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14435 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14436}
14437
14438fn harness_mcp_proxy_event_subscription(
14443 shared: &NodeShared,
14444 enabled: bool,
14445) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14446 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14447}
14448
14449fn queue_connection_event(
14450 pending: &mut Vec<NodeEventEnvelope>,
14451 event: NodeEventEnvelope,
14452 discard_through: u64,
14453) {
14454 if event.sequence <= discard_through
14455 && !event.event.requires_harness_mcp_proxy_capability()
14456 {
14457 return;
14458 }
14459 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14460 if let Some(index) = pending.iter().position(|current| {
14461 matches!(
14462 ¤t.event,
14463 NodeEvent::TerminalFrame {
14464 address: current_address,
14465 ..
14466 } if current_address == address
14467 )
14468 }) {
14469 pending.remove(index);
14470 }
14471 }
14472 pending.push(event);
14473}
14474
14475fn project_event_without_harness_mcp_proxy(
14476 envelope: NodeEventEnvelope,
14477) -> Option<NodeEventEnvelope> {
14478 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14479}
14480
14481fn queue_connection_event_batch(
14482 pending: &mut Vec<NodeEventEnvelope>,
14483 events: &[NodeEventEnvelope],
14484 discard_through: u64,
14485) {
14486 for event in events {
14487 queue_connection_event(pending, event.clone(), discard_through);
14488 }
14489}
14490
14491fn apply_connection_resync_watermark(
14492 pending: &mut Vec<NodeEventEnvelope>,
14493 discard_through: &mut u64,
14494 marker_sequence: u64,
14495) {
14496 *discard_through = (*discard_through).max(marker_sequence);
14497 pending.retain(|event| event.sequence > *discard_through);
14498}
14499
14500fn connection_event_burst_len(pending_len: usize) -> usize {
14501 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14502}
14503
14504fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14505 let history = shared
14506 .history
14507 .lock()
14508 .unwrap_or_else(|poisoned| poisoned.into_inner());
14509 let sequence = history.last_sequence;
14510 let oldest_available_sequence = history.replay_floor_sequence;
14511 NodeEventEnvelope {
14512 sequence,
14513 event: NodeEvent::ResyncRequired {
14514 oldest_available_sequence,
14515 },
14516 }
14517}
14518
14519fn node_compatibility_support(
14520 shared: &NodeShared,
14521) -> Result<NodeCompatibilitySupport, NodeServerError> {
14522 let mut support = node_compatibility_support_for_manifest(
14523 &shared.provider_contracts,
14524 &shared.provider_adapter_contracts,
14525 )?;
14526 if shared
14527 .delivery_store
14528 .lock()
14529 .unwrap_or_else(|poisoned| poisoned.into_inner())
14530 .is_some()
14531 {
14532 support.capabilities.push(
14533 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14534 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14535 );
14536 }
14537 if shared.harness_mcp_registry.is_some() {
14538 support.capabilities.push(
14539 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14540 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14541 );
14542 }
14543 Ok(support)
14544}
14545
14546fn node_compatibility_support_for_manifest(
14547 provider_contracts: &[ProviderContractSupport],
14548 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14549) -> Result<NodeCompatibilitySupport, NodeServerError> {
14550 Ok(NodeCompatibilitySupport {
14551 build_stamp: BUILD_STAMP.to_owned(),
14552 capabilities: baseline_capabilities()?,
14553 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14554 path_semantics: platform::path_semantics(),
14555 local_transport: platform::local_transport(),
14556 state_schema: StateSchemaSupport {
14557 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14558 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14559 },
14560 provider_contracts: provider_contracts.to_vec(),
14561 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14562 })
14563}
14564
14565fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14566 let capabilities = [
14567 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14568 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14569 NODE_REPOSITORY_PATH_CAPABILITY,
14570 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14571 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14572 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14573 NODE_GIT_READ_CAPABILITY,
14574 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14575 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14576 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14577 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14578 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14579 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14580 NODE_ACP_CONTROL_CAPABILITY,
14581 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14582 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14583 NODE_WORKTREE_SELECTION_CAPABILITY,
14584 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14585 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14586 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14587 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14588 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14589 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14590 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14591 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14592 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14593 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14594 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14595 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14596 ]
14597 .into_iter()
14598 .map(|capability| {
14599 CapabilityId::new(capability)
14600 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14601 })
14602 .collect::<Result<Vec<_>, _>>()?;
14603 #[cfg(windows)]
14604 let capabilities = {
14605 let mut capabilities = capabilities;
14606 capabilities.push(
14607 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14608 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14609 );
14610 capabilities
14611 };
14612 Ok(capabilities)
14613}
14614
14615fn request_uses_unnegotiated_capability(
14616 request: &NodeRequest,
14617 selected_capabilities: &[CapabilityId],
14618) -> bool {
14619 let selected = |required: &str| {
14620 selected_capabilities
14621 .iter()
14622 .any(|capability| capability.as_str() == required)
14623 };
14624 request
14625 .required_capability()
14626 .is_some_and(|required| !selected(required))
14627 || (request.requires_worktree_selection_capability()
14628 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14629 || (request.requires_spawn_spec_defaults_overrides_capability()
14630 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14631 || (request.requires_spawn_profile_revision_capability()
14632 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14633 || (request.requires_child_environment_profile_capability()
14634 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14635 || (request.requires_session_bundle_materialization_capability()
14636 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14637 || (request.requires_history_context_pack_capability()
14638 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14639}
14640
14641fn snapshot_for_wire(
14642 shared: &NodeShared,
14643 include_provider_runtime_status: bool,
14644 include_open_provider_ids: bool,
14645 include_spawn_profiles: bool,
14646 include_managed_worktrees: bool,
14647 include_child_environment_profiles: bool,
14648 include_session_bundles: bool,
14649 include_history_context_packs: bool,
14650 include_agent_progress: bool,
14651 include_session_task_correlation: bool,
14652) -> NodeSnapshot {
14653 let mut snapshot = shared.snapshot();
14654 if !include_provider_runtime_status {
14655 snapshot.provider_runtime_statuses.clear();
14656 }
14657 if !include_open_provider_ids {
14658 project_snapshot_legacy_provider_ids(&mut snapshot);
14659 }
14660 if !include_managed_worktrees {
14661 snapshot.managed_worktrees.clear();
14662 for workspace in &mut snapshot.workspaces {
14663 workspace.managed_worktree_profiles = None;
14664 }
14665 }
14666 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14667 if !include_spawn_profiles {
14668 inventory.spawn_profiles = None;
14669 }
14670 if !include_session_bundles {
14671 inventory.bundles = None;
14672 }
14673 if inventory.spawn_profiles.is_none()
14674 && inventory.bundles.is_none()
14675 && inventory.network_allowlists.is_none()
14676 {
14677 snapshot.launch_inventory = None;
14678 }
14679 }
14680 if !include_child_environment_profiles {
14681 clear_snapshot_child_environment_profiles(&mut snapshot);
14682 }
14683 if !include_session_bundles {
14684 clear_snapshot_session_bundles(&mut snapshot);
14685 }
14686 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14687 if !include_history_context_packs {
14688 clear_snapshot_context_packs(&mut snapshot);
14689 }
14690 if !include_agent_progress {
14691 snapshot.agent_progress.clear();
14692 }
14693 if !include_session_task_correlation {
14694 clear_snapshot_session_task_bindings(&mut snapshot);
14695 }
14696 snapshot
14697}
14698
14699fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14700 for record in &mut snapshot.session_records {
14701 record.task_binding = None;
14702 }
14703}
14704
14705fn project_event_without_session_task_binding(
14706 mut envelope: NodeEventEnvelope,
14707) -> NodeEventEnvelope {
14708 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14709 record.task_binding = None;
14710 }
14711 envelope
14712}
14713
14714fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14715 let Ok(response) = reply.result.as_mut() else { return };
14716 match response {
14717 NodeResponse::Snapshot { snapshot, .. } => {
14718 clear_snapshot_session_task_bindings(snapshot);
14719 }
14720 NodeResponse::Resync { snapshot, events, .. } => {
14721 clear_snapshot_session_task_bindings(snapshot);
14722 for event in events {
14723 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14724 record.task_binding = None;
14725 }
14726 }
14727 }
14728 NodeResponse::SessionRecordUpdated { record }
14729 | NodeResponse::ProviderSessionIndexed { record }
14730 | NodeResponse::NativeSessionIndexed { record, .. }
14731 | NodeResponse::SessionRecordResumed { record, .. } => {
14732 record.task_binding = None;
14733 }
14734 _ => {}
14735 }
14736}
14737
14738fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14739 let Ok(response) = reply.result.as_mut() else { return };
14740 match response {
14741 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14742 snapshot.agent_progress.clear();
14743 }
14744 _ => {}
14745 }
14746}
14747
14748fn project_event_without_managed_worktrees(
14749 mut envelope: NodeEventEnvelope,
14750) -> Option<NodeEventEnvelope> {
14751 if matches!(
14752 envelope.event,
14753 NodeEvent::ManagedWorktreeUpserted { .. }
14754 | NodeEvent::ManagedWorktreeRemoved { .. }
14755 ) {
14756 return None;
14757 }
14758 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14759 workspace.managed_worktree_profiles = None;
14760 }
14761 Some(envelope)
14762}
14763
14764fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14765 if matches!(
14766 reply.result,
14767 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14768 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14769 ) {
14770 reply.result = Err(failure(
14771 NodeFailureCode::UnsupportedCapability,
14772 "managed worktree capability was not negotiated",
14773 ));
14774 return;
14775 }
14776 let Ok(response) = reply.result.as_mut() else { return };
14777 match response {
14778 NodeResponse::Snapshot { snapshot, .. } => {
14779 snapshot.managed_worktrees.clear();
14780 for workspace in &mut snapshot.workspaces {
14781 workspace.managed_worktree_profiles = None;
14782 }
14783 }
14784 NodeResponse::Resync { snapshot, events, .. } => {
14785 snapshot.managed_worktrees.clear();
14786 for workspace in &mut snapshot.workspaces {
14787 workspace.managed_worktree_profiles = None;
14788 }
14789 *events = std::mem::take(events)
14790 .into_iter()
14791 .filter_map(project_event_without_managed_worktrees)
14792 .collect();
14793 }
14794 NodeResponse::WorkspaceRegistered { workspace }
14795 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14796 | NodeResponse::WorktreeCreated { workspace, .. } => {
14797 workspace.managed_worktree_profiles = None;
14798 }
14799 NodeResponse::WorkspaceInspected { inspection } => {
14800 inspection.git.managed_worktree = None;
14801 }
14802 _ => {}
14803 }
14804}
14805
14806fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14807 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14808 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14809 });
14810 if includes_open_provider_ids {
14811 return;
14812 }
14813 compatibility
14814 .provider_contracts
14815 .retain(|contract| provider_id_is_legacy(&contract.provider));
14816 compatibility
14817 .provider_adapter_contracts
14818 .retain(|contract| provider_id_is_legacy(&contract.provider));
14819}
14820
14821fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14822 if let Some(spec) = match request {
14823 NodeRequest::SpawnSpec { spec }
14824 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14825 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14826 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14827 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14828 _ => None,
14829 } {
14830 return shared
14831 .resolve_spawn_spec(spec)
14832 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14833 }
14834 request_requires_open_provider_ids_with(
14835 request,
14836 |session| shared.validate_address(session).ok(),
14837 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14838 )
14839}
14840
14841fn request_requires_child_environment_profile(
14842 shared: &NodeShared,
14843 request: &NodeRequest,
14844) -> bool {
14845 if let Some(spec) = match request {
14846 NodeRequest::SpawnSpec { spec }
14847 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14848 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14849 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14850 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14851 _ => None,
14852 } {
14853 return shared
14854 .resolve_spawn_spec(spec)
14855 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14856 }
14857 match request {
14858 NodeRequest::Resume { session, .. }
14859 | NodeRequest::Prompt { session, .. }
14860 | NodeRequest::Paste { session, .. }
14861 | NodeRequest::Input { session, .. }
14862 | NodeRequest::TerminalBytes { session, .. }
14863 | NodeRequest::TerminalControl { session, .. }
14864 | NodeRequest::Resize { session, .. }
14865 | NodeRequest::Interrupt { session }
14866 | NodeRequest::Stop { session, .. }
14867 | NodeRequest::Remove { session }
14868 | NodeRequest::ResolveInteraction { session, .. }
14869 | NodeRequest::SetSessionMode { session, .. }
14870 | NodeRequest::SetSessionConfigOption { session, .. }
14871 | NodeRequest::SetSessionModel { session, .. }
14872 | NodeRequest::DiscoverHistory { session, .. }
14873 | NodeRequest::LoadHistory { session, .. }
14874 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14875 | NodeRequest::ExportContextPack { session } => shared
14876 .session_bindings
14877 .lock()
14878 .unwrap_or_else(|poisoned| poisoned.into_inner())
14879 .get(&session.session.instance_id)
14880 .filter(|binding| {
14881 binding.workspace_id == session.workspace_id
14882 && binding.generation == session.session.generation
14883 })
14884 .map_or(true, |binding| binding.environment_profile.is_some()),
14885 NodeRequest::RenameSessionRecord { record_id, .. }
14886 | NodeRequest::SetSessionTask { record_id, .. }
14887 | NodeRequest::ResumeSessionRecord { record_id, .. }
14888 | NodeRequest::ForgetSessionRecord { record_id } => shared
14889 .record(record_id)
14890 .map_or(true, |record| record.environment_profile.is_some()),
14891 NodeRequest::ArmHarnessMcpReservation { .. }
14892 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14893 | NodeRequest::ActivateHarnessMcpReservation { .. }
14894 | NodeRequest::AbortHarnessMcpReservation { .. }
14895 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14896 | NodeRequest::RejectHarnessMcpCall { .. }
14897 | NodeRequest::Snapshot
14898 | NodeRequest::Resync { .. }
14899 | NodeRequest::BeginDeliveryStage { .. }
14900 | NodeRequest::PutDeliveryBlobChunk { .. }
14901 | NodeRequest::CommitDeliveryStage { .. }
14902 | NodeRequest::AbortDeliveryStage { .. }
14903 | NodeRequest::BrowseHostDirectories { .. }
14904 | NodeRequest::InspectWorkspace { .. }
14905 | NodeRequest::ReadWorkspaceFile { .. }
14906 | NodeRequest::WriteWorkspaceFile { .. }
14907 | NodeRequest::CreateWorkspaceFile { .. }
14908 | NodeRequest::CreateWorkspaceDirectory { .. }
14909 | NodeRequest::ReadGitHistory { .. }
14910 | NodeRequest::ReadGitDiff { .. }
14911 | NodeRequest::AcquireController { .. }
14912 | NodeRequest::ReleaseController
14913 | NodeRequest::Spawn { .. }
14914 | NodeRequest::SpawnWithOptions { .. }
14915 | NodeRequest::SpawnSpec { .. }
14916 | NodeRequest::RegisterWorkspace { .. }
14917 | NodeRequest::CreateStandaloneWorkspace { .. }
14918 | NodeRequest::UnregisterWorkspace { .. }
14919 | NodeRequest::CreateWorktree { .. }
14920 | NodeRequest::RemoveWorktree { .. }
14921 | NodeRequest::SpawnManagedWorktree { .. }
14922 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14923 | NodeRequest::CleanupManagedWorktree { .. }
14924 | NodeRequest::IndexProviderSession { .. }
14925 | NodeRequest::IndexNativeSession { .. }
14926 | NodeRequest::CatalogNativeSessions { .. }
14927 | NodeRequest::PageNativeSessions { .. }
14928 | NodeRequest::PreviewNativeSession { .. }
14929 | NodeRequest::PreviewSessionRecord { .. }
14930 | NodeRequest::ForgetContextPack { .. }
14931 | NodeRequest::ResolveDurableContextPack { .. }
14932 | NodeRequest::ReadContextPack { .. }
14933 | NodeRequest::Shutdown => false,
14934 }
14935}
14936
14937fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14938 if let Some(spec) = match request {
14939 NodeRequest::SpawnSpec { spec }
14940 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14941 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14942 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14943 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14944 _ => None,
14945 } {
14946 return shared
14947 .resolve_spawn_spec(spec)
14948 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14949 }
14950 match request {
14951 NodeRequest::Resume { session, .. }
14952 | NodeRequest::Prompt { session, .. }
14953 | NodeRequest::Paste { session, .. }
14954 | NodeRequest::Input { session, .. }
14955 | NodeRequest::TerminalBytes { session, .. }
14956 | NodeRequest::TerminalControl { session, .. }
14957 | NodeRequest::Resize { session, .. }
14958 | NodeRequest::Interrupt { session }
14959 | NodeRequest::Stop { session, .. }
14960 | NodeRequest::Remove { session }
14961 | NodeRequest::ResolveInteraction { session, .. }
14962 | NodeRequest::SetSessionMode { session, .. }
14963 | NodeRequest::SetSessionConfigOption { session, .. }
14964 | NodeRequest::SetSessionModel { session, .. }
14965 | NodeRequest::DiscoverHistory { session, .. }
14966 | NodeRequest::LoadHistory { session, .. }
14967 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14968 | NodeRequest::ExportContextPack { session } => shared
14969 .session_bindings
14970 .lock()
14971 .unwrap_or_else(|poisoned| poisoned.into_inner())
14972 .get(&session.session.instance_id)
14973 .filter(|binding| {
14974 binding.workspace_id == session.workspace_id
14975 && binding.generation == session.session.generation
14976 })
14977 .map_or(true, |binding| binding.bundle.is_some()),
14978 NodeRequest::RenameSessionRecord { record_id, .. }
14979 | NodeRequest::SetSessionTask { record_id, .. }
14980 | NodeRequest::ResumeSessionRecord { record_id, .. }
14981 | NodeRequest::ForgetSessionRecord { record_id } => shared
14982 .record(record_id)
14983 .map_or(true, |record| record.bundle.is_some()),
14984 NodeRequest::ArmHarnessMcpReservation { .. }
14985 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14986 | NodeRequest::ActivateHarnessMcpReservation { .. }
14987 | NodeRequest::AbortHarnessMcpReservation { .. }
14988 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14989 | NodeRequest::RejectHarnessMcpCall { .. }
14990 | NodeRequest::Snapshot
14991 | NodeRequest::Resync { .. }
14992 | NodeRequest::BeginDeliveryStage { .. }
14993 | NodeRequest::PutDeliveryBlobChunk { .. }
14994 | NodeRequest::CommitDeliveryStage { .. }
14995 | NodeRequest::AbortDeliveryStage { .. }
14996 | NodeRequest::BrowseHostDirectories { .. }
14997 | NodeRequest::InspectWorkspace { .. }
14998 | NodeRequest::ReadWorkspaceFile { .. }
14999 | NodeRequest::WriteWorkspaceFile { .. }
15000 | NodeRequest::CreateWorkspaceFile { .. }
15001 | NodeRequest::CreateWorkspaceDirectory { .. }
15002 | NodeRequest::ReadGitHistory { .. }
15003 | NodeRequest::ReadGitDiff { .. }
15004 | NodeRequest::AcquireController { .. }
15005 | NodeRequest::ReleaseController
15006 | NodeRequest::Spawn { .. }
15007 | NodeRequest::SpawnWithOptions { .. }
15008 | NodeRequest::SpawnSpec { .. }
15009 | NodeRequest::RegisterWorkspace { .. }
15010 | NodeRequest::CreateStandaloneWorkspace { .. }
15011 | NodeRequest::UnregisterWorkspace { .. }
15012 | NodeRequest::CreateWorktree { .. }
15013 | NodeRequest::RemoveWorktree { .. }
15014 | NodeRequest::SpawnManagedWorktree { .. }
15015 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15016 | NodeRequest::CleanupManagedWorktree { .. }
15017 | NodeRequest::IndexProviderSession { .. }
15018 | NodeRequest::IndexNativeSession { .. }
15019 | NodeRequest::CatalogNativeSessions { .. }
15020 | NodeRequest::PageNativeSessions { .. }
15021 | NodeRequest::PreviewNativeSession { .. }
15022 | NodeRequest::PreviewSessionRecord { .. }
15023 | NodeRequest::ForgetContextPack { .. }
15024 | NodeRequest::ResolveDurableContextPack { .. }
15025 | NodeRequest::ReadContextPack { .. }
15026 | NodeRequest::Shutdown => false,
15027 }
15028}
15029
15030fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15031 if request.requires_history_context_pack_capability() {
15032 return true;
15033 }
15034 if let Some(spec) = match request {
15035 NodeRequest::SpawnSpec { spec }
15036 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15037 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15038 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15039 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15040 _ => None,
15041 } {
15042 return shared
15043 .resolve_spawn_spec(spec)
15044 .map_or(true, |resolved| resolved.context_id.is_some());
15045 }
15046 match request {
15047 NodeRequest::Resume { session, .. }
15048 | NodeRequest::Prompt { session, .. }
15049 | NodeRequest::Paste { session, .. }
15050 | NodeRequest::Input { session, .. }
15051 | NodeRequest::TerminalBytes { session, .. }
15052 | NodeRequest::TerminalControl { session, .. }
15053 | NodeRequest::Resize { session, .. }
15054 | NodeRequest::Interrupt { session }
15055 | NodeRequest::Stop { session, .. }
15056 | NodeRequest::Remove { session }
15057 | NodeRequest::ResolveInteraction { session, .. }
15058 | NodeRequest::SetSessionMode { session, .. }
15059 | NodeRequest::SetSessionConfigOption { session, .. }
15060 | NodeRequest::SetSessionModel { session, .. } => shared
15061 .session_bindings
15062 .lock()
15063 .unwrap_or_else(|poisoned| poisoned.into_inner())
15064 .get(&session.session.instance_id)
15065 .filter(|binding| {
15066 binding.workspace_id == session.workspace_id
15067 && binding.generation == session.session.generation
15068 })
15069 .map_or(true, |binding| binding.context.is_some()),
15070 NodeRequest::RenameSessionRecord { record_id, .. }
15071 | NodeRequest::SetSessionTask { record_id, .. }
15072 | NodeRequest::ResumeSessionRecord { record_id, .. }
15073 | NodeRequest::ForgetSessionRecord { record_id } => shared
15074 .record(record_id)
15075 .map_or(true, |record| record.context.is_some()),
15076 NodeRequest::ArmHarnessMcpReservation { .. }
15077 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15078 | NodeRequest::ActivateHarnessMcpReservation { .. }
15079 | NodeRequest::AbortHarnessMcpReservation { .. }
15080 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15081 | NodeRequest::RejectHarnessMcpCall { .. }
15082 | NodeRequest::Snapshot
15083 | NodeRequest::Resync { .. }
15084 | NodeRequest::BeginDeliveryStage { .. }
15085 | NodeRequest::PutDeliveryBlobChunk { .. }
15086 | NodeRequest::CommitDeliveryStage { .. }
15087 | NodeRequest::AbortDeliveryStage { .. }
15088 | NodeRequest::BrowseHostDirectories { .. }
15089 | NodeRequest::InspectWorkspace { .. }
15090 | NodeRequest::ReadWorkspaceFile { .. }
15091 | NodeRequest::WriteWorkspaceFile { .. }
15092 | NodeRequest::CreateWorkspaceFile { .. }
15093 | NodeRequest::CreateWorkspaceDirectory { .. }
15094 | NodeRequest::ReadGitHistory { .. }
15095 | NodeRequest::ReadGitDiff { .. }
15096 | NodeRequest::AcquireController { .. }
15097 | NodeRequest::ReleaseController
15098 | NodeRequest::Spawn { .. }
15099 | NodeRequest::SpawnWithOptions { .. }
15100 | NodeRequest::SpawnSpec { .. }
15101 | NodeRequest::RegisterWorkspace { .. }
15102 | NodeRequest::CreateStandaloneWorkspace { .. }
15103 | NodeRequest::UnregisterWorkspace { .. }
15104 | NodeRequest::CreateWorktree { .. }
15105 | NodeRequest::RemoveWorktree { .. }
15106 | NodeRequest::SpawnManagedWorktree { .. }
15107 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15108 | NodeRequest::CleanupManagedWorktree { .. }
15109 | NodeRequest::IndexProviderSession { .. }
15110 | NodeRequest::IndexNativeSession { .. }
15111 | NodeRequest::CatalogNativeSessions { .. }
15112 | NodeRequest::PageNativeSessions { .. }
15113 | NodeRequest::PreviewNativeSession { .. }
15114 | NodeRequest::PreviewSessionRecord { .. }
15115 | NodeRequest::DiscoverHistory { .. }
15116 | NodeRequest::LoadHistory { .. }
15117 | NodeRequest::ExportContextPackForSessionRecord { .. }
15118 | NodeRequest::ExportContextPack { .. }
15119 | NodeRequest::ForgetContextPack { .. }
15120 | NodeRequest::ResolveDurableContextPack { .. }
15121 | NodeRequest::ReadContextPack { .. }
15122 | NodeRequest::Shutdown => false,
15123 }
15124}
15125
15126fn request_requires_open_provider_ids_with(
15127 request: &NodeRequest,
15128 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15129 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15130) -> bool {
15131 match request {
15132 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15133 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15134 NodeRequest::CatalogNativeSessions { route, .. }
15135 | NodeRequest::PageNativeSessions { route, .. } => {
15136 !provider_id_is_legacy(&route.provider)
15137 }
15138 NodeRequest::PreviewNativeSession { selection, .. }
15139 | NodeRequest::IndexNativeSession { selection, .. } => {
15140 !provider_id_is_legacy(&selection.route.provider)
15141 }
15142 NodeRequest::SpawnSpec { .. }
15143 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15144 NodeRequest::SpawnManagedWorktree { .. }
15145 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15146 NodeRequest::Resume { session, .. }
15147 | NodeRequest::Prompt { session, .. }
15148 | NodeRequest::Paste { session, .. }
15149 | NodeRequest::Input { session, .. }
15150 | NodeRequest::TerminalBytes { session, .. }
15151 | NodeRequest::TerminalControl { session, .. }
15152 | NodeRequest::Resize { session, .. }
15153 | NodeRequest::Interrupt { session }
15154 | NodeRequest::Stop { session, .. }
15155 | NodeRequest::Remove { session }
15156 | NodeRequest::ResolveInteraction { session, .. }
15157 | NodeRequest::SetSessionMode { session, .. }
15158 | NodeRequest::SetSessionConfigOption { session, .. }
15159 | NodeRequest::SetSessionModel { session, .. }
15160 | NodeRequest::DiscoverHistory { session, .. }
15161 | NodeRequest::LoadHistory { session, .. }
15162 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15163 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15164 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15165 NodeRequest::RenameSessionRecord { record_id, .. }
15166 | NodeRequest::SetSessionTask { record_id, .. }
15167 | NodeRequest::ResumeSessionRecord { record_id, .. }
15168 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15169 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15170 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15171 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15172 NodeRequest::ArmHarnessMcpReservation { .. }
15173 | NodeRequest::ActivateHarnessMcpReservation { .. }
15174 | NodeRequest::AbortHarnessMcpReservation { .. }
15175 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15176 | NodeRequest::RejectHarnessMcpCall { .. }
15177 | NodeRequest::Snapshot
15178 | NodeRequest::Resync { .. }
15179 | NodeRequest::BeginDeliveryStage { .. }
15180 | NodeRequest::PutDeliveryBlobChunk { .. }
15181 | NodeRequest::CommitDeliveryStage { .. }
15182 | NodeRequest::AbortDeliveryStage { .. }
15183 | NodeRequest::BrowseHostDirectories { .. }
15184 | NodeRequest::InspectWorkspace { .. }
15185 | NodeRequest::ReadWorkspaceFile { .. }
15186 | NodeRequest::WriteWorkspaceFile { .. }
15187 | NodeRequest::CreateWorkspaceFile { .. }
15188 | NodeRequest::CreateWorkspaceDirectory { .. }
15189 | NodeRequest::ReadGitHistory { .. }
15190 | NodeRequest::ReadGitDiff { .. }
15191 | NodeRequest::AcquireController { .. }
15192 | NodeRequest::ReleaseController
15193 | NodeRequest::RegisterWorkspace { .. }
15194 | NodeRequest::CreateStandaloneWorkspace { .. }
15195 | NodeRequest::UnregisterWorkspace { .. }
15196 | NodeRequest::CreateWorktree { .. }
15197 | NodeRequest::RemoveWorktree { .. }
15198 | NodeRequest::CleanupManagedWorktree { .. }
15199 | NodeRequest::Shutdown => false,
15200 NodeRequest::ForgetContextPack { .. } => true,
15201 NodeRequest::ResolveDurableContextPack { .. }
15207 | NodeRequest::ReadContextPack { .. } => true,
15208 }
15209}
15210
15211fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15212 snapshot.enabled_providers.retain(provider_id_is_legacy);
15213 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15214 snapshot
15215 .provider_runtime_statuses
15216 .as_slice()
15217 .iter()
15218 .filter(|status| provider_id_is_legacy(status.provider()))
15219 .cloned(),
15220 )
15221 .expect("a filtered provider runtime projection remains valid");
15222 snapshot
15223 .session_records
15224 .retain(|record| provider_id_is_legacy(&record.provider));
15225 for workspace in &mut snapshot.workspaces {
15226 workspace
15227 .sessions
15228 .retain(|session| provider_id_is_legacy(&session.agent_id));
15229 }
15230}
15231
15232fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15233 for record in &mut snapshot.session_records {
15234 record.environment_profile = None;
15235 }
15236 if let Some(profiles) = snapshot
15237 .launch_inventory
15238 .as_mut()
15239 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15240 {
15241 for profile in profiles {
15242 profile.environment_profile = None;
15243 }
15244 }
15245}
15246
15247fn project_event_without_child_environment_profile(
15248 mut envelope: NodeEventEnvelope,
15249) -> NodeEventEnvelope {
15250 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15251 record.environment_profile = None;
15252 }
15253 envelope
15254}
15255
15256fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15257 let contains_environment_profile = match reply.result.as_ref() {
15258 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15259 receipt.environment_profile.is_some()
15260 }
15261 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15262 receipt.spawn.environment_profile.is_some()
15263 }
15264 _ => false,
15265 };
15266 if contains_environment_profile {
15267 reply.result = Err(failure(
15268 NodeFailureCode::UnsupportedCapability,
15269 "child environment profiles were not negotiated",
15270 ));
15271 return;
15272 }
15273 let Ok(response) = reply.result.as_mut() else {
15274 return;
15275 };
15276 match response {
15277 NodeResponse::Snapshot { snapshot, .. } => {
15278 clear_snapshot_child_environment_profiles(snapshot);
15279 }
15280 NodeResponse::Resync { snapshot, events, .. } => {
15281 clear_snapshot_child_environment_profiles(snapshot);
15282 for event in events {
15283 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15284 record.environment_profile = None;
15285 }
15286 }
15287 }
15288 NodeResponse::SessionRecordUpdated { record }
15289 | NodeResponse::ProviderSessionIndexed { record }
15290 | NodeResponse::NativeSessionIndexed { record, .. }
15291 | NodeResponse::SessionRecordResumed { record, .. } => {
15292 record.environment_profile = None;
15293 }
15294 NodeResponse::Armed { .. }
15295 | NodeResponse::Spawned { .. }
15296 | NodeResponse::Activated { .. }
15297 | NodeResponse::Aborted { .. }
15298 | NodeResponse::ReplyChunkAccepted { .. }
15299 | NodeResponse::CallRejected { .. }
15300 | NodeResponse::DeliveryStageBegun { .. }
15301 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15302 | NodeResponse::DeliveryCommitted { .. }
15303 | NodeResponse::DeliveryStageAborted { .. }
15304 | NodeResponse::WorkspaceInspected { .. }
15305 | NodeResponse::HostDirectoriesBrowsed { .. }
15306 | NodeResponse::WorkspaceFileRead { .. }
15307 | NodeResponse::WorkspaceFileWritten { .. }
15308 | NodeResponse::WorkspaceFileCreated { .. }
15309 | NodeResponse::WorkspaceDirectoryCreated { .. }
15310 | NodeResponse::GitHistoryRead { .. }
15311 | NodeResponse::GitDiffRead { .. }
15312 | NodeResponse::Controller { .. }
15313 | NodeResponse::SpawnAccepted { .. }
15314 | NodeResponse::SpawnSpecAccepted { .. }
15315 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15316 | NodeResponse::ManagedWorktreeCleanup { .. }
15317 | NodeResponse::NativeSessionsCataloged { .. }
15318 | NodeResponse::NativeSessionsPaged { .. }
15319 | NodeResponse::NativeSessionPreviewed { .. }
15320 | NodeResponse::SessionRecordPreviewed { .. }
15321 | NodeResponse::HistoryDiscovered { .. }
15322 | NodeResponse::HistoryLoaded { .. }
15323 | NodeResponse::ContextPackForSessionRecordExported { .. }
15324 | NodeResponse::ContextPackExported { .. }
15325 | NodeResponse::ContextPackForgotten { .. }
15326 | NodeResponse::DurableContextPackResolved { .. }
15327 | NodeResponse::ContextPackBytesRead { .. }
15328 | NodeResponse::SessionRecordForgotten { .. }
15329 | NodeResponse::WorkspaceRegistered { .. }
15330 | NodeResponse::StandaloneWorkspaceCreated { .. }
15331 | NodeResponse::WorkspaceUnregistered { .. }
15332 | NodeResponse::WorktreeCreated { .. }
15333 | NodeResponse::WorktreeRemoved { .. }
15334 | NodeResponse::Accepted
15335 | NodeResponse::ShuttingDown => {}
15336 }
15337}
15338
15339fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15340 for record in &mut snapshot.session_records {
15341 record.bundle = None;
15342 }
15343}
15344
15345fn project_event_without_session_bundle(
15346 mut envelope: NodeEventEnvelope,
15347) -> NodeEventEnvelope {
15348 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15349 record.bundle = None;
15350 }
15351 envelope
15352}
15353
15354fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15355 let contains_bundle = match reply.result.as_ref() {
15356 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15357 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15358 receipt.spawn.bundle.is_some()
15359 }
15360 _ => false,
15361 };
15362 if contains_bundle {
15363 reply.result = Err(failure(
15364 NodeFailureCode::UnsupportedCapability,
15365 "session bundles were not negotiated",
15366 ));
15367 return;
15368 }
15369 let Ok(response) = reply.result.as_mut() else {
15370 return;
15371 };
15372 match response {
15373 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15374 NodeResponse::Resync { snapshot, events, .. } => {
15375 clear_snapshot_session_bundles(snapshot);
15376 for event in events {
15377 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15378 record.bundle = None;
15379 }
15380 }
15381 }
15382 NodeResponse::SessionRecordUpdated { record }
15383 | NodeResponse::ProviderSessionIndexed { record }
15384 | NodeResponse::NativeSessionIndexed { record, .. }
15385 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15386 NodeResponse::Armed { .. }
15387 | NodeResponse::Spawned { .. }
15388 | NodeResponse::Activated { .. }
15389 | NodeResponse::Aborted { .. }
15390 | NodeResponse::ReplyChunkAccepted { .. }
15391 | NodeResponse::CallRejected { .. }
15392 | NodeResponse::DeliveryStageBegun { .. }
15393 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15394 | NodeResponse::DeliveryCommitted { .. }
15395 | NodeResponse::DeliveryStageAborted { .. }
15396 | NodeResponse::WorkspaceInspected { .. }
15397 | NodeResponse::HostDirectoriesBrowsed { .. }
15398 | NodeResponse::WorkspaceFileRead { .. }
15399 | NodeResponse::WorkspaceFileWritten { .. }
15400 | NodeResponse::WorkspaceFileCreated { .. }
15401 | NodeResponse::WorkspaceDirectoryCreated { .. }
15402 | NodeResponse::GitHistoryRead { .. }
15403 | NodeResponse::GitDiffRead { .. }
15404 | NodeResponse::Controller { .. }
15405 | NodeResponse::SpawnAccepted { .. }
15406 | NodeResponse::SpawnSpecAccepted { .. }
15407 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15408 | NodeResponse::ManagedWorktreeCleanup { .. }
15409 | NodeResponse::NativeSessionsCataloged { .. }
15410 | NodeResponse::NativeSessionsPaged { .. }
15411 | NodeResponse::NativeSessionPreviewed { .. }
15412 | NodeResponse::SessionRecordPreviewed { .. }
15413 | NodeResponse::HistoryDiscovered { .. }
15414 | NodeResponse::HistoryLoaded { .. }
15415 | NodeResponse::ContextPackForSessionRecordExported { .. }
15416 | NodeResponse::ContextPackExported { .. }
15417 | NodeResponse::ContextPackForgotten { .. }
15418 | NodeResponse::DurableContextPackResolved { .. }
15419 | NodeResponse::ContextPackBytesRead { .. }
15420 | NodeResponse::SessionRecordForgotten { .. }
15421 | NodeResponse::WorkspaceRegistered { .. }
15422 | NodeResponse::StandaloneWorkspaceCreated { .. }
15423 | NodeResponse::WorkspaceUnregistered { .. }
15424 | NodeResponse::WorktreeCreated { .. }
15425 | NodeResponse::WorktreeRemoved { .. }
15426 | NodeResponse::Accepted
15427 | NodeResponse::ShuttingDown => {}
15428 }
15429}
15430
15431fn project_snapshot_history_for_wire(
15432 snapshot: &mut NodeSnapshot,
15433 include_history_metadata: bool,
15434) {
15435 for workspace in &mut snapshot.workspaces {
15436 for session in &mut workspace.sessions {
15437 if include_history_metadata {
15438 session.history.loaded = None;
15439 } else {
15440 session.history = Default::default();
15441 }
15442 }
15443 }
15444}
15445
15446fn project_event_history_for_wire(
15447 mut envelope: NodeEventEnvelope,
15448 include_history_metadata: bool,
15449) -> NodeEventEnvelope {
15450 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15451 for session in &mut workspace.sessions {
15452 if include_history_metadata {
15453 session.history.loaded = None;
15454 } else {
15455 session.history = Default::default();
15456 }
15457 }
15458 }
15459 envelope
15460}
15461
15462fn project_response_history_for_wire(
15463 reply: &mut ResponseEnvelope,
15464 include_history_metadata: bool,
15465) {
15466 let Ok(response) = reply.result.as_mut() else {
15467 return;
15468 };
15469 match response {
15470 NodeResponse::Snapshot { snapshot, .. } => {
15471 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15472 }
15473 NodeResponse::Resync {
15474 snapshot, events, ..
15475 } => {
15476 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15477 for event in events {
15478 *event = project_event_history_for_wire(
15479 event.clone(),
15480 include_history_metadata,
15481 );
15482 }
15483 }
15484 NodeResponse::WorkspaceRegistered { workspace }
15485 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15486 | NodeResponse::WorktreeCreated { workspace, .. } => {
15487 for session in &mut workspace.sessions {
15488 if include_history_metadata {
15489 session.history.loaded = None;
15490 } else {
15491 session.history = Default::default();
15492 }
15493 }
15494 }
15495 _ => {}
15496 }
15497}
15498
15499fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15500 for record in &mut snapshot.session_records {
15501 record.context_id = None;
15502 record.context = None;
15503 record.exported_context = None;
15504 }
15505}
15506
15507fn project_event_without_context_pack(
15508 mut envelope: NodeEventEnvelope,
15509) -> NodeEventEnvelope {
15510 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15511 record.context_id = None;
15512 record.context = None;
15513 record.exported_context = None;
15514 }
15515 envelope
15516}
15517
15518fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15519 let contains_context = match reply.result.as_ref() {
15520 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15521 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15522 receipt.spawn.context.is_some()
15523 }
15524 Ok(NodeResponse::HistoryDiscovered { .. })
15525 | Ok(NodeResponse::HistoryLoaded { .. })
15526 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15527 | Ok(NodeResponse::ContextPackExported { .. })
15528 | Ok(NodeResponse::ContextPackForgotten { .. })
15529 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15530 _ => false,
15531 };
15532 if contains_context {
15533 reply.result = Err(failure(
15534 NodeFailureCode::UnsupportedCapability,
15535 "history context packs were not negotiated",
15536 ));
15537 return;
15538 }
15539 let Ok(response) = reply.result.as_mut() else {
15540 return;
15541 };
15542 match response {
15543 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15544 NodeResponse::Resync {
15545 snapshot, events, ..
15546 } => {
15547 clear_snapshot_context_packs(snapshot);
15548 for event in events {
15549 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15550 record.context_id = None;
15551 record.context = None;
15552 record.exported_context = None;
15553 }
15554 }
15555 }
15556 NodeResponse::SessionRecordUpdated { record }
15557 | NodeResponse::ProviderSessionIndexed { record }
15558 | NodeResponse::NativeSessionIndexed { record, .. }
15559 | NodeResponse::SessionRecordResumed { record, .. } => {
15560 record.context_id = None;
15561 record.context = None;
15562 record.exported_context = None;
15563 }
15564 _ => {}
15565 }
15566}
15567
15568fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15569 let contains_open_record = match reply.result.as_ref() {
15570 Ok(NodeResponse::SessionRecordUpdated { record })
15571 | Ok(NodeResponse::ProviderSessionIndexed { record })
15572 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15573 !provider_id_is_legacy(&record.provider)
15574 }
15575 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15576 !provider_id_is_legacy(&selection.route.provider)
15577 || !provider_id_is_legacy(&record.provider)
15578 }
15579 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15580 !provider_id_is_legacy(&receipt.provider)
15581 }
15582 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15583 !provider_id_is_legacy(&receipt.spawn.provider)
15584 }
15585 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15586 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15587 !provider_id_is_legacy(&route.provider)
15588 }
15589 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15590 !provider_id_is_legacy(&selection.route.provider)
15591 }
15592 _ => false,
15593 };
15594 if contains_open_record {
15595 reply.result = Err(failure(
15596 NodeFailureCode::UnsupportedCapability,
15597 "open provider IDs were not negotiated",
15598 ));
15599 return;
15600 }
15601 let Ok(response) = reply.result.as_mut() else {
15602 return;
15603 };
15604 match response {
15605 NodeResponse::Snapshot { snapshot, .. } => {
15606 project_snapshot_legacy_provider_ids(snapshot);
15607 }
15608 NodeResponse::Resync { snapshot, events, .. } => {
15609 project_snapshot_legacy_provider_ids(snapshot);
15610 *events = std::mem::take(events)
15611 .into_iter()
15612 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15613 .collect();
15614 }
15615 NodeResponse::WorkspaceRegistered { workspace }
15616 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15617 | NodeResponse::WorktreeCreated { workspace, .. } => {
15618 workspace
15619 .sessions
15620 .retain(|session| provider_id_is_legacy(&session.agent_id));
15621 }
15622 NodeResponse::Armed { .. }
15623 | NodeResponse::Spawned { .. }
15624 | NodeResponse::Activated { .. }
15625 | NodeResponse::Aborted { .. }
15626 | NodeResponse::ReplyChunkAccepted { .. }
15627 | NodeResponse::CallRejected { .. }
15628 | NodeResponse::DeliveryStageBegun { .. }
15629 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15630 | NodeResponse::DeliveryCommitted { .. }
15631 | NodeResponse::DeliveryStageAborted { .. }
15632 | NodeResponse::WorkspaceInspected { .. }
15633 | NodeResponse::HostDirectoriesBrowsed { .. }
15634 | NodeResponse::WorkspaceFileRead { .. }
15635 | NodeResponse::WorkspaceFileWritten { .. }
15636 | NodeResponse::WorkspaceFileCreated { .. }
15637 | NodeResponse::WorkspaceDirectoryCreated { .. }
15638 | NodeResponse::GitHistoryRead { .. }
15639 | NodeResponse::GitDiffRead { .. }
15640 | NodeResponse::Controller { .. }
15641 | NodeResponse::SpawnAccepted { .. }
15642 | NodeResponse::SpawnSpecAccepted { .. }
15643 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15644 | NodeResponse::ManagedWorktreeCleanup { .. }
15645 | NodeResponse::NativeSessionsCataloged { .. }
15646 | NodeResponse::NativeSessionsPaged { .. }
15647 | NodeResponse::NativeSessionPreviewed { .. }
15648 | NodeResponse::SessionRecordPreviewed { .. }
15649 | NodeResponse::HistoryDiscovered { .. }
15650 | NodeResponse::HistoryLoaded { .. }
15651 | NodeResponse::ContextPackForSessionRecordExported { .. }
15652 | NodeResponse::ContextPackExported { .. }
15653 | NodeResponse::ContextPackForgotten { .. }
15654 | NodeResponse::DurableContextPackResolved { .. }
15655 | NodeResponse::ContextPackBytesRead { .. }
15656 | NodeResponse::SessionRecordUpdated { .. }
15657 | NodeResponse::ProviderSessionIndexed { .. }
15658 | NodeResponse::NativeSessionIndexed { .. }
15659 | NodeResponse::SessionRecordResumed { .. }
15660 | NodeResponse::SessionRecordForgotten { .. }
15661 | NodeResponse::WorkspaceUnregistered { .. }
15662 | NodeResponse::WorktreeRemoved { .. }
15663 | NodeResponse::Accepted
15664 | NodeResponse::ShuttingDown => {}
15665 }
15666}
15667
15668fn project_event_legacy_provider_ids(
15669 shared: &NodeShared,
15670 mut envelope: NodeEventEnvelope,
15671) -> Option<NodeEventEnvelope> {
15672 let include = match &mut envelope.event {
15673 NodeEvent::HarnessMcpReadCall { .. } => true,
15674 NodeEvent::Control { address, .. }
15675 | NodeEvent::AgentStream { address, .. }
15676 | NodeEvent::TerminalFrame { address, .. } => shared
15677 .validate_address(address)
15678 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15679 NodeEvent::WorkspaceAdded { workspace } => {
15680 workspace
15681 .sessions
15682 .retain(|session| provider_id_is_legacy(&session.agent_id));
15683 true
15684 }
15685 NodeEvent::SessionRecordUpserted { record } => {
15686 provider_id_is_legacy(&record.provider)
15687 }
15688 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15689 NodeEvent::SessionRecordRemoved { .. } => shared
15690 .history
15691 .lock()
15692 .unwrap_or_else(|poisoned| poisoned.into_inner())
15693 .removed_record_providers
15694 .get(&envelope.sequence)
15695 .is_some_and(provider_id_is_legacy),
15696 NodeEvent::ControllerChanged { .. }
15697 | NodeEvent::ManagedWorktreeUpserted { .. }
15698 | NodeEvent::ManagedWorktreeRemoved { .. }
15699 | NodeEvent::WorkspaceRemoved { .. }
15700 | NodeEvent::ResyncRequired { .. } => true,
15701 };
15702 include.then_some(envelope)
15703}
15704
15705fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15706 let Ok(response) = reply.result.as_mut() else {
15707 return;
15708 };
15709 match response {
15710 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15711 snapshot.provider_runtime_statuses.clear();
15712 }
15713 NodeResponse::Armed { .. }
15714 | NodeResponse::Spawned { .. }
15715 | NodeResponse::Activated { .. }
15716 | NodeResponse::Aborted { .. }
15717 | NodeResponse::ReplyChunkAccepted { .. }
15718 | NodeResponse::CallRejected { .. }
15719 | NodeResponse::DeliveryStageBegun { .. }
15720 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15721 | NodeResponse::DeliveryCommitted { .. }
15722 | NodeResponse::DeliveryStageAborted { .. }
15723 | NodeResponse::WorkspaceInspected { .. }
15724 | NodeResponse::HostDirectoriesBrowsed { .. }
15725 | NodeResponse::WorkspaceFileRead { .. }
15726 | NodeResponse::WorkspaceFileWritten { .. }
15727 | NodeResponse::WorkspaceFileCreated { .. }
15728 | NodeResponse::WorkspaceDirectoryCreated { .. }
15729 | NodeResponse::GitHistoryRead { .. }
15730 | NodeResponse::GitDiffRead { .. }
15731 | NodeResponse::Controller { .. }
15732 | NodeResponse::SpawnAccepted { .. }
15733 | NodeResponse::SpawnSpecAccepted { .. }
15734 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15735 | NodeResponse::ManagedWorktreeCleanup { .. }
15736 | NodeResponse::NativeSessionsCataloged { .. }
15737 | NodeResponse::NativeSessionsPaged { .. }
15738 | NodeResponse::NativeSessionPreviewed { .. }
15739 | NodeResponse::SessionRecordPreviewed { .. }
15740 | NodeResponse::HistoryDiscovered { .. }
15741 | NodeResponse::HistoryLoaded { .. }
15742 | NodeResponse::ContextPackForSessionRecordExported { .. }
15743 | NodeResponse::ContextPackExported { .. }
15744 | NodeResponse::ContextPackForgotten { .. }
15745 | NodeResponse::DurableContextPackResolved { .. }
15746 | NodeResponse::ContextPackBytesRead { .. }
15747 | NodeResponse::SessionRecordUpdated { .. }
15748 | NodeResponse::ProviderSessionIndexed { .. }
15749 | NodeResponse::NativeSessionIndexed { .. }
15750 | NodeResponse::SessionRecordResumed { .. }
15751 | NodeResponse::SessionRecordForgotten { .. }
15752 | NodeResponse::WorkspaceRegistered { .. }
15753 | NodeResponse::StandaloneWorkspaceCreated { .. }
15754 | NodeResponse::WorkspaceUnregistered { .. }
15755 | NodeResponse::WorktreeCreated { .. }
15756 | NodeResponse::WorktreeRemoved { .. }
15757 | NodeResponse::Accepted
15758 | NodeResponse::ShuttingDown => {}
15759 }
15760}
15761
15762struct ControllerReleaseGuard {
15763 shared: Arc<NodeShared>,
15764 connection_id: u64,
15765}
15766
15767impl Drop for ControllerReleaseGuard {
15768 fn drop(&mut self) {
15769 self.shared.release_controller(self.connection_id);
15770 }
15771}
15772
15773struct AbortTaskOnDrop(AbortHandle);
15774
15775impl Drop for AbortTaskOnDrop {
15776 fn drop(&mut self) {
15777 self.0.abort();
15778 }
15779}
15780
15781fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15786 std::env::var(name)
15787 .ok()
15788 .and_then(|value| value.parse::<u64>().ok())
15789 .filter(|value| *value > 0)
15790 .unwrap_or(default)
15791 .min(max)
15792}
15793
15794fn workspace_inspection_time_budget_ms() -> u64 {
15795 env_config_bounded(
15796 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15797 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15798 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15799 )
15800}
15801
15802fn workspace_inspection_entry_cap() -> usize {
15803 env_config_bounded(
15804 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15805 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15806 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15807 ) as usize
15808}
15809
15810struct WorkspaceWalkBudget {
15816 deadline: Instant,
15817 entry_cap: usize,
15818 entries_visited: u64,
15819 time_budget_exceeded: bool,
15820 entry_cap_exceeded: bool,
15821}
15822
15823impl WorkspaceWalkBudget {
15824 fn new(deadline: Instant, entry_cap: usize) -> Self {
15825 Self {
15826 deadline,
15827 entry_cap,
15828 entries_visited: 0,
15829 time_budget_exceeded: false,
15830 entry_cap_exceeded: false,
15831 }
15832 }
15833
15834 fn is_exhausted(&self) -> bool {
15835 self.time_budget_exceeded || self.entry_cap_exceeded
15836 }
15837
15838 fn record_visit(&mut self) -> bool {
15841 self.entries_visited += 1;
15842 if self.entries_visited as usize >= self.entry_cap {
15843 self.entry_cap_exceeded = true;
15844 }
15845 if Instant::now() >= self.deadline {
15846 self.time_budget_exceeded = true;
15847 }
15848 self.is_exhausted()
15849 }
15850}
15851
15852fn collect_workspace_entries(
15853 root: &Path,
15854 deadline: Instant,
15855 entry_cap: usize,
15856) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15857 let mut entries = Vec::new();
15858 let mut truncated = false;
15859 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15860 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15861 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15865 (entries, truncated, budget)
15866}
15867
15868fn workspace_inspection_truncation(
15872 walk_budget: &WorkspaceWalkBudget,
15873 git_time_budget_exceeded: bool,
15874 elapsed: Duration,
15875) -> Option<WorkspaceInspectionTruncationV1> {
15876 if !walk_budget.time_budget_exceeded
15877 && !walk_budget.entry_cap_exceeded
15878 && !git_time_budget_exceeded
15879 {
15880 return None;
15881 }
15882 Some(WorkspaceInspectionTruncationV1 {
15883 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15884 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15885 git_time_budget_exceeded,
15886 entries_visited: walk_budget.entries_visited,
15887 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15888 })
15889}
15890
15891fn walk_workspace_directory(
15892 directory: &Path,
15893 relative_directory: Option<&RepositoryPath>,
15894 depth: usize,
15895 entries: &mut Vec<WorkspaceEntry>,
15896 truncated: &mut bool,
15897 budget: &mut WorkspaceWalkBudget,
15898) {
15899 if budget.is_exhausted() {
15900 *truncated = true;
15901 return;
15902 }
15903 let read_dir = match std::fs::read_dir(directory) {
15904 Ok(read_dir) => read_dir,
15905 Err(_) => {
15906 *truncated = true;
15907 return;
15908 }
15909 };
15910 let mut children = Vec::new();
15911 for child in read_dir {
15912 match child {
15913 Ok(child) => children.push(child),
15914 Err(_) => *truncated = true,
15915 }
15916 }
15917 children.sort_by_key(|child| child.file_name());
15918 for child in children {
15919 if budget.record_visit() {
15920 *truncated = true;
15921 return;
15922 }
15923 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15924 *truncated = true;
15925 return;
15926 }
15927 let file_type = match child.file_type() {
15928 Ok(file_type) => file_type,
15929 Err(_) => {
15930 *truncated = true;
15931 continue;
15932 }
15933 };
15934 if file_type.is_symlink() {
15935 continue;
15936 }
15937 let name = match child.file_name().into_string() {
15938 Ok(name) => name,
15939 Err(_) => {
15940 *truncated = true;
15941 continue;
15942 }
15943 };
15944 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15945 continue;
15946 }
15947 let relative_path = match relative_directory {
15948 None => windows_repository_path(name),
15949 Some(parent) => parent
15950 .as_utf8()
15951 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15952 };
15953 let Some(relative_path) = relative_path else {
15954 *truncated = true;
15955 continue;
15956 };
15957 let kind = if file_type.is_dir() {
15958 WorkspaceEntryKind::Directory
15959 } else if file_type.is_file() {
15960 WorkspaceEntryKind::File
15961 } else {
15962 continue;
15963 };
15964 entries.push(WorkspaceEntry {
15965 relative_path: relative_path.clone(),
15966 kind,
15967 });
15968 if file_type.is_dir() {
15969 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15970 walk_workspace_directory(
15971 &child.path(),
15972 Some(&relative_path),
15973 depth + 1,
15974 entries,
15975 truncated,
15976 budget,
15977 );
15978 } else {
15979 *truncated = true;
15980 }
15981 }
15982 }
15983}
15984
15985fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15986 if value.contains('\\') {
15987 return None;
15988 }
15989 RepositoryPath::utf8(value).ok()
15990}
15991
15992fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15993 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15994}
15995
15996fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16005 if name.eq_ignore_ascii_case(".git")
16006 || name.eq_ignore_ascii_case("target")
16007 || name.eq_ignore_ascii_case("node_modules")
16008 {
16009 return true;
16010 }
16011 path.join("CACHEDIR.TAG").is_file()
16012}
16013
16014fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16015 matches!(
16016 status,
16017 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16018 )
16019}
16020
16021#[derive(Clone, Debug, Eq, PartialEq)]
16022enum ContextPackRepositoryHead {
16023 NotRepository,
16024 Unborn,
16025 Commit(String),
16026}
16027
16028#[derive(Clone, Debug, Eq, PartialEq)]
16029struct ContextPackRepositoryObservation {
16030 head: ContextPackRepositoryHead,
16031 git: GitSnapshot,
16032 files: Vec<ContextPackRepositoryFileSource>,
16033}
16034
16035fn stable_context_pack_repository(
16036 first: ContextPackRepositoryObservation,
16037 second: ContextPackRepositoryObservation,
16038) -> Result<ContextPackRepository, NodeFailure> {
16039 if first != second {
16040 return Err(failure(
16041 NodeFailureCode::ContextPackMaterializationFailed,
16042 "context repository changed during bounded capture",
16043 ));
16044 }
16045 Ok(ContextPackRepository::from_sources(
16046 &second.git,
16047 second.files,
16048 ))
16049}
16050
16051fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16058 if Instant::now() < deadline {
16059 return false;
16060 }
16061 append_git_diagnostic(
16062 snapshot,
16063 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16064 );
16065 true
16066}
16067
16068async fn run_git_bounded_with_deadline(
16077 root: &str,
16078 arguments: &[&str],
16079 output_limit: usize,
16080 deadline: Instant,
16081) -> io::Result<GitCommandOutput> {
16082 let remaining = deadline.checked_duration_since(Instant::now());
16083 let timeout_ms = remaining
16084 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16085 .unwrap_or(0)
16086 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16087 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16088}
16089
16090async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16097 let mut snapshot = GitSnapshot {
16098 is_repository: false,
16099 branch: None,
16100 status: Vec::new(),
16101 recent_commits: Vec::new(),
16102 worktrees: Vec::new(),
16103 managed_worktree: None,
16104 truncated: false,
16105 diagnostic: None,
16106 };
16107 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16108 return (snapshot, true);
16109 }
16110 let repository = match run_git_bounded_with_deadline(
16111 root,
16112 &["rev-parse", "--is-inside-work-tree"],
16113 4 * 1_024,
16114 deadline,
16115 )
16116 .await
16117 {
16118 Ok(output) => output,
16119 Err(error) => {
16120 append_git_diagnostic(
16121 &mut snapshot,
16122 &format!("git inspection unavailable: {error}"),
16123 );
16124 return (snapshot, false);
16125 }
16126 };
16127 snapshot.truncated |= repository.truncated;
16128 if repository.timed_out {
16129 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16130 return (snapshot, false);
16131 }
16132 if !repository.success
16133 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16134 {
16135 let stderr = String::from_utf8_lossy(&repository.stderr);
16136 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16137 append_git_diagnostic(&mut snapshot, stderr.trim());
16138 }
16139 return (snapshot, false);
16140 }
16141 snapshot.is_repository = true;
16142
16143 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16144 return (snapshot, true);
16145 }
16146 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16147 Ok(output) => {
16148 snapshot.truncated |= output.truncated;
16149 if output.timed_out {
16150 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16151 } else if output.success {
16152 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16153 if !branch.is_empty() {
16154 snapshot.branch = Some(truncate_git_field(
16155 &branch,
16156 MAX_REPOSITORY_PATH_BYTES,
16157 &mut snapshot.truncated,
16158 ));
16159 }
16160 }
16161 }
16162 Err(error) => append_git_diagnostic(
16163 &mut snapshot,
16164 &format!("git branch query failed: {error}"),
16165 ),
16166 }
16167 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16168 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16169 snapshot.truncated |= output.truncated;
16170 if output.success && !output.timed_out {
16171 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16172 if !head.is_empty() {
16173 snapshot.branch = Some(format!("detached:{head}"));
16174 }
16175 }
16176 }
16177 }
16178
16179 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16180 return (snapshot, true);
16181 }
16182 match run_git_bounded_with_deadline(
16183 root,
16184 &[
16185 "status",
16186 "--porcelain=v1",
16187 "-z",
16188 "--renames",
16189 "--untracked-files=normal",
16190 "--",
16191 ".",
16192 ],
16193 GIT_OUTPUT_MAX_BYTES,
16194 deadline,
16195 )
16196 .await
16197 {
16198 Ok(output) => {
16199 snapshot.truncated |= output.truncated;
16200 if output.timed_out {
16201 append_git_diagnostic(&mut snapshot, "git status timed out");
16202 } else if output.success {
16203 parse_git_status(&output.stdout, &mut snapshot);
16204 } else {
16205 append_git_diagnostic(
16206 &mut snapshot,
16207 String::from_utf8_lossy(&output.stderr).trim(),
16208 );
16209 }
16210 }
16211 Err(error) => append_git_diagnostic(
16212 &mut snapshot,
16213 &format!("git status failed: {error}"),
16214 ),
16215 }
16216
16217 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16218 return (snapshot, true);
16219 }
16220 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16221 match run_git_bounded_with_deadline(
16222 root,
16223 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16224 16 * 1_024,
16225 deadline,
16226 )
16227 .await
16228 {
16229 Ok(output) => {
16230 snapshot.truncated |= output.truncated;
16231 if output.timed_out {
16232 append_git_diagnostic(&mut snapshot, "git log timed out");
16233 } else if output.success {
16234 parse_git_commits(&output.stdout, &mut snapshot);
16235 } else {
16236 let stderr = String::from_utf8_lossy(&output.stderr);
16237 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16238 append_git_diagnostic(&mut snapshot, stderr.trim());
16239 }
16240 }
16241 }
16242 Err(error) => append_git_diagnostic(
16243 &mut snapshot,
16244 &format!("git log failed: {error}"),
16245 ),
16246 }
16247
16248 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16249 return (snapshot, true);
16250 }
16251 match list_git_worktrees_with_deadline(root, deadline).await {
16252 Ok(worktrees) => {
16253 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16254 }
16255 Err(error) => append_git_diagnostic(
16256 &mut snapshot,
16257 &format!("git worktree list failed: {}", error.message),
16258 ),
16259 }
16260 (snapshot, false)
16261}
16262
16263fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16264 let mut remaining = output;
16265 while !remaining.is_empty() {
16266 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16267 snapshot.truncated = true;
16268 break;
16269 }
16270 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16271 snapshot.truncated = true;
16272 break;
16273 };
16274 let record = &remaining[..record_end];
16275 remaining = &remaining[record_end + 1..];
16276 let has_previous_path = record
16277 .get(..2)
16278 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16279 let previous_path_bytes = if has_previous_path {
16280 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16281 snapshot.truncated = true;
16282 break;
16283 };
16284 let previous = &remaining[..previous_end];
16285 remaining = &remaining[previous_end + 1..];
16286 Some(previous)
16287 } else {
16288 None
16289 };
16290 if record.len() < 4
16291 || record[2] != b' '
16292 || !is_git_status_code(record[0])
16293 || !is_git_status_code(record[1])
16294 {
16295 snapshot.truncated = true;
16296 continue;
16297 }
16298 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16299 snapshot.truncated = true;
16300 continue;
16301 };
16302 let previous_path = match previous_path_bytes {
16303 Some(previous) => {
16304 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16305 snapshot.truncated = true;
16306 continue;
16307 };
16308 Some(previous)
16309 }
16310 None => None,
16311 };
16312 snapshot.status.push(GitStatusEntry {
16313 index_status: (record[0] as char).to_string(),
16314 worktree_status: (record[1] as char).to_string(),
16315 path,
16316 previous_path,
16317 });
16318 }
16319}
16320
16321fn is_git_status_code(byte: u8) -> bool {
16322 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16323}
16324
16325fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16326 for line in String::from_utf8_lossy(output).lines() {
16327 let Some((id, summary)) = line.split_once('\u{1f}') else {
16328 continue;
16329 };
16330 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16331 snapshot.truncated = true;
16332 break;
16333 }
16334 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16335 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16336 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16337 }
16338}
16339
16340fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16341 if value.len() <= max_bytes {
16342 return value.to_owned();
16343 }
16344 *truncated = true;
16345 let mut boundary = max_bytes;
16346 while boundary > 0 && !value.is_char_boundary(boundary) {
16347 boundary -= 1;
16348 }
16349 value[..boundary].to_owned()
16350}
16351
16352fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16353 if message.is_empty() {
16354 return;
16355 }
16356 let normalized = message
16357 .chars()
16358 .map(|character| if character.is_control() { ' ' } else { character })
16359 .collect::<String>();
16360 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16361 if !diagnostic.is_empty() {
16362 diagnostic.push_str("; ");
16363 }
16364 diagnostic.push_str(normalized.trim());
16365 snapshot.diagnostic = Some(truncate_git_field(
16366 &diagnostic,
16367 GIT_DIAGNOSTIC_MAX_BYTES,
16368 &mut snapshot.truncated,
16369 ));
16370}
16371
16372async fn run_git_bounded(
16373 root: &str,
16374 arguments: &[&str],
16375 output_limit: usize,
16376) -> io::Result<GitCommandOutput> {
16377 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16378}
16379
16380async fn read_git_history_bounded(
16381 root: &str,
16382 path: Option<&RepositoryPath>,
16383 before: Option<&GitObjectId>,
16384 limit: u16,
16385) -> Result<GitHistoryPage, NodeFailure> {
16386 let path = path
16387 .map(|path| {
16388 path.as_utf8().ok_or_else(|| {
16389 failure(
16390 NodeFailureCode::InvalidRepositoryPath,
16391 "git history path must use UTF-8",
16392 )
16393 })
16394 })
16395 .transpose()?
16396 .unwrap_or(".");
16397 let count = (usize::from(limit) + 1).to_string();
16398 let start = before
16399 .map(|revision| format!("{}^", revision.as_str()))
16400 .unwrap_or_else(|| "HEAD".to_owned());
16401 let arguments = [
16402 "--no-pager",
16403 "log",
16404 "--no-decorate",
16405 "--date=iso-strict",
16406 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16407 "-n",
16408 count.as_str(),
16409 start.as_str(),
16410 "--",
16411 path,
16412 ];
16413 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16414 .await
16415 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16416 if output.timed_out {
16417 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16418 }
16419 if !output.success {
16420 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16421 }
16422 let mut commits = parse_git_history(&output.stdout);
16423 let has_more = commits.len() > usize::from(limit) || output.truncated;
16424 commits.truncate(usize::from(limit));
16425 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16426 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16427}
16428
16429fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16430 String::from_utf8_lossy(output)
16431 .split('\u{1e}')
16432 .filter_map(|record| {
16433 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16434 if fields.len() != 9 {
16435 return None;
16436 }
16437 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16438 let parents = if fields[1].is_empty() {
16439 Vec::new()
16440 } else {
16441 fields[1]
16442 .split(' ')
16443 .map(|value| GitObjectId::new(value.to_owned()))
16444 .collect::<Result<Vec<_>, _>>()
16445 .ok()?
16446 };
16447 Some(GitCommitDetails {
16448 id,
16449 parents,
16450 subject: bounded_git_text(fields[2], 512),
16451 author_name: bounded_git_text(fields[3], 256),
16452 author_email: bounded_git_text(fields[4], 320),
16453 authored_at: bounded_git_text(fields[5], 64),
16454 committer_name: bounded_git_text(fields[6], 256),
16455 committer_email: bounded_git_text(fields[7], 320),
16456 committed_at: bounded_git_text(fields[8], 64),
16457 signature_status: GitSignatureStatus::NoSignature,
16458 signer: None,
16459 })
16460 })
16461 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16462 .collect()
16463}
16464
16465fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16466 let normalized = value
16467 .chars()
16468 .map(|character| if character.is_control() { ' ' } else { character })
16469 .collect::<String>();
16470 if normalized.len() <= max_bytes {
16471 return normalized;
16472 }
16473 let mut boundary = max_bytes;
16474 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16475 boundary -= 1;
16476 }
16477 normalized[..boundary].to_owned()
16478}
16479
16480async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16481 let path = request.path.as_ref().map(|path| {
16482 path.as_utf8()
16483 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16484 }).transpose()?;
16485 let mut arguments = match &request.mode {
16486 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16487 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16488 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16489 };
16490 if let GitDiffMode::Commit { revision } = &request.mode {
16491 arguments.push(revision.as_str());
16492 }
16493 arguments.push("--");
16494 if let Some(path) = path {
16495 arguments.push(path);
16496 }
16497 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16498 .await
16499 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16500 if output.timed_out {
16501 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16502 }
16503 if !output.success {
16504 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16505 }
16506 Ok(GitDiff {
16507 mode: request.mode,
16508 path: request.path,
16509 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16510 truncated: output.truncated,
16511 })
16512}
16513
16514async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16515 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16516 ResponseEnvelope { request_id: envelope.request_id, result }
16517}
16518
16519async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16520 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16521 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16522 }
16523 let read_only = matches!(
16524 &request,
16525 NodeRequest::Snapshot
16526 | NodeRequest::Resync { .. }
16527 | NodeRequest::BrowseHostDirectories { .. }
16528 | NodeRequest::InspectWorkspace { .. }
16529 | NodeRequest::ReadWorkspaceFile { .. }
16530 | NodeRequest::ReadGitHistory { .. }
16531 | NodeRequest::ReadGitDiff { .. }
16532 | NodeRequest::CatalogNativeSessions { .. }
16533 | NodeRequest::PageNativeSessions { .. }
16534 | NodeRequest::PreviewNativeSession { .. }
16535 | NodeRequest::PreviewSessionRecord { .. }
16536 );
16537 let _mutation_guard = if read_only {
16538 None
16539 } else {
16540 let guard = shared.mutation_gate.lock().await;
16541 if shared.shutdown.load(Ordering::Acquire) {
16542 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16543 }
16544 Some(guard)
16545 };
16546 match request {
16547 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16548 event_sequence: shared.current_sequence(),
16549 controller: shared.controller_state(),
16550 snapshot: shared.snapshot(),
16551 }),
16552 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16553 NodeRequest::ArmHarnessMcpReservation {
16554 reservation_id,
16555 activation_digest,
16556 spawn_spec,
16557 launch,
16558 expires_at_unix_ms,
16559 } => {
16560 shared.require_controller(connection_id, role)?;
16561 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16562 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16563 })?;
16564 let expires_at_unix_ms = registry
16565 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16566 .await
16567 .map_err(harness_mcp_failure)?;
16568 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16569 }
16570 NodeRequest::SpawnSpecWithHarnessMcp {
16571 reservation_id,
16572 activation_digest,
16573 spec,
16574 deadline_unix_ms,
16575 } => {
16576 shared.require_controller(connection_id, role)?;
16577 let receipt = shared
16578 .spawn_from_spec_with_harness_mcp(
16579 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16580 )
16581 .await?;
16582 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16583 }
16584 NodeRequest::ActivateHarnessMcpReservation {
16585 reservation_id,
16586 activation_digest,
16587 record_id,
16588 session,
16589 } => {
16590 shared.require_controller(connection_id, role)?;
16591 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16592 current.instance_id == session.session.instance_id
16593 && current.generation == session.session.generation
16594 }).and_then(|current| current.process_id).ok_or_else(|| {
16595 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16596 })?;
16597 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16598 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16599 })?.activate(
16600 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16601 ).map_err(harness_mcp_failure)?;
16602 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16603 }
16604 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16605 shared.require_controller(connection_id, role)?;
16606 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16607 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16608 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16609 if let Some(instance_id) = instance_id {
16610 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16611 }
16612 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16613 }
16614 NodeRequest::PutHarnessMcpReplyChunk {
16615 reservation_id, activation_digest, record_id, session, call_id,
16616 offset, final_chunk, chunk_hex,
16617 } => {
16618 shared.require_controller(connection_id, role)?;
16619 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16620 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16621 })?.put_reply_chunk(
16622 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16623 offset, final_chunk, &chunk_hex,
16624 ).map_err(harness_mcp_failure)?;
16625 Ok(NodeResponse::ReplyChunkAccepted {
16626 reservation_id, activation_digest, record_id, session, call_id,
16627 next_offset, completed,
16628 })
16629 }
16630 NodeRequest::RejectHarnessMcpCall {
16631 reservation_id, activation_digest, record_id, session, call_id, reason,
16632 } => {
16633 shared.require_controller(connection_id, role)?;
16634 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16635 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16636 })?.reject_call(
16637 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16638 ).map_err(harness_mcp_failure)?;
16639 Ok(NodeResponse::CallRejected {
16640 reservation_id, activation_digest, record_id, session, call_id,
16641 })
16642 }
16643 NodeRequest::BeginDeliveryStage { manifest } => {
16644 shared.require_controller(connection_id, role)?;
16645 shared.begin_delivery_stage(manifest)
16646 }
16647 NodeRequest::PutDeliveryBlobChunk {
16648 stage_id,
16649 blob_digest,
16650 offset,
16651 chunk_hex,
16652 } => {
16653 shared.require_controller(connection_id, role)?;
16654 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16655 }
16656 NodeRequest::CommitDeliveryStage { stage_id } => {
16657 shared.require_controller(connection_id, role)?;
16658 let receipt = shared.commit_delivery_stage(stage_id)?;
16659 Ok(NodeResponse::DeliveryCommitted { receipt })
16660 }
16661 NodeRequest::AbortDeliveryStage { stage_id } => {
16662 shared.require_controller(connection_id, role)?;
16663 shared.abort_delivery_stage(&stage_id)?;
16664 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16665 }
16666 NodeRequest::BrowseHostDirectories { directory, after } => {
16667 let listing = shared.browse_host_directories(directory, after).await?;
16668 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16669 }
16670 NodeRequest::InspectWorkspace { workspace_id } => {
16671 let inspection = shared.inspect_workspace(workspace_id).await?;
16672 Ok(NodeResponse::WorkspaceInspected { inspection })
16673 }
16674 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16675 let file = shared.read_workspace_file(workspace_id, path).await?;
16676 Ok(NodeResponse::WorkspaceFileRead { file })
16677 }
16678 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16679 shared.require_controller(connection_id, role)?;
16680 let file = shared
16681 .write_workspace_file(workspace_id, path, expected_revision, text)
16682 .await?;
16683 Ok(NodeResponse::WorkspaceFileWritten { file })
16684 }
16685 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16686 shared.require_controller(connection_id, role)?;
16687 let file = shared.create_workspace_file(workspace_id, path).await?;
16688 Ok(NodeResponse::WorkspaceFileCreated { file })
16689 }
16690 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16691 shared.require_controller(connection_id, role)?;
16692 let entry = shared
16693 .create_workspace_directory(workspace_id.clone(), path)
16694 .await?;
16695 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16696 }
16697 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16698 if role != ClientRole::Operator {
16699 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16700 }
16701 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16702 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16703 }
16704 let page = shared
16705 .read_git_history(workspace_id.clone(), path, before, limit)
16706 .await?;
16707 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16708 }
16709 NodeRequest::ReadGitDiff { workspace_id, request } => {
16710 if role != ClientRole::Operator {
16711 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16712 }
16713 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16714 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16715 }
16716 NodeRequest::CatalogNativeSessions { route, limit } => {
16717 if role != ClientRole::Operator {
16718 return Err(failure(
16719 NodeFailureCode::ObserverReadOnly,
16720 "native session catalog requires an operator connection",
16721 ));
16722 }
16723 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16724 return Err(failure(
16725 NodeFailureCode::InvalidRequest,
16726 "native session catalog limit is invalid",
16727 ));
16728 }
16729 let (entries, summary) = shared
16730 .catalog_native_sessions(route.clone(), limit)
16731 .await?;
16732 Ok(NodeResponse::NativeSessionsCataloged {
16733 route,
16734 entries,
16735 summary: Some(summary),
16736 })
16737 }
16738 NodeRequest::PageNativeSessions {
16739 route,
16740 window,
16741 catalog_revision,
16742 recent_cutoff_unix_ms,
16743 after_selection_id,
16744 limit,
16745 } => {
16746 if role != ClientRole::Operator {
16747 return Err(failure(
16748 NodeFailureCode::ObserverReadOnly,
16749 "native session catalog paging requires an operator connection",
16750 ));
16751 }
16752 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16753 || after_selection_id
16754 .as_deref()
16755 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16756 {
16757 return Err(failure(
16758 NodeFailureCode::InvalidRequest,
16759 "native session catalog page request is invalid",
16760 ));
16761 }
16762 let page = shared
16763 .page_native_sessions(
16764 route.clone(),
16765 window,
16766 catalog_revision,
16767 recent_cutoff_unix_ms,
16768 after_selection_id,
16769 limit,
16770 )
16771 .await?;
16772 Ok(NodeResponse::NativeSessionsPaged {
16773 route,
16774 page,
16775 })
16776 }
16777 NodeRequest::PreviewNativeSession {
16778 selection,
16779 message_limit,
16780 } => {
16781 if role != ClientRole::Operator {
16782 return Err(failure(
16783 NodeFailureCode::ObserverReadOnly,
16784 "native session preview requires an operator connection",
16785 ));
16786 }
16787 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16788 .contains(&message_limit)
16789 || selection.validate().is_err()
16790 {
16791 return Err(failure(
16792 NodeFailureCode::InvalidRequest,
16793 "native session preview request is invalid",
16794 ));
16795 }
16796 let preview = shared
16797 .preview_native_session(selection.clone(), message_limit)
16798 .await?;
16799 Ok(NodeResponse::NativeSessionPreviewed {
16800 selection,
16801 preview,
16802 })
16803 }
16804 NodeRequest::IndexNativeSession {
16805 selection,
16806 display_name,
16807 } => {
16808 shared.require_controller(connection_id, role)?;
16809 let record = shared
16810 .index_native_session(selection.clone(), display_name)
16811 .await?;
16812 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16813 }
16814 NodeRequest::PreviewSessionRecord {
16815 record_id,
16816 message_limit,
16817 } => {
16818 if role != ClientRole::Operator {
16819 return Err(failure(
16820 NodeFailureCode::ObserverReadOnly,
16821 "native session preview requires an operator connection",
16822 ));
16823 }
16824 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16825 .contains(&message_limit)
16826 {
16827 return Err(failure(
16828 NodeFailureCode::InvalidRequest,
16829 "native session preview request is invalid",
16830 ));
16831 }
16832 let record = shared.record(&record_id)?;
16833 let working_directory = shared.workspace_root(&record.workspace_id)?;
16834 if !platform::roots_equal(
16835 &working_directory,
16836 windows_path_text(&record.canonical_root),
16837 ) {
16838 return Err(failure(
16839 NodeFailureCode::SessionRecordConflict,
16840 "managed session workspace root changed; refusing to preview another directory",
16841 ));
16842 }
16843 let identity = record.provider_session.clone().ok_or_else(|| {
16844 failure(
16845 NodeFailureCode::BackendOperationFailed,
16846 "native session preview is unavailable for this session record",
16847 )
16848 })?;
16849 if identity.key != ProviderSessionKey::SessionId {
16850 return Err(failure(
16851 NodeFailureCode::BackendOperationFailed,
16852 "native session preview is unavailable for this session identity",
16853 ));
16854 }
16855 let preview = shared
16856 .preview_native_session_by_session_id(
16857 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16858 )
16859 .await?;
16860 if preview.session_id != identity.id {
16861 return Err(failure(
16862 NodeFailureCode::SessionRecordConflict,
16863 "native session preview identity changed while loading",
16864 ));
16865 }
16866 shared.revalidate_session_record_preview(&record, &identity)?;
16867 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16868 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16869 record_id: record_id.clone(),
16870 summary: SessionHistorySummaryV1::from(&preview),
16871 });
16872 Ok(NodeResponse::SessionRecordPreviewed {
16873 record_id,
16874 preview,
16875 })
16876 }
16877 NodeRequest::AcquireController { lease_ms } => {
16878 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16879 Ok(NodeResponse::Controller { controller: Some(controller) })
16880 }
16881 NodeRequest::ReleaseController => {
16882 shared.release_controller(connection_id);
16883 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16884 }
16885 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16886 shared.require_controller(connection_id, role)?;
16887 let root = require_windows_path(root)?;
16888 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16889 let workspace = shared.register_workspace(workspace_id, root).await?;
16890 Ok(NodeResponse::WorkspaceRegistered { workspace })
16891 }
16892 NodeRequest::CreateStandaloneWorkspace {
16893 workspace_id,
16894 root,
16895 initial_branch,
16896 } => {
16897 shared.require_controller(connection_id, role)?;
16898 let root = require_windows_path(root)?;
16899 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16900 let workspace = shared
16901 .create_standalone_workspace(workspace_id, root, initial_branch)
16902 .await?;
16903 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16904 }
16905 NodeRequest::UnregisterWorkspace { workspace_id } => {
16906 shared.require_controller(connection_id, role)?;
16907 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16908 shared.unregister_workspace(&workspace_id)?;
16909 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16910 }
16911 NodeRequest::CreateWorktree {
16912 source_workspace_id,
16913 workspace_id,
16914 target_root,
16915 branch,
16916 base,
16917 } => {
16918 shared.require_controller(connection_id, role)?;
16919 let target_root = require_windows_path(target_root)?;
16920 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16921 let (worktree, workspace) = shared
16922 .create_worktree(
16923 source_workspace_id,
16924 workspace_id,
16925 target_root,
16926 branch,
16927 base,
16928 )
16929 .await?;
16930 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16931 }
16932 NodeRequest::RemoveWorktree {
16933 source_workspace_id,
16934 target_root,
16935 } => {
16936 shared.require_controller(connection_id, role)?;
16937 let native_target_root = require_windows_path(target_root.clone())?;
16938 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16939 let workspace_id = shared
16940 .remove_worktree(source_workspace_id, native_target_root)
16941 .await?;
16942 Ok(NodeResponse::WorktreeRemoved {
16943 target_root,
16944 workspace_id,
16945 })
16946 }
16947 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16948 shared.require_controller(connection_id, role)?;
16949 if !terminal_size.is_valid() {
16950 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16951 }
16952 if let Some(prompt) = initial_prompt.as_deref() {
16953 validate_node_text("spawn initial prompt", prompt)?;
16954 }
16955 match shared
16956 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16957 .await
16958 {
16959 Ok(session) => {
16960 shared.mark_spawn_provisional(session.clone());
16961 Ok(NodeResponse::SpawnAccepted { session })
16962 }
16963 Err(err) => {
16964 tracing::warn!(
16965 %provider,
16966 %workspace_id,
16967 ?mode,
16968 code = ?err.code,
16969 message = %err.message,
16970 "session spawn refused"
16971 );
16972 Err(err)
16973 }
16974 }
16975 }
16976 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16977 shared.require_controller(connection_id, role)?;
16978 if !terminal_size.is_valid() {
16979 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16980 }
16981 if let Some(prompt) = initial_prompt.as_deref() {
16982 validate_node_text("spawn initial prompt", prompt)?;
16983 }
16984 let plan = backend_paths::plan_launch_options(
16985 provider.as_str(),
16986 mode,
16987 options.model.is_some(),
16988 options.effort.is_some(),
16989 )
16990 .map_err(|r| {
16991 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16992 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16993 })?;
16994 let selection = spawn_selection(&options)?;
16995 let nick_model = selection.as_ref().map(|s| s.model.clone());
16996 let pty_selection = match plan {
16997 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16998 _ => None,
16999 };
17000 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17001 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17002 SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17003 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17004 .await
17005 }).await
17006 }).await;
17007 match spawned {
17008 Ok(session) => {
17009 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17010 shared
17011 .pending_acp_options
17012 .lock()
17013 .unwrap_or_else(|p| p.into_inner())
17014 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17015 }
17016 shared.mark_spawn_provisional(session.clone());
17017 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17018 Ok(NodeResponse::SpawnAccepted { session })
17019 }
17020 Err(err) => {
17021 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17022 Err(err)
17023 }
17024 }
17025 }
17026 NodeRequest::SpawnSpec { spec } => {
17027 shared.require_controller(connection_id, role)?;
17028 let receipt = shared.spawn_from_spec(spec).await?;
17029 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17030 }
17031 NodeRequest::SpawnManagedWorktree { request } => {
17032 shared.require_controller(connection_id, role)?;
17033 let receipt = shared.spawn_managed_worktree(request).await?;
17034 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17035 }
17036 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17037 shared.require_controller(connection_id, role)?;
17038 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17039 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17040 }
17041 NodeRequest::CleanupManagedWorktree { lease_id } => {
17042 shared.require_controller(connection_id, role)?;
17043 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17044 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17045 }
17046 NodeRequest::DiscoverHistory { session, limit } => {
17047 controlled_session(shared, connection_id, role, &session)?;
17048 let candidates = shared.discover_history(&session, limit).await?;
17049 Ok(NodeResponse::HistoryDiscovered {
17050 session,
17051 candidates,
17052 })
17053 }
17054 NodeRequest::LoadHistory {
17055 session,
17056 candidate_id,
17057 } => {
17058 controlled_session(shared, connection_id, role, &session)?;
17059 let loaded = shared
17060 .load_history(&session, candidate_id)
17061 .await?;
17062 Ok(NodeResponse::HistoryLoaded {
17063 session,
17064 session_id: loaded.session_id,
17065 message_count: loaded.message_count,
17066 completed_turn_count: loaded.completed_turn_count,
17067 })
17068 }
17069 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17070 shared.require_controller(connection_id, role)?;
17071 let context = shared
17072 .export_context_pack_for_session_record(&record_id, &session, false)
17073 .await?;
17074 Ok(NodeResponse::ContextPackForSessionRecordExported {
17075 record_id,
17076 session,
17077 context,
17078 })
17079 }
17080 NodeRequest::ExportContextPack { session } => {
17081 controlled_session(shared, connection_id, role, &session)?;
17082 let context = shared.export_context_pack(&session).await?;
17083 Ok(NodeResponse::ContextPackExported { context })
17084 }
17085 NodeRequest::ForgetContextPack { context_id } => {
17086 shared.require_controller(connection_id, role)?;
17087 shared.forget_context_pack(&context_id)?;
17088 Ok(NodeResponse::ContextPackForgotten { context_id })
17089 }
17090 NodeRequest::ResolveDurableContextPack { context_id } => {
17091 let context = shared
17095 .context_catalog
17096 .read()
17097 .unwrap_or_else(|poisoned| poisoned.into_inner())
17098 .get(&context_id)
17099 .map(|pack| pack.receipt().clone())
17100 .ok_or_else(|| {
17101 failure(
17102 NodeFailureCode::UnknownContextPack,
17103 "context pack is not present",
17104 )
17105 })?;
17106 Ok(NodeResponse::DurableContextPackResolved { context })
17107 }
17108 NodeRequest::ReadContextPack { digest } => {
17109 let (id, byte_len, bytes) = shared
17114 .context_catalog
17115 .read()
17116 .unwrap_or_else(|poisoned| poisoned.into_inner())
17117 .get_by_digest(&digest)
17118 .map(|pack| {
17119 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17120 })
17121 .ok_or_else(|| {
17122 failure(
17123 NodeFailureCode::UnknownContextPack,
17124 &format!("context pack {digest} not on this node"),
17125 )
17126 })?;
17127 tracing::debug!(
17128 digest = digest.as_str(),
17129 byte_len,
17130 "read context pack bytes",
17131 );
17132 Ok(NodeResponse::ContextPackBytesRead {
17133 pack: ContextPackBytesRead {
17134 digest,
17135 id,
17136 byte_len,
17137 bytes,
17138 },
17139 })
17140 }
17141 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17142 let provider = controlled_session(shared, connection_id, role, &session)?;
17143 if let Some(prompt) = initial_prompt.as_deref() {
17144 validate_node_text("resume initial prompt", prompt)?;
17145 }
17146 let runtime_requirement = if initial_prompt.is_some() {
17147 ProviderRuntimeRequirement::ResumeWithPrompt
17148 } else {
17149 ProviderRuntimeRequirement::Resume
17150 };
17151 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17152 let runtime_policy = shared
17153 .admit_provider_runtime(&provider, runtime_requirement)
17154 .await?;
17155 let working_directory = shared.bound_workspace_root(&session)?;
17156 let request = ResumeLaunchRequest {
17157 working_directory,
17158 terminal_size,
17159 initial_prompt,
17160 };
17161 request
17162 .validate()
17163 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17164 let command = shared.prepare_command(ControlCommand::Resume {
17165 instance_id: session.session.instance_id,
17166 target: ResumeTarget::CurrentProvider,
17167 runtime_policy,
17168 request,
17169 });
17170 shared.arm_resume(&session, command.id, runtime_policy)?;
17171 let dispatch = shared.dispatch_envelope(command);
17172 if let Err(error) = dispatch {
17173 shared.clear_armed_resume(&session);
17174 return Err(error);
17175 }
17176 Ok(NodeResponse::Accepted)
17177 }
17178 NodeRequest::RenameSessionRecord {
17179 record_id,
17180 display_name,
17181 } => {
17182 shared.require_controller(connection_id, role)?;
17183 let record = shared.rename_session_record(&record_id, display_name)?;
17184 Ok(NodeResponse::SessionRecordUpdated { record })
17185 }
17186 NodeRequest::SetSessionTask {
17187 record_id,
17188 expected_revision,
17189 target,
17190 } => {
17191 shared.require_controller(connection_id, role)?;
17192 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17193 Ok(NodeResponse::SessionRecordUpdated { record })
17194 }
17195 NodeRequest::IndexProviderSession {
17196 workspace_id,
17197 provider,
17198 identity,
17199 display_name,
17200 } => {
17201 shared.require_controller(connection_id, role)?;
17202 let record = shared.index_provider_session(
17203 workspace_id,
17204 provider,
17205 identity,
17206 display_name,
17207 )?;
17208 Ok(NodeResponse::ProviderSessionIndexed { record })
17209 }
17210 NodeRequest::ResumeSessionRecord {
17211 record_id,
17212 terminal_size,
17213 initial_prompt,
17214 } => {
17215 shared.require_controller(connection_id, role)?;
17216 if let Some(prompt) = initial_prompt.as_deref() {
17217 validate_node_text("managed resume initial prompt", prompt)?;
17218 }
17219 let (record, session) = shared
17220 .resume_session_record(
17221 &record_id,
17222 terminal_size,
17223 initial_prompt,
17224 )
17225 .await?;
17226 Ok(NodeResponse::SessionRecordResumed { record, session })
17227 }
17228 NodeRequest::ForgetSessionRecord { record_id } => {
17229 shared.require_controller(connection_id, role)?;
17230 shared.forget_session_record(&record_id).await?;
17231 Ok(NodeResponse::SessionRecordForgotten { record_id })
17232 }
17233 NodeRequest::Prompt { session, text } => {
17234 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17235 validate_node_text("prompt", &text)?;
17236 shared.require_session_runtime_policy(
17237 &session,
17238 ProviderRuntimeRequirement::SemanticPrompt,
17239 )?;
17240 let framing = prompt_framing(&agent_id);
17241 shared
17242 .dispatch_input_bounded(
17243 &session,
17244 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17245 )
17246 .await?;
17247 Ok(NodeResponse::Accepted)
17248 }
17249 NodeRequest::Paste { session, text } => {
17250 controlled_session(shared, connection_id, role, &session)?;
17251 validate_node_text("paste", &text)?;
17252 shared.require_session_runtime_policy(
17253 &session,
17254 ProviderRuntimeRequirement::SemanticPrompt,
17255 )?;
17256 shared
17257 .dispatch_input_bounded(
17258 &session,
17259 InputAction::InsertDraft(PromptPayload {
17260 text,
17261 framing: PromptFraming::BracketedPaste,
17262 }),
17263 )
17264 .await?;
17265 Ok(NodeResponse::Accepted)
17266 }
17267 NodeRequest::Input { session, text } => {
17268 controlled_session(shared, connection_id, role, &session)?;
17269 validate_node_text("terminal input", &text)?;
17270 shared
17271 .dispatch_input_bounded(
17272 &session,
17273 InputAction::TerminalText(TerminalText { text }),
17274 )
17275 .await?;
17276 Ok(NodeResponse::Accepted)
17277 }
17278 NodeRequest::TerminalBytes { session, bytes } => {
17279 controlled_session(shared, connection_id, role, &session)?;
17280 shared.sessionize_spawn(session.session.instance_id);
17281 let action = terminal_bytes_action(bytes)?;
17282 shared
17283 .dispatch_input_bounded(&session, action)
17284 .await?;
17285 Ok(NodeResponse::Accepted)
17286 }
17287 NodeRequest::TerminalControl { session, control } => {
17288 controlled_session(shared, connection_id, role, &session)?;
17289 shared
17290 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17291 .await?;
17292 Ok(NodeResponse::Accepted)
17293 }
17294 NodeRequest::Resize { session, size } => {
17295 controlled_session(shared, connection_id, role, &session)?;
17296 shared.sessionize_spawn(session.session.instance_id);
17297 if !size.is_valid() {
17298 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17299 }
17300 shared.dispatch_resize_bounded(&session, size).await?;
17301 Ok(NodeResponse::Accepted)
17302 }
17303 NodeRequest::Interrupt { session } => {
17304 controlled_session(shared, connection_id, role, &session)?;
17305 shared
17306 .dispatch_input_bounded(
17307 &session,
17308 InputAction::TerminalControl(TerminalControl::Interrupt),
17309 )
17310 .await?;
17311 Ok(NodeResponse::Accepted)
17312 }
17313 NodeRequest::Stop { session, force } => {
17314 controlled_session(shared, connection_id, role, &session)?;
17315 shared.sessionize_spawn(session.session.instance_id);
17316 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17317 shared.retire_session_grok_leader(session.session.instance_id);
17318 Ok(NodeResponse::Accepted)
17319 }
17320 NodeRequest::Remove { session } => {
17321 controlled_session(shared, connection_id, role, &session)?;
17322 shared.remove_session(&session).await?;
17323 Ok(NodeResponse::Accepted)
17324 }
17325 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17326 shared.require_controller(connection_id, role)?;
17327 let snapshot = shared.require_acp_session(&session)?;
17328 let interaction = snapshot
17329 .provider
17330 .interactions
17331 .iter()
17332 .find(|interaction| {
17333 interaction_correlation(
17334 session.session.instance_id,
17335 session.session.generation,
17336 interaction.id.0,
17337 ) == correlation_id
17338 })
17339 .ok_or_else(|| failure(
17340 NodeFailureCode::InvalidRequest,
17341 "unknown interaction correlation",
17342 ))?;
17343 if interaction.status != ProviderInteractionStatus::Pending {
17344 return Err(failure(
17345 NodeFailureCode::BackendOperationFailed,
17346 "interaction has already been resolved",
17347 ));
17348 }
17349 response
17350 .validate_for(interaction.interaction_kind)
17351 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17352 let interaction_id = interaction.id;
17353 let response_kind = response.kind();
17354 let response_outcome = response.outcome();
17355 shared.dispatch(ControlCommand::ResolveInteraction {
17356 instance_id: session.session.instance_id,
17357 generation: session.session.generation,
17358 interaction_id,
17359 response,
17360 })?;
17361 tracing::info!(
17367 node_id = %shared.node_id,
17368 session = ?session.session,
17369 correlation_id = %correlation_id,
17370 interaction_id = interaction_id.0,
17371 response_kind = ?response_kind,
17372 outcome = ?response_outcome,
17373 "interaction resolved",
17374 );
17375 Ok(NodeResponse::Accepted)
17376 }
17377 NodeRequest::SetSessionMode { session, mode_id } => {
17378 shared.require_controller(connection_id, role)?;
17379 shared.require_acp_session(&session)?;
17380 shared.dispatch(ControlCommand::SetSessionMode {
17381 instance_id: session.session.instance_id,
17382 mode_id,
17383 })?;
17384 Ok(NodeResponse::Accepted)
17385 }
17386 NodeRequest::SetSessionConfigOption {
17387 session,
17388 option_id,
17389 value_json,
17390 } => {
17391 shared.require_controller(connection_id, role)?;
17392 shared.require_acp_session(&session)?;
17393 shared.dispatch(ControlCommand::SetSessionConfigOption {
17394 instance_id: session.session.instance_id,
17395 option_id,
17396 value_json,
17397 })?;
17398 Ok(NodeResponse::Accepted)
17399 }
17400 NodeRequest::SetSessionModel { session, model_id } => {
17401 shared.require_controller(connection_id, role)?;
17402 shared.require_acp_session(&session)?;
17403 shared.dispatch(ControlCommand::SetSessionModel {
17404 instance_id: session.session.instance_id,
17405 model_id,
17406 })?;
17407 Ok(NodeResponse::Accepted)
17408 }
17409 NodeRequest::Shutdown => {
17410 shared.require_controller(connection_id, role)?;
17411 shared.begin_shutdown_locked().await?;
17412 Ok(NodeResponse::ShuttingDown)
17413 }
17414 }
17415}
17416
17417fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17418 shared.require_controller(connection_id, role)?;
17419 shared.validate_address(session)
17420}
17421
17422fn select_history_candidate(
17428 candidates: &[HistoryCandidateSummary],
17429 provider_session_id: Option<&str>,
17430) -> Result<String, NodeFailure> {
17431 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17432 Some(id) => candidate.session_id_hint == id,
17433 None => true,
17434 });
17435 let candidate = matches.next().ok_or_else(|| {
17436 failure(
17437 NodeFailureCode::SessionRecordConflict,
17438 if provider_session_id.is_some() {
17439 "no history candidate matches the managed provider session"
17440 } else {
17441 "no history candidate exists for the managed session"
17442 },
17443 )
17444 })?;
17445 if matches.next().is_some() {
17446 return Err(failure(
17447 NodeFailureCode::SessionRecordConflict,
17448 if provider_session_id.is_some() {
17449 "history identity is ambiguous for the managed provider session"
17450 } else {
17451 "history is ambiguous without a verified provider session identity"
17452 },
17453 ));
17454 }
17455 Ok(candidate.id.clone())
17456}
17457
17458fn resolved_spawn_profile_summary(
17459 profile: &SpawnProfileDefaults,
17460 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17461) -> Option<SpawnProfileSummary> {
17462 let environment_profile = match profile.environment_profile_id.as_ref() {
17463 None => None,
17464 Some(profile_id) => {
17465 let binding = environment_profiles.get(profile_id)?;
17466 if binding.provider != profile.provider
17467 || binding.native_profile_id(profile.mode).is_none()
17468 {
17469 return None;
17470 }
17471 Some(ResolvedEnvironmentProfileReceipt {
17472 profile_id: binding.id.clone(),
17473 profile_revision: binding.revision.clone(),
17474 network_allowlist: None,
17475 browser_profile_id: None,
17476 })
17477 }
17478 };
17479 Some(SpawnProfileSummary {
17480 id: profile.profile_id.clone(),
17481 revision: profile.revision.clone(),
17482 environment_profile,
17483 })
17484}
17485
17486fn session_record_export_target_matches(
17505 expected: &ManagedSessionRecord,
17506 current: &ManagedSessionRecord,
17507 session: &SessionAddress,
17508 allow_clean_detachment: bool,
17509) -> bool {
17510 if current == expected && current.active_session.as_ref() == Some(session) {
17511 return current.state == ManagedSessionState::Live;
17512 }
17513 allow_clean_detachment
17514 && current.state == ManagedSessionState::Dormant
17515 && current.active_session.is_none()
17516 && current.record_id == expected.record_id
17517 && current.display_name == expected.display_name
17518 && current.provider == expected.provider
17519 && current.mode == expected.mode
17520 && current.workspace_id == expected.workspace_id
17521 && current.canonical_root == expected.canonical_root
17522 && current.provider_session == expected.provider_session
17523 && current.environment_profile == expected.environment_profile
17524 && current.bundle == expected.bundle
17525 && current.context_id == expected.context_id
17526 && current.context == expected.context
17527 && current.exported_context == expected.exported_context
17528 && current.task_binding == expected.task_binding
17529 && current.created_at_unix_ms == expected.created_at_unix_ms
17530 && current.last_error == expected.last_error
17531}
17532
17533fn session_record_context_export_binding_is_exact(
17534 expected: &ManagedSessionRecord,
17535 current: &ManagedSessionRecord,
17536 identity: Option<&ProviderSessionIdentity>,
17537 session: &SessionAddress,
17538 binding: &SessionBinding,
17539 provider: &AgentId,
17540 current_root: &str,
17541 allow_clean_detachment: bool,
17542) -> bool {
17543 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17544 && current.provider_session.as_ref() == identity
17545 && current.workspace_id == session.workspace_id
17546 && provider == ¤t.provider
17547 && binding.workspace_id == session.workspace_id
17548 && binding.generation == session.session.generation
17549 && binding.record_id.as_ref() == Some(¤t.record_id)
17550 && platform::roots_equal(
17551 current_root,
17552 windows_path_text(¤t.canonical_root),
17553 )
17554}
17555
17556fn session_record_context_export_source_is_usable(
17557 record: &ManagedSessionRecord,
17558 status: &SessionStatus,
17559 allow_clean_detachment: bool,
17560) -> bool {
17561 (record.state == ManagedSessionState::Live
17562 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17563 && context_pack_source_status_is_usable(status)
17564}
17565
17566fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17567 PromptFraming::BracketedPaste
17568}
17569
17570fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17571 if text.len() > MAX_NODE_TEXT_BYTES {
17572 return Err(failure(
17573 NodeFailureCode::InvalidRequest,
17574 &format!(
17575 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17576 text.len(),
17577 ),
17578 ));
17579 }
17580 Ok(())
17581}
17582
17583fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17584 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17585 return Err(failure(
17586 NodeFailureCode::InvalidRequest,
17587 &format!(
17588 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17589 bytes.len(),
17590 ),
17591 ));
17592 }
17593 Ok(())
17594}
17595
17596fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17597 validate_terminal_bytes(&bytes)?;
17598 Ok(InputAction::TerminalBytes(bytes))
17599}
17600
17601fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17602 if root.is_empty()
17603 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17604 || root.chars().any(char::is_control)
17605 {
17606 return Err(failure(
17607 NodeFailureCode::InvalidWorkspaceRoot,
17608 &format!(
17609 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17610 ),
17611 ));
17612 }
17613 Ok(())
17614}
17615
17616fn canonical_standalone_workspace_candidate(
17617 workspace_id: &WorkspaceId,
17618 root: &str,
17619) -> Result<String, NodeServerError> {
17620 let path = Path::new(root);
17621 match std::fs::symlink_metadata(path) {
17622 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17623 .map(|workspace| workspace.canonical_root),
17624 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17625 if !path.is_absolute() {
17626 return Err(NodeServerError::InvalidWorkspaceRoot {
17627 workspace_id: workspace_id.clone(),
17628 path: root.to_owned(),
17629 message: "path is not absolute".to_owned(),
17630 });
17631 }
17632 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17633 workspace_id: workspace_id.clone(),
17634 path: root.to_owned(),
17635 message: "path has no standalone directory name".to_owned(),
17636 })?;
17637 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17638 workspace_id: workspace_id.clone(),
17639 path: root.to_owned(),
17640 message: "path has no parent directory".to_owned(),
17641 })?;
17642 let canonical = std::fs::canonicalize(parent)
17643 .map(|parent| parent.join(name))
17644 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17645 workspace_id: workspace_id.clone(),
17646 path: root.to_owned(),
17647 message: error.to_string(),
17648 })?;
17649 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17650 NodeServerError::InvalidWorkspaceRoot {
17651 workspace_id: workspace_id.clone(),
17652 path: path.to_string_lossy().into_owned(),
17653 message: "canonical path is not valid Unicode".to_owned(),
17654 }
17655 })?;
17656 let canonical_root = platform::normalize_canonical_root(canonical_root);
17657 validate_workspace_root(workspace_id, &canonical_root)?;
17658 Ok(canonical_root)
17659 }
17660 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17661 workspace_id: workspace_id.clone(),
17662 path: root.to_owned(),
17663 message: error.to_string(),
17664 }),
17665 }
17666}
17667
17668fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17669 let Some(revision) = revision else {
17670 return Ok(());
17671 };
17672 if revision.is_empty()
17673 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17674 || revision.chars().any(char::is_control)
17675 {
17676 return Err(failure(
17677 NodeFailureCode::InvalidRequest,
17678 &format!(
17679 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17680 ),
17681 ));
17682 }
17683 Ok(())
17684}
17685
17686fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17687 let code = match error.kind {
17688 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17689 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17690 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17691 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17692 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17693 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17694 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17695 };
17696 failure(code, &error.message)
17697}
17698
17699fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17700 let code = match error.kind {
17701 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17702 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17703 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17704 NodeFailureCode::BackendOperationFailed
17705 }
17706 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17707 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17708 }
17709 };
17710 failure(code, error.message)
17711}
17712
17713fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17714 let (code, message) = match error.kind {
17715 GitWorktreeErrorKind::Invalid => (
17716 NodeFailureCode::InvalidRequest,
17717 "managed Git input is invalid",
17718 ),
17719 GitWorktreeErrorKind::NotRepository => (
17720 NodeFailureCode::NotGitRepository,
17721 "managed source is not a Git repository",
17722 ),
17723 GitWorktreeErrorKind::Conflict => (
17724 NodeFailureCode::WorktreeConflict,
17725 "managed Git identity conflicts with existing repository state",
17726 ),
17727 GitWorktreeErrorKind::Protected => (
17728 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17729 "managed worktree ownership validation failed",
17730 ),
17731 GitWorktreeErrorKind::Dirty => (
17732 NodeFailureCode::WorktreeDirty,
17733 "managed worktree contains uncommitted changes",
17734 ),
17735 GitWorktreeErrorKind::Locked => (
17736 NodeFailureCode::WorktreeLocked,
17737 "managed worktree is locked",
17738 ),
17739 GitWorktreeErrorKind::Failed => (
17740 NodeFailureCode::BackendOperationFailed,
17741 "managed Git operation failed",
17742 ),
17743 };
17744 NodeFailure { code, message: message.to_owned() }
17745}
17746
17747fn managed_spawn_receipt(
17748 resolved: &ResolvedSpawnSpec,
17749 incarnation_id: NodeIncarnationId,
17750 session: SessionAddress,
17751 lease: ManagedWorktreeLeaseSnapshot,
17752 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17753 bundle: Option<ResolvedBundleReceipt>,
17754 context: Option<ResolvedContextPackReceipt>,
17755) -> ManagedWorktreeSpawnReceipt {
17756 let mut managed = resolved.clone();
17757 managed.target.worktree_id = Some(lease.workspace_id.clone());
17758 ManagedWorktreeSpawnReceipt {
17759 spawn: managed.receipt_with_materialization(
17760 incarnation_id,
17761 session,
17762 environment_profile,
17763 bundle,
17764 context,
17765 ),
17766 lease,
17767 }
17768}
17769
17770fn validate_selected_worktree(
17771 source_root: &str,
17772 selected_root: &str,
17773 worktrees: &[NativeGitWorktreeSnapshot],
17774) -> Result<(), NodeFailure> {
17775 if !worktrees
17776 .iter()
17777 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17778 {
17779 return Err(failure(
17780 NodeFailureCode::WorktreeProtected,
17781 "source workspace is not an authoritative non-bare Git worktree root",
17782 ));
17783 }
17784 if !worktrees.iter().any(|worktree| {
17785 worktree_paths_equal(&worktree.path, selected_root)
17786 && !worktree.is_main
17787 && !worktree.is_bare
17788 }) {
17789 return Err(failure(
17790 NodeFailureCode::WorktreeProtected,
17791 "selected workspace is not an eligible Git worktree in the source repository",
17792 ));
17793 }
17794 Ok(())
17795}
17796
17797fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17798 let code = match error.kind() {
17799 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17800 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17801 NodeFailureCode::RepositoryPathUnsafe
17802 }
17803 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17804 WorkspaceFileReadErrorKind::NotRegularFile => {
17805 NodeFailureCode::RepositoryFileNotRegular
17806 }
17807 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17808 NodeFailureCode::RepositoryFileReadFailed
17809 }
17810 WorkspaceFileReadErrorKind::RevisionConflict => {
17811 NodeFailureCode::RepositoryFileRevisionConflict
17812 }
17813 WorkspaceFileReadErrorKind::AlreadyExists
17814 | WorkspaceFileReadErrorKind::ParentNotFound
17815 | WorkspaceFileReadErrorKind::ParentNotDirectory
17816 | WorkspaceFileReadErrorKind::Canceled => {
17817 NodeFailureCode::RepositoryFileReadFailed
17818 }
17819 };
17820 failure(code, "repository file read failed")
17821}
17822
17823fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17824 let code = match error.kind() {
17825 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17826 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17827 NodeFailureCode::RepositoryPathUnsafe
17828 }
17829 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17830 WorkspaceFileReadErrorKind::NotRegularFile => {
17831 NodeFailureCode::RepositoryFileNotRegular
17832 }
17833 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17834 NodeFailureCode::RepositoryFileWriteFailed
17835 }
17836 WorkspaceFileReadErrorKind::RevisionConflict => {
17837 NodeFailureCode::RepositoryFileRevisionConflict
17838 }
17839 WorkspaceFileReadErrorKind::AlreadyExists
17840 | WorkspaceFileReadErrorKind::ParentNotFound
17841 | WorkspaceFileReadErrorKind::ParentNotDirectory
17842 | WorkspaceFileReadErrorKind::Canceled => {
17843 NodeFailureCode::RepositoryFileWriteFailed
17844 }
17845 };
17846 failure(code, "repository file write failed")
17847}
17848
17849async fn settle_workspace_entry_create<T>(
17850 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17851 commit_state: Arc<AtomicU8>,
17852 deadline: Duration,
17853 timeout_message: &'static str,
17854 task_failure_message: &'static str,
17855) -> Result<T, NodeFailure> {
17856 let joined = match timeout(deadline, &mut task).await {
17857 Ok(joined) => joined,
17858 Err(_) => {
17859 if commit_state
17860 .compare_exchange(
17861 WORKSPACE_ENTRY_CREATE_PENDING,
17862 WORKSPACE_ENTRY_CREATE_CANCELED,
17863 Ordering::AcqRel,
17864 Ordering::Acquire,
17865 )
17866 .is_ok()
17867 {
17868 return Err(failure(
17869 NodeFailureCode::RepositoryEntryCreateTimedOut,
17870 timeout_message,
17871 ));
17872 }
17873 debug_assert_eq!(
17874 commit_state.load(Ordering::Acquire),
17875 WORKSPACE_ENTRY_CREATE_COMMITTING,
17876 );
17877 task.await
17878 }
17879 };
17880 joined
17881 .map_err(|_| {
17882 failure(
17883 NodeFailureCode::RepositoryEntryCreateFailed,
17884 task_failure_message,
17885 )
17886 })?
17887 .map_err(workspace_entry_create_failure)
17888}
17889
17890fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17891 let code = match error.kind() {
17892 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17893 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17894 NodeFailureCode::RepositoryPathUnsafe
17895 }
17896 WorkspaceFileReadErrorKind::AlreadyExists => {
17897 NodeFailureCode::RepositoryEntryAlreadyExists
17898 }
17899 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17900 NodeFailureCode::RepositoryParentNotFound
17901 }
17902 WorkspaceFileReadErrorKind::ParentNotDirectory
17903 | WorkspaceFileReadErrorKind::NotRegularFile => {
17904 NodeFailureCode::RepositoryParentNotDirectory
17905 }
17906 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17907 NodeFailureCode::RepositoryEntryCreateFailed
17908 }
17909 WorkspaceFileReadErrorKind::RevisionConflict => {
17910 NodeFailureCode::RepositoryEntryCreateFailed
17911 }
17912 WorkspaceFileReadErrorKind::Canceled => {
17913 NodeFailureCode::RepositoryEntryCreateTimedOut
17914 }
17915 };
17916 failure(code, "repository entry create failed")
17917}
17918
17919fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17920 let value = digest(&SHA256, bytes)
17921 .as_ref()
17922 .iter()
17923 .map(|byte| format!("{byte:02x}"))
17924 .collect();
17925 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17926}
17927
17928fn managed_spawn_request_digest_v2(
17929 request: &ManagedWorktreeSpawnRequestV2,
17930) -> Result<String, NodeFailure> {
17931 let encoded = serde_json::to_vec(request).map_err(|_| {
17932 failure(
17933 NodeFailureCode::InvalidRequest,
17934 "managed worktree V2 request could not be canonicalized",
17935 )
17936 })?;
17937 let mut value = String::with_capacity(71);
17938 value.push_str("sha256:");
17939 for byte in digest(&SHA256, &encoded).as_ref() {
17940 use std::fmt::Write as _;
17941 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17942 }
17943 Ok(value)
17944}
17945
17946fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17947 let code = match error {
17948 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17949 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17950 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17951 NativeSessionPreviewError::UnsupportedProvider
17952 | NativeSessionPreviewError::PreviewUnavailable
17953 | NativeSessionPreviewError::SessionNotFound
17954 | NativeSessionPreviewError::AmbiguousSession => {
17955 NodeFailureCode::BackendOperationFailed
17956 }
17957 };
17958 failure(code, "native session preview failed")
17959}
17960
17961fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17962 let code = match error.kind() {
17963 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17964 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17965 };
17966 failure(code, "host directory browse failed")
17967}
17968
17969fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17970 let code = match error {
17971 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17972 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17973 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17974 NodeFailureCode::DeliveryStageConflict
17975 }
17976 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17977 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17978 NodeFailureCode::DeliveryChunkOutOfOrder
17979 }
17980 DeliveryStoreError::BlobDigestMismatch => {
17981 NodeFailureCode::DeliveryBlobDigestMismatch
17982 }
17983 DeliveryStoreError::BundleDigestMismatch => {
17984 NodeFailureCode::DeliveryBundleDigestMismatch
17985 }
17986 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17987 DeliveryStoreError::Unavailable
17988 | DeliveryStoreError::Corrupt
17989 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17990 };
17991 failure(code, "delivery operation failed")
17992}
17993
17994fn validate_workspace_root(
17995 workspace_id: &WorkspaceId,
17996 canonical_root: &str,
17997) -> Result<(), NodeServerError> {
17998 #[cfg(windows)]
17999 if canonical_root.starts_with(r"\\") {
18000 return Err(NodeServerError::InvalidWorkspaceRoot {
18001 workspace_id: workspace_id.clone(),
18002 path: canonical_root.to_owned(),
18003 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18004 });
18005 }
18006 if !platform::workspace_root_supported(canonical_root)
18007 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18008 {
18009 return Err(NodeServerError::InvalidWorkspaceRoot {
18010 workspace_id: workspace_id.clone(),
18011 path: canonical_root.to_owned(),
18012 message: "canonical path must be absolute, bounded, and free of control characters"
18013 .to_owned(),
18014 });
18015 }
18016 Ok(())
18017}
18018
18019fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18020 NodeFailure {
18021 code,
18022 message: node_failure_category(code).to_owned(),
18023 }
18024}
18025
18026fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18027 let code = match error {
18028 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18029 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18030 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18031 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18032 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18033 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18034 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18035 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18036 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18037 };
18038 failure(code, "harness MCP proxy operation failed")
18039}
18040
18041static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18045
18046fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18053 let trace = launch.trace.as_ref()?;
18054 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18055 if trace_dir.is_empty() {
18056 return None;
18057 }
18058 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18059 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18060 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18061}
18062
18063fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18064 let remaining = deadline.saturating_duration_since(Instant::now());
18065 if remaining.is_zero() {
18066 Err(failure(
18067 NodeFailureCode::SpawnDeadlineExceeded,
18068 "spawn deadline elapsed",
18069 ))
18070 } else {
18071 Ok(remaining)
18072 }
18073}
18074
18075fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18076 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18077 match deadline {
18078 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18079 None => Ok(bounded),
18080 }
18081}
18082
18083fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18084 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18085 failure(
18086 NodeFailureCode::SpawnDeadlineExceeded,
18087 "spawn dispatch exceeded the spawn deadline",
18088 )
18089 } else {
18090 error
18091 }
18092}
18093
18094fn spawn_runtime_capabilities(
18095 capabilities: &SpawnRequiredCapabilities,
18096) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18097 capabilities
18098 .iter()
18099 .map(|capability| match capability.as_str() {
18100 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18101 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18102 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18103 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18104 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18105 }
18106 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18107 _ => Err(failure(
18108 NodeFailureCode::UnsupportedSpawnCapability,
18109 "spawn required capability is unknown",
18110 )),
18111 })
18112 .collect()
18113}
18114
18115fn persistence_failure(_error: io::Error) -> NodeFailure {
18116 NodeFailure {
18117 code: NodeFailureCode::BackendOperationFailed,
18118 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18119 }
18120}
18121
18122fn node_failure_category(code: NodeFailureCode) -> &'static str {
18123 match code {
18124 NodeFailureCode::InvalidRequest => "invalid-request",
18125 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18126 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18127 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18128 NodeFailureCode::ReservationConflict => "reservation-conflict",
18129 NodeFailureCode::ReservationExpired => "reservation-expired",
18130 NodeFailureCode::BindingMismatch => "binding-mismatch",
18131 NodeFailureCode::NotActivated => "not-activated",
18132 NodeFailureCode::CallNotFound => "call-not-found",
18133 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18134 NodeFailureCode::ResponseTooLarge => "response-too-large",
18135 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18136 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18137 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18138 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18139 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18140 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18141 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18142 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18143 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18144 NodeFailureCode::Unauthorized => "unauthorized",
18145 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18146 NodeFailureCode::ControllerBusy => "controller-busy",
18147 NodeFailureCode::ControllerRequired => "controller-required",
18148 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18149 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18150 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18151 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18152 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18153 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18154 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18155 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18156 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18157 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18158 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18159 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18160 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18161 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18162 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18163 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18164 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18165 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18166 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18167 NodeFailureCode::GitReadFailed => "git-read-failed",
18168 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18169 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18170 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18171 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18172 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18173 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18174 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18175 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18176 NodeFailureCode::TurnInFlight => "turn-in-flight",
18177 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18178 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18179 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18180 "unsupported-network-allowlist-mapping"
18181 }
18182 NodeFailureCode::BrowserStationProbeUnavailable => {
18183 "browser-station-probe-unavailable"
18184 }
18185 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18186 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18187 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18188 "environment-profile-binding-mismatch"
18189 }
18190 NodeFailureCode::UnknownBundle => "unknown-bundle",
18191 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18192 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18193 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18194 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18195 NodeFailureCode::ContextPackMaterializationFailed => {
18196 "context-pack-materialization-failed"
18197 }
18198 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18199 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18200 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18201 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18202 NodeFailureCode::LastWorkspace => "last-workspace",
18203 NodeFailureCode::NotGitRepository => "not-git-repository",
18204 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18205 NodeFailureCode::WorktreeProtected => "worktree-protected",
18206 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18207 NodeFailureCode::WorktreeLocked => "worktree-locked",
18208 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18209 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18210 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18211 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18212 "managed-worktree-profile-revision-mismatch"
18213 }
18214 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18215 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18216 "standalone-workspace-recovery-required"
18217 }
18218 NodeFailureCode::UnknownSession => "unknown-session",
18219 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18220 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18221 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18222 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18223 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18224 NodeFailureCode::WorkspaceRegistrationRequired => {
18225 "workspace-registration-required"
18226 }
18227 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18228 NodeFailureCode::StaleGeneration => "stale-generation",
18229 NodeFailureCode::BackendBusy => "backend-busy",
18230 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18231 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18232 NodeFailureCode::ShuttingDown => "shutting-down",
18233 }
18234}
18235
18236fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18237 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18238}
18239
18240fn durable_state_load_error(error: io::Error) -> NodeServerError {
18241 let category = match session_registry::state_load_refusal(&error) {
18242 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18243 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18244 }
18245 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18246 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18247 }
18248 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18249 DURABLE_STATE_CONFLICT_ERROR
18250 }
18251 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18252 };
18253 durable_state_server_error(error, category)
18254}
18255
18256fn unix_time_ms() -> u64 {
18257 std::time::SystemTime::now()
18258 .duration_since(std::time::UNIX_EPOCH)
18259 .unwrap_or_default()
18260 .as_millis()
18261 .min(u64::MAX as u128) as u64
18262}
18263
18264fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18265 if record.provider_session.is_some() {
18266 ManagedSessionState::Dormant
18267 } else {
18268 ManagedSessionState::Unavailable
18269 }
18270}
18271
18272fn active_record_state(
18273 identity_admitted: bool,
18274 provider_session_is_known: bool,
18275) -> ManagedSessionState {
18276 if identity_admitted && !provider_session_is_known {
18277 ManagedSessionState::IdentityPending
18278 } else {
18279 ManagedSessionState::Live
18280 }
18281}
18282
18283fn managed_worktree_record_holders(
18284 records: &[ManagedSessionRecord],
18285) -> Vec<(SessionRecordId, WorkspaceId)> {
18286 records
18287 .iter()
18288 .filter(|record| record.provider_session.is_some())
18289 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18290 .collect()
18291}
18292
18293fn managed_spawn_record_ownership(
18294 binding: &SessionBinding,
18295 effective_runtime_policy: ProviderRuntimePolicy,
18296) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18297 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18298 if effective_runtime_policy.provider_session_identity {
18299 (binding.record_id.clone(), None)
18300 } else {
18301 (None, binding.record_id.clone())
18302 }
18303}
18304
18305#[derive(Debug, Error)]
18306pub enum NodeServerError {
18307 #[error("node endpoint must be a bounded absolute local transport path")]
18308 InvalidEndpoint,
18309 #[error("node access token must contain 1..=4096 bytes")]
18310 InvalidAccessToken,
18311 #[error("node HTTP observer must listen on a loopback address: {0}")]
18312 InvalidApiListen(std::net::SocketAddr),
18313 #[error("node browser bridge must listen on a loopback address: {0}")]
18314 InvalidBridgeListen(std::net::SocketAddr),
18315 #[error("node browser bridge token must contain 1..=4096 bytes")]
18316 InvalidBridgeToken,
18317 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18318 BridgeUnderlayRequiresBridgeListen,
18319 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18320 BridgeUnderlayRequiresBridgeToken,
18321 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18322 InvalidBridgeUnderlayToken,
18323 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18324 InvalidBridgeUnderlayKey,
18325 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18326 InvalidCallHome(std::net::SocketAddr),
18327 #[error("node could not reach its call-home relay: {0}")]
18328 CallHomeTransport(#[source] std::io::Error),
18329 #[error("node durable state path must be an absolute file path: {0}")]
18330 InvalidStatePath(String),
18331 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18332 InvalidHarnessMcpHelper,
18333 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18334 InvalidNetworkAllowlistCatalogPath(String),
18335 #[error("network allowlist catalog could not be read: {0}")]
18336 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18337 #[error("network allowlist catalog line {line}: {message}")]
18338 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18339 #[error("network allowlist catalog: {0}")]
18340 InvalidNetworkAllowlistCatalog(String),
18341 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18342 NetworkAllowlistCatalogCapacity { max: usize },
18343 #[error("the local state directory is unavailable; supply an explicit state path")]
18344 LocalStateDirectoryUnavailable,
18345 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18346 LocalRuntimeDirectoryUnavailable,
18347 #[error("node durable state failed: {0}")]
18348 DurableState(io::Error),
18349 #[error("node requires at least one configured workspace")]
18350 NoWorkspaces,
18351 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18352 InvalidWorkspaceRoot {
18353 workspace_id: WorkspaceId,
18354 path: String,
18355 message: String,
18356 },
18357 #[error("duplicate workspace ID: {0}")]
18358 DuplicateWorkspaceId(WorkspaceId),
18359 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18360 DuplicateWorkspaceRoot {
18361 first: WorkspaceId,
18362 second: WorkspaceId,
18363 root: String,
18364 },
18365 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18366 ManagedWorktreeProfileRequired(WorkspaceId),
18367 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18368 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18369 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18370 DuplicateManagedWorktreeProfile {
18371 workspace_id: WorkspaceId,
18372 profile_id: WorktreeProfileId,
18373 },
18374 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18375 ManagedWorktreeProfileCapacity {
18376 workspace_id: WorkspaceId,
18377 max: usize,
18378 },
18379 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18380 InvalidManagedWorktreeProfile {
18381 workspace_id: WorkspaceId,
18382 profile_id: WorktreeProfileId,
18383 message: String,
18384 },
18385 #[error("duplicate node environment profile: {0}")]
18386 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18387 #[error("node environment profile provider is unavailable: {0}")]
18388 EnvironmentProfileProviderUnavailable(AgentId),
18389 #[error("node environment profile capacity is {max}")]
18390 EnvironmentProfileCapacity { max: usize },
18391 #[error("node environment profiles reuse native profile ID '{0}'")]
18392 DuplicateNativeEnvironmentProfile(String),
18393 #[error("node native environment profile is invalid: {0}")]
18394 NativeEnvironmentProfile(String),
18395 #[error("node session-environment materialization root must be absolute")]
18396 InvalidSessionEnvironmentRoot,
18397 #[error("node session-environment materializer is unavailable")]
18398 SessionEnvironmentMaterializerRequired,
18399 #[error("node session-environment materializer failed to initialize")]
18400 SessionEnvironmentMaterializer,
18401 #[error("node bundle catalog is invalid: {0}")]
18402 BundleCatalog(String),
18403 #[error("node delivery store failed to initialize: {0}")]
18404 DeliveryStore(String),
18405 #[error("node context pack store failed to initialize")]
18406 ContextPackStore,
18407 #[error("active agent registry failed: {0}")]
18408 Registry(String),
18409 #[error("node provider contract manifest is invalid: {0}")]
18410 ProviderContractManifest(String),
18411 #[error("named pipe I/O failed: {0}")]
18412 Io(#[from] io::Error),
18413 #[error("node HTTP observer failed: {0}")]
18414 HttpApi(io::Error),
18415 #[error("node browser bridge failed: {0}")]
18416 BridgeApi(io::Error),
18417 #[error(transparent)]
18418 Frame(#[from] FrameError),
18419 #[error("node handshake failed: {0}")]
18420 Handshake(String),
18421 #[error("node authentication frame was not received before the bounded deadline")]
18422 AuthenticationTimedOut,
18423 #[error("node authentication primitive failed: {0}")]
18424 Authentication(String),
18425 #[error("node incarnation identity generation failed: {0}")]
18426 IncarnationIdentity(String),
18427 #[error("node pre-authentication limiter is closed")]
18428 PreauthClosed,
18429 #[error("node authenticated connection limit was reached")]
18430 AuthenticatedConnectionLimit,
18431 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18432 SignalStreamClosed,
18433 #[error("node stop-all dispatch failed: {0:?}")]
18434 ShutdownDispatch(NodeFailure),
18435 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18436 ShutdownTimedOut { active_native_sessions: usize },
18437}
18438
18439impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18440 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18441 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18442 match error {
18443 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18444 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18445 E::InvalidEntry { line, message } => {
18446 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18447 }
18448 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18449 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18450 }
18451 }
18452}
18453
18454#[cfg(test)]
18455mod agent_stream_blocked_tests {
18456 use super::*;
18457 use gate4agent_types::{
18458 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18459 ProviderSource,
18460 };
18461
18462 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18463 ProviderSource {
18464 family,
18465 binding: AdapterBinding::new(
18466 AdapterId::new(adapter).unwrap(),
18467 "fixture/v1",
18468 AdapterVerification::SyntheticFixture,
18469 )
18470 .unwrap(),
18471 }
18472 }
18473
18474 fn provider_control_event_at(
18475 family: AdapterFamily,
18476 adapter: &str,
18477 source_sequence: u64,
18478 provider_event: ProviderEvent,
18479 ) -> ControlEvent {
18480 ControlEvent {
18481 sequence: 41,
18482 command_id: None,
18483 instance_id: AgentInstanceId(7),
18484 generation: SessionGeneration(3),
18485 event: ControlEventKind::ProviderEvent {
18486 sequence: 8,
18487 source: provider_source(family, adapter),
18488 source_sequence,
18489 event: provider_event,
18490 },
18491 }
18492 }
18493
18494 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18495 provider_control_event_at(family, "fixture", 9, provider_event)
18496 }
18497
18498 fn denied_host_request(
18499 by: ProviderHostDecisionAuthority,
18500 reason: Option<String>,
18501 ) -> ControlEvent {
18502 provider_control_event_at(
18503 AdapterFamily::Acp,
18504 "claude-code",
18505 2,
18506 ProviderEvent::HostRequestObserved {
18507 method: "terminal/create".to_owned(),
18508 params_json: String::new(),
18509 decision: ProviderHostRequestDecision::Denied { by },
18510 outcome: ProviderHostRequestOutcome::Executed,
18511 reason,
18512 },
18513 )
18514 }
18515
18516 fn blocked(
18517 event: &ControlEvent,
18518 ) -> (
18519 Option<String>,
18520 String,
18521 BlockAuthorityV1,
18522 Option<String>,
18523 String,
18524 Option<String>,
18525 ) {
18526 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18527 chunk.validate().expect("minted chunk must validate");
18528 let AgentStreamChunkKindV1::Blocked {
18529 correlation_id,
18530 tool_class,
18531 authority,
18532 reason_kind,
18533 reason,
18534 help,
18535 } = chunk.kind
18536 else {
18537 panic!("expected AgentStreamChunkKindV1::Blocked");
18538 };
18539 (correlation_id, tool_class, authority, reason_kind, reason, help)
18540 }
18541
18542 #[test]
18546 fn the_enabled_provider_matrix_is_exact() {
18547 let registry = active_registry().unwrap();
18548 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18549 assert_eq!(
18550 providers
18551 .iter()
18552 .map(|contract| contract.provider.as_str())
18553 .collect::<Vec<_>>(),
18554 vec!["shell", "claude", "codex", "grok", "kimi"],
18556 );
18557 assert!(adapters.iter().all(|contract| {
18558 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18559 }));
18560 }
18561
18562 #[test]
18565 fn gate_denied_host_request_mints_a_blocked_chunk() {
18566 let gate_text =
18567 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18568 let event = denied_host_request(
18569 ProviderHostDecisionAuthority::Gate,
18570 Some(gate_text.to_owned()),
18571 );
18572 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18573 assert_eq!(chunk.source_sequence, 2);
18574 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18575 assert_eq!(authority, BlockAuthorityV1::HostGate);
18576 assert_eq!(reason, gate_text);
18577 assert_eq!(correlation_id, None);
18578 assert_eq!(reason_kind, None);
18579 assert_eq!(help, None);
18580 assert_eq!(tool_class, "Write");
18581 }
18582
18583 const CLASSIFIER_SAMPLE: &str = "Permission for this action was denied by the Claude Code auto mode classifier. Reason: Blocked by classifier. If you have other tasks that don't depend on this action, continue working on those. IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomplish this goal, e.g. using head instead of cat. But you *should not* attempt to work around this denial in malicious ways, e.g. do not use your ability to run tests to execute non-test actions. You should only try to work around this restriction in reasonable ways that do not attempt to bypass the intent behind this denial. If you believe this capability is essential to complete the user's request, STOP and explain to the user what you were trying to do and why you need this permission. Let the user decide how to proceed. To allow this type of action in the future, the user can add a Bash permission rule to their settings.";
18584
18585 #[test]
18591 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18592 let event = provider_control_event(
18593 AdapterFamily::Acp,
18594 ProviderEvent::ToolCompleted {
18595 id: "toolu_9".to_owned(),
18596 output: CLASSIFIER_SAMPLE.to_owned(),
18597 is_error: true,
18598 duration_ms: None,
18599 agent_id: None,
18600 non_execution_kind: Some("permission-rule".to_owned()),
18601 },
18602 );
18603 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18604 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18605 assert_eq!(reason, "Blocked by classifier");
18606 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18607 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18608 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18609 }
18610
18611 #[test]
18616 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18617 let message = "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 6:19 PM.";
18618 let event = provider_control_event(
18619 AdapterFamily::Acp,
18620 ProviderEvent::SessionEnded {
18621 result: "JSON-RPC error -32603: Internal error".to_owned(),
18622 cost_usd: None,
18623 is_error: true,
18624 stop_reason: Some(ProviderStopReason::ProviderError {
18625 code: -32603,
18626 message: message.to_owned(),
18627 vendor_code: Some("usageLimitExceeded".to_owned()),
18628 }),
18629 },
18630 );
18631 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18632 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18633 assert_eq!(reason, message);
18634 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18635 assert_eq!(tool_class, "Session");
18636 }
18637
18638 #[test]
18641 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18642 let event = provider_control_event(
18643 AdapterFamily::Acp,
18644 ProviderEvent::SessionEnded {
18645 result: "refusal".to_owned(),
18646 cost_usd: None,
18647 is_error: true,
18648 stop_reason: Some(ProviderStopReason::Refusal),
18649 },
18650 );
18651 let (_, _, authority, _, _, _) = blocked(&event);
18652 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18653 }
18654
18655 #[test]
18659 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18660 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18661 let event = provider_control_event(
18662 AdapterFamily::Acp,
18663 ProviderEvent::ToolCompleted {
18664 id: "toolu_1".to_owned(),
18665 output: "command exited with status 1".to_owned(),
18666 is_error: true,
18667 duration_ms: Some(12),
18668 agent_id: None,
18669 non_execution_kind,
18670 },
18671 );
18672 assert!(agent_stream_chunk(&event).is_none());
18673 }
18674 let narrative = provider_control_event(
18675 AdapterFamily::Acp,
18676 ProviderEvent::ToolCompleted {
18677 id: "toolu_2".to_owned(),
18678 output: "the harness blocked the write, approval needed".to_owned(),
18679 is_error: false,
18680 duration_ms: Some(3),
18681 agent_id: None,
18682 non_execution_kind: None,
18683 },
18684 );
18685 assert!(agent_stream_chunk(&narrative).is_none());
18686 }
18687
18688 #[test]
18692 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18693 for (decision, outcome) in [
18694 (
18695 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18696 ProviderHostRequestOutcome::Executed,
18697 ),
18698 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18699 (
18700 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18701 ProviderHostRequestOutcome::Failed {
18702 error: "terminal/create spawn failed: os error 3".to_owned(),
18703 },
18704 ),
18705 ] {
18706 let event = provider_control_event_at(
18707 AdapterFamily::Acp,
18708 "claude-code",
18709 3,
18710 ProviderEvent::HostRequestObserved {
18711 method: "fs/read_text_file".to_owned(),
18712 params_json: String::new(),
18713 decision: decision.clone(),
18714 outcome,
18715 reason: None,
18716 },
18717 );
18718 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18719 }
18720 }
18721
18722 #[test]
18726 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18727 for (by, expected_authority, expected_reason) in [
18728 (
18729 ProviderHostDecisionAuthority::Operator,
18730 BlockAuthorityV1::Operator,
18731 "declined by the operator",
18732 ),
18733 (
18734 ProviderHostDecisionAuthority::DeadlinePolicy,
18735 BlockAuthorityV1::HostDeadline,
18736 "denied by host policy after the operator deadline expired",
18737 ),
18738 ] {
18739 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18740 assert_eq!(authority, expected_authority);
18741 assert_eq!(reason, expected_reason);
18742 }
18743 }
18744
18745 #[test]
18749 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18750 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18751 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18752 ProviderHostDecisionAuthority::Policy,
18753 Some(oversized),
18754 ));
18755 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18756 }
18757}
18758
18759#[cfg(test)]
18760mod standalone_capability_platform_tests {
18761 use super::*;
18762
18763 #[test]
18764 fn standalone_workspace_capability_requires_windows_identity_guards() {
18765 let capabilities = baseline_capabilities().unwrap();
18766 let advertised = capabilities.iter().any(|capability| {
18767 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18768 });
18769 assert_eq!(advertised, cfg!(windows));
18770
18771 let request = NodeRequest::CreateStandaloneWorkspace {
18772 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18773 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18774 initial_branch: None,
18775 };
18776 assert_eq!(
18777 request_uses_unnegotiated_capability(&request, &capabilities),
18778 !cfg!(windows),
18779 );
18780 }
18781}
18782
18783
18784#[cfg(test)]
18785mod station_knobs_resolve_tests {
18786 use super::*;
18787 use gate4agent_catalog::EnvMutation;
18788 use gate4agent_runtime_native::{
18789 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18790 NativeLaunchProfileId,
18791 };
18792 use std::ffi::OsString;
18793 use std::sync::Arc;
18794
18795 struct EmptyEnvironmentResolver;
18796
18797 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18798 fn resolve_child_environment(
18799 &self,
18800 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18801 Ok(vec![EnvMutation {
18802 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18803 value: None,
18804 }])
18805 }
18806 }
18807
18808 fn agent(value: &str) -> AgentId {
18809 AgentId::new(value).unwrap()
18810 }
18811
18812 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18813 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18814 let profile_revision =
18815 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18816 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18817 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18818 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18819 provider: agent("claude"),
18820 mode: SessionMode::Pty,
18821 terminal_size: gate4agent_types::TerminalSize {
18822 rows: 24,
18823 columns: 80,
18824 },
18825 prompt: None,
18826 bundle_id: None,
18827 context_id: None,
18828 environment_profile_id: Some(profile_id.clone()),
18829 };
18830 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18831 let catalog = active_registry().unwrap();
18832 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18833 let control = runtime.native_launch_profile_control();
18834 let node_profile = NodeEnvironmentProfile::new(
18835 profile_id.clone(),
18836 profile_revision,
18837 agent("claude"),
18838 [NativeLaunchProfile::new(
18839 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18840 agent("claude"),
18841 TransportKind::Pty,
18842 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18843 Arc::new(EmptyEnvironmentResolver),
18844 )
18845 .unwrap()],
18846 )
18847 .unwrap();
18848 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18849 for native_profile in native_profiles {
18850 runtime.upsert_native_launch_profile(native_profile).unwrap();
18851 }
18852 let workspace = WorkspaceConfig::new(
18853 WorkspaceId::new("primary").unwrap(),
18854 std::env::current_dir().unwrap(),
18855 )
18856 .unwrap();
18857 let shared = NodeShared::new_with_incarnation(
18858 handle,
18859 "fixture-token".to_owned(),
18860 NodeId::new("node-station-knobs-test").unwrap(),
18861 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18862 vec![workspace],
18863 vec![agent("claude")],
18864 ProviderRuntimeStatuses::default(),
18865 None,
18866 Vec::new(),
18867 Vec::new(),
18868 spawn_profiles,
18869 Some(control),
18870 None,
18871 None,
18872 None,
18873 Vec::new(),
18874 Vec::new(),
18875 Vec::new(),
18876 Vec::new(),
18877 None,
18878 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18879 );
18880 shared
18881 .environment_profiles
18882 .write()
18883 .unwrap_or_else(|poisoned| poisoned.into_inner())
18884 .insert(binding.id.clone(), binding);
18885 (shared, spawn_defaults)
18886 }
18887
18888 #[test]
18891 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18892 let (shared, defaults) = station_knobs_fixture();
18893 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18894 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18895 let spec = crate::protocol::SpawnSpec {
18896 target: crate::protocol::SpawnTarget {
18897 node_id: shared.node_id.clone(),
18898 workspace_id: WorkspaceId::new("primary").unwrap(),
18899 worktree_id: None,
18900 },
18901 profile_id: defaults.profile_id.clone(),
18902 expected_profile_revision: defaults.revision.clone(),
18903 overrides: crate::protocol::SpawnOverrides {
18904 network_allowlist: Some(unknown),
18905 browser_profile_id: Some(browser),
18906 ..crate::protocol::SpawnOverrides::default()
18907 },
18908 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18909 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18910 "station-knobs-unknown-allowlist",
18911 )
18912 .unwrap(),
18913 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18914 };
18915 let resolved = spec.resolve(&defaults).unwrap();
18916 let failure = shared
18917 .resolve_environment_profile(&resolved)
18918 .expect_err("unknown allowlist must refuse");
18919 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18920 }
18921
18922 #[test]
18928 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18929 let (shared, defaults) = station_knobs_fixture();
18930 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18931 #[cfg(not(feature = "dig2-station-probe"))]
18932 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18933 shared
18934 .network_allowlist_catalog
18935 .write()
18936 .unwrap_or_else(|poisoned| poisoned.into_inner())
18937 .insert_id(allowlist.clone())
18938 .unwrap();
18939 let spec = crate::protocol::SpawnSpec {
18940 target: crate::protocol::SpawnTarget {
18941 node_id: shared.node_id.clone(),
18942 workspace_id: WorkspaceId::new("primary").unwrap(),
18943 worktree_id: None,
18944 },
18945 profile_id: defaults.profile_id.clone(),
18946 expected_profile_revision: defaults.revision.clone(),
18947 overrides: crate::protocol::SpawnOverrides {
18948 network_allowlist: Some(allowlist.clone()),
18949 #[cfg(not(feature = "dig2-station-probe"))]
18950 browser_profile_id: Some(browser.clone()),
18951 ..crate::protocol::SpawnOverrides::default()
18952 },
18953 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18954 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18955 .unwrap(),
18956 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18957 };
18958 let resolved = spec.resolve(&defaults).unwrap();
18959 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18960 #[cfg(not(feature = "dig2-station-probe"))]
18961 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18962 let receipt = shared
18963 .resolve_environment_profile(&resolved)
18964 .expect("resolve ok")
18965 .expect("env profile present");
18966 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18967 #[cfg(not(feature = "dig2-station-probe"))]
18968 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18969 #[cfg(feature = "dig2-station-probe")]
18970 assert_eq!(receipt.browser_profile_id, None);
18971 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18972
18973 let omitted = crate::protocol::SpawnSpec {
18974 overrides: crate::protocol::SpawnOverrides::default(),
18975 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18976 .unwrap(),
18977 ..spec
18978 };
18979 let resolved_omit = omitted.resolve(&defaults).unwrap();
18980 let bare = shared
18981 .resolve_environment_profile(&resolved_omit)
18982 .expect("resolve ok")
18983 .expect("env profile present");
18984 assert_eq!(bare.network_allowlist, None);
18985 assert_eq!(bare.browser_profile_id, None);
18986 }
18987
18988 #[cfg(feature = "dig2-station-probe")]
18993 #[test]
18994 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18995 let (shared, defaults) = station_knobs_fixture();
18996 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18997 let spec = crate::protocol::SpawnSpec {
18998 target: crate::protocol::SpawnTarget {
18999 node_id: shared.node_id.clone(),
19000 workspace_id: WorkspaceId::new("primary").unwrap(),
19001 worktree_id: None,
19002 },
19003 profile_id: defaults.profile_id.clone(),
19004 expected_profile_revision: defaults.revision.clone(),
19005 overrides: crate::protocol::SpawnOverrides {
19006 browser_profile_id: Some(browser),
19007 ..crate::protocol::SpawnOverrides::default()
19008 },
19009 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19010 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19011 "station-knobs-probe-refuse",
19012 )
19013 .unwrap(),
19014 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19015 };
19016 let resolved = spec.resolve(&defaults).unwrap();
19017 let failure = shared
19018 .resolve_environment_profile(&resolved)
19019 .expect_err("browser_profile with failing probe must refuse");
19020 #[cfg(windows)]
19021 {
19022 assert!(
19023 matches!(
19024 failure.code,
19025 NodeFailureCode::BrowserStationUnreachable
19026 | NodeFailureCode::BrowserStationProbeUnavailable
19027 ),
19028 "unexpected code {:?}",
19029 failure.code
19030 );
19031 }
19032 #[cfg(not(windows))]
19033 {
19034 assert_eq!(
19035 failure.code,
19036 NodeFailureCode::BrowserStationProbeUnavailable
19037 );
19038 }
19039 }
19040
19041 #[cfg(feature = "dig2-station-probe")]
19043 #[test]
19044 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19045 let (shared, defaults) = station_knobs_fixture();
19046 let spec = crate::protocol::SpawnSpec {
19047 target: crate::protocol::SpawnTarget {
19048 node_id: shared.node_id.clone(),
19049 workspace_id: WorkspaceId::new("primary").unwrap(),
19050 worktree_id: None,
19051 },
19052 profile_id: defaults.profile_id.clone(),
19053 expected_profile_revision: defaults.revision.clone(),
19054 overrides: crate::protocol::SpawnOverrides::default(),
19055 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19056 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19057 "station-knobs-probe-omit",
19058 )
19059 .unwrap(),
19060 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19061 };
19062 let resolved = spec.resolve(&defaults).unwrap();
19063 let receipt = shared
19064 .resolve_environment_profile(&resolved)
19065 .expect("omit browser_profile must not probe")
19066 .expect("env profile present");
19067 assert_eq!(receipt.browser_profile_id, None);
19068 }
19069
19070 #[cfg(feature = "dig2-station-probe")]
19074 #[test]
19075 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19076 let (shared, _defaults) = station_knobs_fixture();
19077 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19078 let workspace_id = WorkspaceId::new("primary").unwrap();
19079 let address_a = SessionAddress {
19080 workspace_id: workspace_id.clone(),
19081 session: SessionKey {
19082 instance_id: AgentInstanceId(101),
19083 generation: SessionGeneration(1),
19084 },
19085 };
19086 let address_b = SessionAddress {
19087 workspace_id: workspace_id.clone(),
19088 session: SessionKey {
19089 instance_id: AgentInstanceId(102),
19090 generation: SessionGeneration(1),
19091 },
19092 };
19093 let guard_a = crate::dig2_station_lease::acquire_lease(
19094 &shared.browser_station_leases,
19095 profile.clone(),
19096 address_a.session.instance_id,
19097 1,
19098 )
19099 .expect("first lease");
19100 let busy = crate::dig2_station_lease::acquire_lease(
19101 &shared.browser_station_leases,
19102 profile.clone(),
19103 address_b.session.instance_id,
19104 2,
19105 )
19106 .expect_err("second holder must refuse");
19107 assert!(matches!(
19108 busy,
19109 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19110 ));
19111 shared.bind_session_with_policy(
19113 &address_a,
19114 ProviderRuntimePolicy::raw_pty(),
19115 None,
19116 );
19117 guard_a.retain();
19118 assert!(shared.remove_binding(&address_a).is_some());
19119 crate::dig2_station_lease::acquire_lease(
19120 &shared.browser_station_leases,
19121 profile,
19122 address_b.session.instance_id,
19123 3,
19124 )
19125 .expect("reacquire after clear")
19126 .retain();
19127 assert!(
19128 crate::dig2_station_lease::release_lease_for_holder(
19129 &shared.browser_station_leases,
19130 address_b.session.instance_id,
19131 )
19132 );
19133 }
19134
19135 #[cfg(not(feature = "dig2-station-probe"))]
19138 #[test]
19139 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19140 let (shared, defaults) = station_knobs_fixture();
19141 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19142 let spec = crate::protocol::SpawnSpec {
19143 target: crate::protocol::SpawnTarget {
19144 node_id: shared.node_id.clone(),
19145 workspace_id: WorkspaceId::new("primary").unwrap(),
19146 worktree_id: None,
19147 },
19148 profile_id: defaults.profile_id.clone(),
19149 expected_profile_revision: defaults.revision.clone(),
19150 overrides: crate::protocol::SpawnOverrides {
19151 browser_profile_id: Some(browser.clone()),
19152 ..crate::protocol::SpawnOverrides::default()
19153 },
19154 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19155 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19156 .unwrap(),
19157 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19158 };
19159 let resolved = spec.resolve(&defaults).unwrap();
19160 let receipt = shared
19161 .resolve_environment_profile(&resolved)
19162 .expect("stub resolve")
19163 .expect("env profile");
19164 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19165 }
19166
19167
19168 #[test]
19171 fn network_allowlist_catalog_loads_from_temp_file() {
19172 let unique = std::time::SystemTime::now()
19173 .duration_since(std::time::UNIX_EPOCH)
19174 .unwrap()
19175 .as_nanos();
19176 let path = std::env::temp_dir().join(format!(
19177 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19178 std::process::id(),
19179 ));
19180 std::fs::write(
19181 &path,
19182 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19183 )
19184 .unwrap();
19185 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19186 .expect("temp catalog file must load");
19187 let _ = std::fs::remove_file(&path);
19188 assert_eq!(loaded.len(), 2);
19189 let (shared, defaults) = station_knobs_fixture();
19190 {
19191 let mut catalog = shared
19192 .network_allowlist_catalog
19193 .write()
19194 .unwrap_or_else(|poisoned| poisoned.into_inner());
19195 *catalog = loaded;
19196 }
19197 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19198 let spec = crate::protocol::SpawnSpec {
19199 target: crate::protocol::SpawnTarget {
19200 node_id: shared.node_id.clone(),
19201 workspace_id: WorkspaceId::new("primary").unwrap(),
19202 worktree_id: None,
19203 },
19204 profile_id: defaults.profile_id.clone(),
19205 expected_profile_revision: defaults.revision.clone(),
19206 overrides: crate::protocol::SpawnOverrides {
19207 network_allowlist: Some(allowlist.clone()),
19208 ..crate::protocol::SpawnOverrides::default()
19209 },
19210 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19211 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19212 "station-knobs-catalog-file",
19213 )
19214 .unwrap(),
19215 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19216 };
19217 let resolved = spec.resolve(&defaults).unwrap();
19218 let receipt = shared
19219 .resolve_environment_profile(&resolved)
19220 .expect("registered via file load")
19221 .expect("env profile present");
19222 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19223 }
19224
19225 #[test]
19228 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19229 let (shared, _defaults) = station_knobs_fixture();
19230 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19231 shared
19232 .network_allowlist_catalog
19233 .write()
19234 .unwrap_or_else(|poisoned| poisoned.into_inner())
19235 .insert_id(allowlist.clone())
19236 .unwrap();
19237 let snapshot = shared.snapshot();
19238 let inventory = snapshot
19239 .launch_inventory
19240 .expect("launch inventory present");
19241 let ids = inventory
19242 .network_allowlists
19243 .expect("non-empty catalog surfaces ids");
19244 assert_eq!(ids, vec![allowlist]);
19245 }
19246
19247 #[test]
19251 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19252 let (shared, defaults) = station_knobs_fixture();
19253 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19254 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19255 id: allowlist.clone(),
19256 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19257 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19258 peer: "127.0.0.1:443".to_owned(),
19259 }],
19260 provider_native: Some(
19261 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19262 codex_network_access: Some(true),
19263 },
19264 ),
19265 };
19266 shared
19267 .network_allowlist_catalog
19268 .write()
19269 .unwrap_or_else(|poisoned| poisoned.into_inner())
19270 .insert(entry)
19271 .unwrap();
19272 let spec = crate::protocol::SpawnSpec {
19273 target: crate::protocol::SpawnTarget {
19274 node_id: shared.node_id.clone(),
19275 workspace_id: WorkspaceId::new("primary").unwrap(),
19276 worktree_id: None,
19277 },
19278 profile_id: defaults.profile_id.clone(),
19279 expected_profile_revision: defaults.revision.clone(),
19280 overrides: crate::protocol::SpawnOverrides {
19281 network_allowlist: Some(allowlist),
19282 ..crate::protocol::SpawnOverrides::default()
19283 },
19284 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19285 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19286 "station-knobs-unsupported-mapping",
19287 )
19288 .unwrap(),
19289 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19290 };
19291 let resolved = spec.resolve(&defaults).unwrap();
19292 assert_eq!(resolved.provider.as_str(), "claude");
19293 let failure = shared
19294 .resolve_environment_profile(&resolved)
19295 .expect_err("claude + codex_network_access must refuse");
19296 assert_eq!(
19297 failure.code,
19298 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19299 );
19300 }
19301
19302 #[test]
19306 fn resolve_codex_network_access_overlay_moderate_only() {
19307 let (shared, defaults) = station_knobs_fixture();
19308 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19309 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19310 id: allowlist.clone(),
19311 permits: Vec::new(),
19312 provider_native: Some(
19313 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19314 codex_network_access: Some(true),
19315 },
19316 ),
19317 };
19318 shared
19319 .network_allowlist_catalog
19320 .write()
19321 .unwrap_or_else(|poisoned| poisoned.into_inner())
19322 .insert(entry)
19323 .unwrap();
19324
19325 let overlay = shared
19326 .resolve_codex_network_access_overlay_args(
19327 &agent("codex"),
19328 SessionMode::Pty,
19329 ApprovalLevel::Moderate,
19330 Some(&allowlist),
19331 )
19332 .expect("Moderate Codex maps network_access");
19333 assert_eq!(
19334 overlay,
19335 vec![
19336 std::ffi::OsString::from("-c"),
19337 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19338 ]
19339 );
19340
19341 let acp = shared
19342 .resolve_codex_network_access_overlay_args(
19343 &agent("codex"),
19344 SessionMode::Acp,
19345 ApprovalLevel::Moderate,
19346 Some(&allowlist),
19347 )
19348 .unwrap();
19349 assert_eq!(acp, overlay);
19350
19351 let inline = shared
19352 .resolve_codex_network_access_overlay_args(
19353 &agent("codex"),
19354 SessionMode::Inline,
19355 ApprovalLevel::Moderate,
19356 Some(&allowlist),
19357 )
19358 .unwrap_err();
19359 assert_eq!(
19360 inline.code,
19361 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19362 );
19363
19364 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19365 let spec = crate::protocol::SpawnSpec {
19366 target: crate::protocol::SpawnTarget {
19367 node_id: shared.node_id.clone(),
19368 workspace_id: WorkspaceId::new("primary").unwrap(),
19369 worktree_id: None,
19370 },
19371 profile_id: defaults.profile_id.clone(),
19372 expected_profile_revision: defaults.revision.clone(),
19373 overrides: crate::protocol::SpawnOverrides {
19374 provider: crate::protocol::SpawnOverride::Set {
19375 value: agent("codex"),
19376 },
19377 approval_level: Some(level),
19378 network_allowlist: Some(allowlist.clone()),
19379 ..crate::protocol::SpawnOverrides::default()
19380 },
19381 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19382 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19383 "codex-net-refuse-{}",
19384 match level {
19385 ApprovalLevel::ReadOnly => "ro",
19386 ApprovalLevel::FullAuto => "fa",
19387 _ => "other",
19388 }
19389 ))
19390 .unwrap(),
19391 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19392 };
19393 let resolved = spec.resolve(&defaults).unwrap();
19394 let failure = shared
19395 .resolve_environment_profile(&resolved)
19396 .expect_err("RO/FullAuto must refuse");
19397 assert_eq!(
19398 failure.code,
19399 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19400 );
19401 }
19402 }
19403
19404 #[test]
19408 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19409 let (shared, _defaults) = station_knobs_fixture();
19410 let allowlist =
19411 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19412 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19413 id: allowlist.clone(),
19414 permits: vec![
19415 crate::network_allowlist_catalog::NetworkPermitSketch {
19416 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19417 peer: "github.com:443".to_owned(),
19418 },
19419 crate::network_allowlist_catalog::NetworkPermitSketch {
19420 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19421 peer: "*.npmjs.org".to_owned(),
19422 },
19423 ],
19424 provider_native: None,
19425 };
19426 shared
19427 .network_allowlist_catalog
19428 .write()
19429 .unwrap_or_else(|poisoned| poisoned.into_inner())
19430 .insert(entry)
19431 .unwrap();
19432
19433 let unique = std::time::SystemTime::now()
19434 .duration_since(std::time::UNIX_EPOCH)
19435 .unwrap()
19436 .as_nanos();
19437 let provider_home = std::env::temp_dir().join(format!(
19438 "gate4agent-claude-provider-home-{}-{unique}",
19439 std::process::id(),
19440 ));
19441 std::fs::create_dir_all(&provider_home).unwrap();
19442
19443 let overlay = shared
19444 .resolve_network_allowlist_overlay_args(
19445 &agent("claude"),
19446 SessionMode::Pty,
19447 ApprovalLevel::Moderate,
19448 Some(&allowlist),
19449 Some(provider_home.as_path()),
19450 )
19451 .expect("Claude PTY settings overlay on unix");
19452 let settings_path = provider_home.join(
19453 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19454 );
19455 assert_eq!(
19456 overlay,
19457 vec![
19458 std::ffi::OsString::from("--settings"),
19459 std::ffi::OsString::from(settings_path.as_os_str()),
19460 ]
19461 );
19462 let body = std::fs::read_to_string(&settings_path).unwrap();
19463 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19464 assert_eq!(value["sandbox"]["enabled"], true);
19465 assert_eq!(
19466 value["sandbox"]["network"]["allowedDomains"],
19467 serde_json::json!(["github.com", "*.npmjs.org"])
19468 );
19469 assert!(settings_path.starts_with(&provider_home));
19470
19471 let acp = shared
19472 .resolve_network_allowlist_overlay_args(
19473 &agent("claude"),
19474 SessionMode::Acp,
19475 ApprovalLevel::Moderate,
19476 Some(&allowlist),
19477 Some(provider_home.as_path()),
19478 )
19479 .expect_err("Claude ACP must refuse settings overlay");
19480 assert_eq!(
19481 acp.code,
19482 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19483 );
19484
19485 let no_home = shared
19486 .resolve_network_allowlist_overlay_args(
19487 &agent("claude"),
19488 SessionMode::Pty,
19489 ApprovalLevel::Moderate,
19490 Some(&allowlist),
19491 None,
19492 )
19493 .expect_err("Claude overlay requires ProviderHome");
19494 assert_eq!(
19495 no_home.code,
19496 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19497 );
19498
19499 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19500 shared
19501 .network_allowlist_catalog
19502 .write()
19503 .unwrap_or_else(|poisoned| poisoned.into_inner())
19504 .insert_id(bare.clone())
19505 .unwrap();
19506 let empty = shared
19507 .resolve_network_allowlist_overlay_args(
19508 &agent("claude"),
19509 SessionMode::Pty,
19510 ApprovalLevel::Moderate,
19511 Some(&bare),
19512 Some(provider_home.as_path()),
19513 )
19514 .unwrap();
19515 assert!(empty.is_empty());
19516
19517 let _ = std::fs::remove_dir_all(&provider_home);
19518 }
19519
19520 #[test]
19522 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19523 let (shared, _defaults) = station_knobs_fixture();
19524 let allowlist =
19525 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19526 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19527 id: allowlist.clone(),
19528 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19529 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19530 peer: "example.com:443".to_owned(),
19531 }],
19532 provider_native: None,
19533 };
19534 shared
19535 .network_allowlist_catalog
19536 .write()
19537 .unwrap_or_else(|poisoned| poisoned.into_inner())
19538 .insert(entry)
19539 .unwrap();
19540 for provider in ["kimi", "grok"] {
19541 let overlay = shared
19542 .resolve_network_allowlist_overlay_args(
19543 &agent(provider),
19544 SessionMode::Pty,
19545 ApprovalLevel::Moderate,
19546 Some(&allowlist),
19547 None,
19548 )
19549 .unwrap();
19550 assert!(
19551 overlay.is_empty(),
19552 "{provider} must not invent network argv from permits"
19553 );
19554 }
19555 }
19556
19557 #[test]
19559 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19560 let unique = std::time::SystemTime::now()
19561 .duration_since(std::time::UNIX_EPOCH)
19562 .unwrap()
19563 .as_nanos();
19564 let path = std::env::temp_dir().join(format!(
19565 "gate4agent-node-allowlist-json-{}-{unique}.json",
19566 std::process::id(),
19567 ));
19568 std::fs::write(
19569 &path,
19570 r#"{
19571 "schema_version": 2,
19572 "entries": [
19573 {
19574 "id": "egress-default",
19575 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19576 },
19577 { "id": "lab-egress" }
19578 ]
19579 }"#,
19580 )
19581 .unwrap();
19582 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19583 .expect("json v2 catalog must load");
19584 let _ = std::fs::remove_file(&path);
19585 assert_eq!(loaded.len(), 2);
19586 let entry = loaded
19587 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19588 .unwrap();
19589 assert_eq!(entry.permits.len(), 1);
19590 let (shared, defaults) = station_knobs_fixture();
19591 {
19592 let mut catalog = shared
19593 .network_allowlist_catalog
19594 .write()
19595 .unwrap_or_else(|poisoned| poisoned.into_inner());
19596 *catalog = loaded;
19597 }
19598 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19599 let spec = crate::protocol::SpawnSpec {
19600 target: crate::protocol::SpawnTarget {
19601 node_id: shared.node_id.clone(),
19602 workspace_id: WorkspaceId::new("primary").unwrap(),
19603 worktree_id: None,
19604 },
19605 profile_id: defaults.profile_id.clone(),
19606 expected_profile_revision: defaults.revision.clone(),
19607 overrides: crate::protocol::SpawnOverrides {
19608 network_allowlist: Some(allowlist.clone()),
19609 ..crate::protocol::SpawnOverrides::default()
19610 },
19611 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19612 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19613 "station-knobs-catalog-json",
19614 )
19615 .unwrap(),
19616 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19617 };
19618 let resolved = spec.resolve(&defaults).unwrap();
19619 let receipt = shared
19620 .resolve_environment_profile(&resolved)
19621 .expect("permits-only entry ok for claude")
19622 .expect("env profile present");
19623 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19624 let inventory = shared
19625 .snapshot()
19626 .launch_inventory
19627 .expect("launch inventory present");
19628 let ids = inventory.network_allowlists.expect("ids surface");
19629 assert_eq!(
19630 ids,
19631 vec![
19632 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19633 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19634 ]
19635 );
19636 }
19637
19638
19639}
19640
19641
19642#[cfg(test)]
19643mod workspace_environment_collision_tests {
19644 use super::*;
19645 use crate::session_environment::{
19646 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19647 };
19648 use gate4agent_catalog::EnvMutation;
19649 use gate4agent_runtime_native::{
19650 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19651 NativeLaunchProfileId,
19652 };
19653 use std::ffi::OsString;
19654 use std::sync::Arc;
19655
19656 struct EmptyEnvironmentResolver;
19657
19658 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19659 fn resolve_child_environment(
19660 &self,
19661 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19662 Ok(vec![EnvMutation {
19663 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19664 value: None,
19665 }])
19666 }
19667 }
19668
19669 fn agent(value: &str) -> AgentId {
19670 AgentId::new(value).unwrap()
19671 }
19672
19673 fn claude_config_dir_collision_fixture() -> (
19675 NodeShared,
19676 ResolvedEnvironmentProfileReceipt,
19677 ResolvedEnvironmentProfileReceipt,
19678 ) {
19679 let catalog = active_registry().unwrap();
19680 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19681 let control = runtime.native_launch_profile_control();
19682 let workspace = WorkspaceConfig::new(
19683 WorkspaceId::new("primary").unwrap(),
19684 std::env::current_dir().unwrap(),
19685 )
19686 .unwrap();
19687 let shared = NodeShared::new_with_incarnation(
19688 handle,
19689 "fixture-token".to_owned(),
19690 NodeId::new("node-env-collision-test").unwrap(),
19691 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19692 vec![workspace],
19693 vec![agent("claude")],
19694 ProviderRuntimeStatuses::default(),
19695 None,
19696 Vec::new(),
19697 Vec::new(),
19698 SpawnProfileRegistry::default(),
19699 Some(control),
19700 None,
19701 None,
19702 None,
19703 Vec::new(),
19704 Vec::new(),
19705 Vec::new(),
19706 Vec::new(),
19707 None,
19708 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19709 );
19710
19711 let mut receipts = Vec::new();
19712 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19713 let profile_id =
19714 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19715 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19716 "claude-config-dir-{suffix}-r1"
19717 ))
19718 .unwrap();
19719 let materialization = NodeSessionMaterializationProfile::new(
19720 Vec::new(),
19721 vec![NodeSessionPathBinding::new(
19722 "CLAUDE_CONFIG_DIR",
19723 NodeSessionPathClass::ProviderHome,
19724 )
19725 .unwrap()],
19726 Vec::new(),
19727 )
19728 .unwrap();
19729 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19730 profile_id.clone(),
19731 profile_revision.clone(),
19732 agent("claude"),
19733 [NativeLaunchProfile::new(
19734 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19735 .unwrap(),
19736 agent("claude"),
19737 TransportKind::Pty,
19738 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19739 Arc::new(EmptyEnvironmentResolver),
19740 )
19741 .unwrap()],
19742 Some(materialization),
19743 )
19744 .unwrap();
19745 let (binding, native_profiles, materialization) = node_profile.into_parts();
19746 for native_profile in native_profiles {
19747 runtime.upsert_native_launch_profile(native_profile).unwrap();
19748 }
19749 shared
19750 .environment_profiles
19751 .write()
19752 .unwrap_or_else(|poisoned| poisoned.into_inner())
19753 .insert(binding.id.clone(), binding);
19754 shared
19755 .environment_materialization_profiles
19756 .write()
19757 .unwrap_or_else(|poisoned| poisoned.into_inner())
19758 .insert(profile_id.clone(), materialization.unwrap());
19759 receipts.push(ResolvedEnvironmentProfileReceipt {
19760 profile_id,
19761 profile_revision,
19762 network_allowlist: None,
19763 browser_profile_id: None,
19764 });
19765 }
19766 let profile_b = receipts.pop().unwrap();
19767 let profile_a = receipts.pop().unwrap();
19768 (shared, profile_a, profile_b)
19769 }
19770
19771 #[test]
19772 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19773 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19774 let primary = WorkspaceId::new("primary").unwrap();
19775 let worktree = WorkspaceId::new("worktree-b").unwrap();
19776 let address = SessionAddress {
19777 workspace_id: primary.clone(),
19778 session: SessionKey {
19779 instance_id: AgentInstanceId(41),
19780 generation: SessionGeneration(1),
19781 },
19782 };
19783 shared.bind_session_with_policy(
19784 &address,
19785 ProviderRuntimePolicy::raw_pty(),
19786 Some(profile_a.clone()),
19787 );
19788
19789 let refused = shared
19790 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19791 .unwrap_err();
19792 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19793
19794 let refused_none = shared
19796 .ensure_workspace_environment_profile_compatible(&primary, None)
19797 .unwrap_err();
19798 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19799
19800 shared
19802 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19803 .unwrap();
19804
19805 shared
19807 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19808 .unwrap();
19809 }
19810
19811 #[tokio::test]
19812 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19813 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19814 let primary = WorkspaceId::new("primary").unwrap();
19815 let address = SessionAddress {
19816 workspace_id: primary.clone(),
19817 session: SessionKey {
19818 instance_id: AgentInstanceId(41),
19819 generation: SessionGeneration(1),
19820 },
19821 };
19822 shared.bind_session_with_policy(
19823 &address,
19824 ProviderRuntimePolicy::raw_pty(),
19825 Some(profile_a),
19826 );
19827
19828 let failure = shared
19829 .spawn_session_with_deadline(
19830 primary,
19831 agent("claude"),
19832 SessionMode::Pty,
19833 gate4agent_types::TerminalSize {
19834 rows: 24,
19835 columns: 80,
19836 },
19837 None,
19838 Some(profile_b),
19839 None,
19840 None,
19841 None,
19842 None,
19843 SpawnRecordPolicy::ProviderIdentityOnly,
19844 Some(Instant::now() + Duration::from_secs(30)),
19845 &[],
19846 None,
19847 ApprovalLevel::default(),
19848 None,
19849 None,
19850 )
19851 .await
19852 .unwrap_err();
19853 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19854 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19856 assert!(shared.handle.snapshot().sessions.is_empty());
19857 }
19858}
19859
19860#[cfg(test)]
19861mod spawn_characterization_tests {
19862 use super::*;
19863 use crate::session_environment::{
19864 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19865 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19866 NodeSessionPathClass,
19867 };
19868 use gate4agent_catalog::EnvMutation;
19869 use gate4agent_runtime_native::{
19870 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19871 NativeLaunchProfile, NativeLaunchProfileId,
19872 };
19873 use std::ffi::OsString;
19874 use std::sync::Arc;
19875
19876 struct EmptyEnvironmentResolver;
19877
19878 struct FixtureSecretResolver {
19879 deny: Arc<AtomicBool>,
19880 }
19881
19882 impl NodeSecretResolver for FixtureSecretResolver {
19883 fn resolve(
19884 &self,
19885 _reference: &NodeSecretReference,
19886 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19887 if self.deny.load(Ordering::Acquire) {
19888 Err(NodeSecretResolveError::Denied)
19889 } else {
19890 NodeSecretValue::text("fixture-secret")
19891 .map_err(|_| NodeSecretResolveError::Unavailable)
19892 }
19893 }
19894 }
19895
19896 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19897 fn resolve_child_environment(
19898 &self,
19899 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19900 Ok(vec![EnvMutation {
19901 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19902 value: None,
19903 }])
19904 }
19905 }
19906
19907 fn agent(value: &str) -> AgentId {
19908 AgentId::new(value).unwrap()
19909 }
19910
19911 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19912 let unique = std::time::SystemTime::now()
19913 .duration_since(std::time::UNIX_EPOCH)
19914 .unwrap()
19915 .as_nanos();
19916 std::env::temp_dir().join(format!(
19917 "gate4agent-node-{label}-{}-{unique}",
19918 std::process::id(),
19919 ))
19920 }
19921
19922 fn materialization_test_shared(
19923 deny_secret: bool,
19924 ) -> (
19925 NodeShared,
19926 NativeRuntime,
19927 ResolvedEnvironmentProfileReceipt,
19928 PathBuf,
19929 Arc<AtomicBool>,
19930 ) {
19931 let workspace = WorkspaceConfig::new(
19932 WorkspaceId::new("primary").unwrap(),
19933 std::env::current_dir().unwrap(),
19934 )
19935 .unwrap();
19936 materialization_test_shared_with_workspace(deny_secret, workspace)
19937 }
19938
19939 fn materialization_test_shared_with_workspace(
19940 deny_secret: bool,
19941 workspace: WorkspaceConfig,
19942 ) -> (
19943 NodeShared,
19944 NativeRuntime,
19945 ResolvedEnvironmentProfileReceipt,
19946 PathBuf,
19947 Arc<AtomicBool>,
19948 ) {
19949 let root = temporary_workspace_root(if deny_secret {
19950 "materialization-denied"
19951 } else {
19952 "materialization-ready"
19953 });
19954 std::fs::create_dir_all(&root).unwrap();
19955 let materialization_root = root.join("session-environments");
19956 let deny = Arc::new(AtomicBool::new(deny_secret));
19957 let resolver = Arc::new(FixtureSecretResolver {
19958 deny: Arc::clone(&deny),
19959 });
19960 let materializer = SessionEnvironmentMaterializer::new(
19961 materialization_root,
19962 resolver,
19963 )
19964 .unwrap();
19965 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19966 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19967 "materialized-claude-r1",
19968 )
19969 .unwrap();
19970 let materialization = NodeSessionMaterializationProfile::new(
19971 vec![NodeSessionEnvironmentMutation::SetSecret {
19972 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19973 reference: NodeSecretReference::new("fixture-token").unwrap(),
19974 }],
19975 vec![NodeSessionPathBinding::new(
19976 "GATE4AGENT_PROVIDER_HOME",
19977 NodeSessionPathClass::ProviderHome,
19978 )
19979 .unwrap()],
19980 vec![NodeSessionFile::secret(
19981 NodeSessionPathClass::Config,
19982 "auth/token",
19983 NodeSecretReference::new("fixture-token").unwrap(),
19984 )
19985 .unwrap()],
19986 )
19987 .unwrap();
19988 let catalog = active_registry().unwrap();
19989 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19990 let control = runtime.native_launch_profile_control();
19991 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19992 profile_id.clone(),
19993 profile_revision.clone(),
19994 agent("claude"),
19995 [NativeLaunchProfile::new(
19996 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19997 agent("claude"),
19998 TransportKind::Pty,
19999 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20000 Arc::new(EmptyEnvironmentResolver),
20001 )
20002 .unwrap()],
20003 Some(materialization),
20004 )
20005 .unwrap();
20006 let (binding, native_profiles, materialization) = node_profile.into_parts();
20007 for native_profile in native_profiles {
20008 runtime.upsert_native_launch_profile(native_profile).unwrap();
20009 }
20010 let shared = NodeShared::new_with_incarnation(
20011 handle,
20012 "fixture-token".to_owned(),
20013 NodeId::new("node-materialization-test").unwrap(),
20014 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20015 vec![workspace],
20016 vec![agent("claude")],
20017 ProviderRuntimeStatuses::default(),
20018 None,
20019 Vec::new(),
20020 Vec::new(),
20021 SpawnProfileRegistry::default(),
20022 Some(control),
20023 None,
20024 Some(materializer),
20025 None,
20026 Vec::new(),
20027 Vec::new(),
20028 Vec::new(),
20029 Vec::new(),
20030 None,
20031 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20032 );
20033 shared
20034 .environment_profiles
20035 .write()
20036 .unwrap_or_else(|poisoned| poisoned.into_inner())
20037 .insert(binding.id.clone(), binding);
20038 shared
20039 .environment_materialization_profiles
20040 .write()
20041 .unwrap_or_else(|poisoned| poisoned.into_inner())
20042 .insert(profile_id.clone(), materialization.unwrap());
20043 (
20044 shared,
20045 runtime,
20046 ResolvedEnvironmentProfileReceipt {
20047 profile_id,
20048 profile_revision,
20049 network_allowlist: None,
20050 browser_profile_id: None,
20051 },
20052 root,
20053 deny,
20054 )
20055 }
20056
20057
20058 async fn characterization_spawn(
20061 shared: &NodeShared,
20062 mode: SessionMode,
20063 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20064 required: &[ProviderRuntimeCapability],
20065 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20066 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20067 shared
20068 .spawn_session_with_deadline(
20069 WorkspaceId::new("primary").unwrap(),
20070 agent("claude"),
20071 mode,
20072 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20073 None,
20074 environment_profile,
20075 None,
20076 None,
20077 None,
20078 Some(ProviderRuntimePolicy::raw_pty()),
20079 SpawnRecordPolicy::ProviderIdentityOnly,
20080 Some(Instant::now() + Duration::from_secs(30)),
20081 required,
20082 harness_mcp,
20083 ApprovalLevel::default(),
20084 None,
20085 None,
20086 )
20087 .await
20088 }
20089
20090 fn assert_no_session_state(shared: &NodeShared) {
20091 assert!(shared.handle.snapshot().sessions.is_empty());
20092 assert!(shared
20093 .session_bindings
20094 .lock()
20095 .unwrap_or_else(|poisoned| poisoned.into_inner())
20096 .is_empty());
20097 assert!(shared
20098 .materializations
20099 .lock()
20100 .unwrap_or_else(|poisoned| poisoned.into_inner())
20101 .is_empty());
20102 }
20103
20104 #[tokio::test]
20105 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20106 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20107 let error = characterization_spawn(
20108 &shared,
20109 SessionMode::Pty,
20110 None,
20111 &[ProviderRuntimeCapability::SemanticReadiness],
20112 None,
20113 )
20114 .await
20115 .unwrap_err();
20116 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20117 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20118 assert_no_session_state(&shared);
20119 drop(shared);
20120 drop(runtime);
20121 std::fs::remove_dir_all(root).unwrap();
20122 }
20123
20124 #[tokio::test]
20125 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20126 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20127 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20128 .await
20129 .unwrap_err();
20130 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20131 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20132 assert_no_session_state(&shared);
20133 drop(shared);
20134 drop(runtime);
20135 std::fs::remove_dir_all(root).unwrap();
20136 }
20137
20138 #[test]
20139 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20140 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20141 let address = SessionAddress {
20142 workspace_id: WorkspaceId::new("primary").unwrap(),
20143 session: SessionKey {
20144 instance_id: AgentInstanceId(77),
20145 generation: SessionGeneration(1),
20146 },
20147 };
20148 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20149 let removed = shared.remove_binding(&address).expect("binding removed");
20150 assert!(removed.environment_profile.is_some());
20151 assert!(shared.remove_binding(&address).is_none());
20152 assert_no_session_state(&shared);
20153 drop(shared);
20154 drop(runtime);
20155 std::fs::remove_dir_all(root).unwrap();
20156 }
20157
20158 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20159 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20160 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20161 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20162 .expect("review current test executable as fixture helper");
20163 let registry = HarnessMcpProxyRegistry::new(helper);
20164 let spec = crate::protocol::SpawnSpec {
20165 target: crate::protocol::SpawnTarget {
20166 node_id: NodeId::new("node-characterization").unwrap(),
20167 workspace_id: WorkspaceId::new("primary").unwrap(),
20168 worktree_id: None,
20169 },
20170 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20171 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20172 overrides: crate::protocol::SpawnOverrides::default(),
20173 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20174 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20175 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20176 };
20177 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20178 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20179 let launch = HarnessMcpLaunchV1 {
20180 server_name: "fixture-harness".to_owned(),
20181 args: vec!["--session-proxy".to_owned()],
20182 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20183 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20184 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20185 trace: None,
20186 scrub_env: Vec::new(),
20187 };
20188 let expires = unix_time_ms().saturating_add(30_000);
20189 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20190 registry
20191 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20192 .unwrap()
20193 }
20194
20195 #[tokio::test]
20196 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20197 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20198 let prepared = characterization_prepared_harness("claude").await;
20199 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20200 .await
20201 .unwrap_err();
20202 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20204 let mismatched = characterization_prepared_harness("codex").await;
20205 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20206 .await
20207 .unwrap_err();
20208 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20209 assert_no_session_state(&shared);
20210 drop(shared);
20211 drop(runtime);
20212 std::fs::remove_dir_all(root).unwrap();
20213 }
20214
20215 #[tokio::test]
20216 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20217 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20218 shared.native_launch_profile_control = None;
20219 let prepared = characterization_prepared_harness("claude").await;
20220 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20221 .await
20222 .unwrap_err();
20223 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20224 assert_no_session_state(&shared);
20225 drop(shared);
20226 drop(runtime);
20227 std::fs::remove_dir_all(root).unwrap();
20228 }
20229}
20230
20231#[cfg(all(test, windows))]
20232mod tests {
20233 use super::*;
20234 use gate4agent_types::ProviderActivity;
20235 use crate::session_environment::{
20236 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20237 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20238 NodeSessionPathClass,
20239 };
20240 use gate4agent_catalog::EnvMutation;
20241 use gate4agent_runtime_native::{
20242 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20243 NativeLaunchProfile, NativeLaunchProfileId,
20244 };
20245 use std::ffi::OsString;
20246 use std::sync::Arc;
20247
20248 struct EmptyEnvironmentResolver;
20249
20250 struct FixtureSecretResolver {
20251 deny: Arc<AtomicBool>,
20252 }
20253
20254 impl NodeSecretResolver for FixtureSecretResolver {
20255 fn resolve(
20256 &self,
20257 _reference: &NodeSecretReference,
20258 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20259 if self.deny.load(Ordering::Acquire) {
20260 Err(NodeSecretResolveError::Denied)
20261 } else {
20262 NodeSecretValue::text("fixture-secret")
20263 .map_err(|_| NodeSecretResolveError::Unavailable)
20264 }
20265 }
20266 }
20267
20268 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20269 fn resolve_child_environment(
20270 &self,
20271 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20272 Ok(vec![EnvMutation {
20273 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20274 value: None,
20275 }])
20276 }
20277 }
20278
20279 fn agent(value: &str) -> AgentId {
20280 AgentId::new(value).unwrap()
20281 }
20282
20283 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20284 let catalog = active_registry().unwrap();
20285 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20286 let workspace = WorkspaceConfig::new(
20287 WorkspaceId::new("primary").unwrap(),
20288 std::env::current_dir().unwrap(),
20289 )
20290 .unwrap()
20291 .with_worktree_service_mode(mode);
20292 NodeShared::new(
20293 handle,
20294 "fixture-token".to_owned(),
20295 NodeId::new("node-terminal-test").unwrap(),
20296 vec![workspace],
20297 vec![agent("claude")],
20298 )
20299 }
20300
20301 fn terminal_test_shared() -> NodeShared {
20302 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20303 }
20304
20305 #[cfg(feature = "fixture")]
20306 #[test]
20307 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20308 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20309 assert_eq!(probe.latest_code(), None);
20310
20311 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20312 assert_eq!(
20313 probe.latest_code(),
20314 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20315 );
20316
20317 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20318 assert_eq!(
20319 probe.latest_code(),
20320 Some(NodeFailureCode::SpawnDeadlineExceeded),
20321 );
20322
20323 probe.record(None);
20324 assert_eq!(probe.latest_code(), None);
20325 }
20326
20327 #[test]
20328 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20329 let source = temporary_workspace_root("launch-inventory-source");
20330 let allocation = temporary_workspace_root("launch-inventory-allocation");
20331 std::fs::create_dir_all(&source).unwrap();
20332 std::fs::create_dir_all(&allocation).unwrap();
20333 let workspace_id = WorkspaceId::new("primary").unwrap();
20334 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20335 .unwrap()
20336 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20337 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20338 workspace = workspace
20339 .with_managed_worktree_profile(
20340 ManagedWorktreeProfile::new(
20341 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20342 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20343 &allocation,
20344 "gate4agent",
20345 "HEAD",
20346 ManagedWorktreeRetention::RemoveWhenReleased,
20347 )
20348 .unwrap(),
20349 )
20350 .unwrap();
20351 }
20352 let overflow = workspace.clone().with_managed_worktree_profile(
20353 ManagedWorktreeProfile::new(
20354 WorktreeProfileId::new("overflow").unwrap(),
20355 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20356 &allocation,
20357 "gate4agent",
20358 "HEAD",
20359 ManagedWorktreeRetention::Retain,
20360 )
20361 .unwrap(),
20362 );
20363 assert!(matches!(
20364 overflow,
20365 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20366 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20367 ..
20368 }),
20369 ));
20370
20371 let catalog = active_registry().unwrap();
20372 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20373 let shared = NodeShared::new(
20374 handle,
20375 "fixture-token".to_owned(),
20376 NodeId::new("node-launch-inventory").unwrap(),
20377 vec![workspace],
20378 vec![agent("claude")],
20379 );
20380 let snapshot = shared.snapshot();
20381 let launch = snapshot.launch_inventory.as_ref().unwrap();
20382 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20383 assert!(launch.bundles.as_ref().unwrap().is_empty());
20384 let profiles = snapshot.workspaces[0]
20385 .managed_worktree_profiles
20386 .as_ref()
20387 .unwrap();
20388 assert_eq!(
20389 snapshot.workspaces[0].worktree_service_mode,
20390 Some(WorktreeServiceMode::Managed),
20391 );
20392 assert_eq!(
20393 profiles.profiles.len(),
20394 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20395 );
20396 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20397 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20398 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20399 }
20400 let legacy = snapshot_for_wire(
20401 &shared,
20402 true,
20403 true,
20404 false,
20405 false,
20406 true,
20407 false,
20408 true,
20409 false,
20410 true,
20411 );
20412 assert!(legacy.launch_inventory.is_none());
20413 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20414
20415 drop(shared);
20416 std::fs::remove_dir_all(source).unwrap();
20417 std::fs::remove_dir_all(allocation).unwrap();
20418 }
20419
20420 #[test]
20421 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20422 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20423 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20424 }));
20425 let source = gate4agent_types::ProviderSource {
20426 family: AdapterFamily::PtySemantic,
20427 binding: AdapterBinding::new(
20428 gate4agent_types::AdapterId::new("claude").unwrap(),
20429 "fixture/v1",
20430 gate4agent_types::AdapterVerification::SyntheticFixture,
20431 )
20432 .unwrap(),
20433 };
20434 let provider = ProviderSnapshot {
20435 sequence: 17,
20436 session: Some(ProviderSessionIdentity {
20437 key: ProviderSessionKey::SessionId,
20438 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20439 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20440 }),
20441 model: Some("PRIVATE_MODEL".to_owned()),
20442 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20443 completed_turns: 3,
20444 usage: gate4agent_types::TokenUsage {
20445 input_tokens: 10,
20446 output_tokens: 20,
20447 cache_read_tokens: 30,
20448 cache_write_tokens: 40,
20449 reasoning_tokens: 50,
20450 context_window: Some(200_000),
20451 },
20452 lead_activity: ProviderActivity::WaitingForInput,
20453 activity: ProviderActivity::WaitingForInput,
20454 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20455 active_tools: (0..9)
20456 .map(|index| gate4agent_types::ActiveProviderTool {
20457 id: format!("private-tool-id-{index}"),
20458 name: if index == 0 {
20459 format!(" Read\u{0000}{}", "x".repeat(80))
20460 } else {
20461 format!("Tool-{index}")
20462 },
20463 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20464 })
20465 .collect(),
20466 interactions: vec![gate4agent_types::ProviderInteraction {
20467 id: gate4agent_types::ProviderInteractionId(17),
20468 source: source.clone(),
20469 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20470 interaction_kind: ProviderInteractionKind::Approval,
20471 tool_name: " Approve\u{0007}Tool ".to_owned(),
20472 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20473 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20474 resume_lead_activity: Some(ProviderActivity::Working),
20475 status: ProviderInteractionStatus::Pending,
20476 }],
20477 subagents: vec![gate4agent_types::ProviderSubagent {
20478 source: source.clone(),
20479 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20480 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20481 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20482 }],
20483 sources: vec![gate4agent_types::ProviderSourceCursor {
20484 source,
20485 sequence: 17,
20486 gap_count: 2,
20487 stale: true,
20488 }],
20489 last_event: Some(ProviderEvent::Error {
20490 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20491 }),
20492 gap_count: 2,
20493 stale: true,
20494 };
20495 let address = terminal_address(3);
20496 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20497 assert_eq!(entry.address, address);
20498 assert_eq!(entry.progress.provider_sequence, 17);
20499 assert_eq!(entry.progress.active_tool_count, 9);
20500 assert_eq!(
20501 entry.progress.active_tool_labels.len(),
20502 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20503 );
20504 assert_eq!(entry.progress.subagent_count, 1);
20505 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20506 assert!(entry.progress.truncated);
20507 let encoded = serde_json::to_string(&entry).unwrap();
20508 for forbidden in [
20509 "PRIVATE_PROVIDER_SESSION",
20510 "PRIVATE_PROVIDER_PATH",
20511 "PRIVATE_MODEL",
20512 "PRIVATE_TOOL_INVENTORY",
20513 "PRIVATE_CURRENT_PROMPT",
20514 "PRIVATE_TOOL_INPUT_JSON",
20515 "PRIVATE_PROVIDER_REQUEST",
20516 "PRIVATE_INTERACTION_PROMPT",
20517 "PRIVATE_INTERACTION_AGENT",
20518 "PRIVATE_SUBAGENT_ID",
20519 "PRIVATE_SUBAGENT_TYPE",
20520 "PRIVATE_SUBAGENT_DESCRIPTION",
20521 "PRIVATE_PROVIDER_ERROR",
20522 "input_json",
20523 "current_prompt",
20524 "session_id",
20525 ] {
20526 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20527 }
20528
20529 let snapshot = NodeSnapshot {
20530 node_id: NodeId::new("node-progress-test").unwrap(),
20531 enabled_providers: vec![agent("claude")],
20532 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20533 workspaces: Vec::new(),
20534 session_records: Vec::new(),
20535 managed_worktrees: Vec::new(),
20536 launch_inventory: None,
20537 agent_progress: vec![entry],
20538 };
20539 let mut legacy_reply = ResponseEnvelope {
20540 request_id: 1,
20541 result: Ok(NodeResponse::Snapshot {
20542 event_sequence: 0,
20543 controller: None,
20544 snapshot: snapshot.clone(),
20545 }),
20546 };
20547 project_response_without_agent_progress(&mut legacy_reply);
20548 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20549 panic!("agent progress projection changed response kind");
20550 };
20551 assert!(legacy.agent_progress.is_empty());
20552 assert_eq!(snapshot.agent_progress.len(), 1);
20553 }
20554
20555 fn terminal_address(generation: u64) -> SessionAddress {
20556 SessionAddress {
20557 workspace_id: WorkspaceId::new("primary").unwrap(),
20558 session: SessionKey {
20559 instance_id: AgentInstanceId(41),
20560 generation: SessionGeneration(generation),
20561 },
20562 }
20563 }
20564
20565 fn spawn_spec_fixture() -> SpawnSpec {
20566 SpawnSpec {
20567 target: crate::protocol::SpawnTarget {
20568 node_id: NodeId::new("node-terminal-test").unwrap(),
20569 workspace_id: WorkspaceId::new("primary").unwrap(),
20570 worktree_id: None,
20571 },
20572 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20573 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20574 .unwrap(),
20575 overrides: crate::protocol::SpawnOverrides::default(),
20576 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20577 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20578 required_capabilities: SpawnRequiredCapabilities::default(),
20579 }
20580 }
20581
20582 fn environment_profile_test_shared(
20583 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20584 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20585 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20586 "local-claude-r1",
20587 )
20588 .unwrap();
20589 let spawn_profiles = SpawnProfileRegistry::new([
20590 crate::protocol::SpawnProfileDefaults {
20591 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20592 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20593 provider: agent("claude"),
20594 mode: SessionMode::Pty,
20595 terminal_size: gate4agent_types::TerminalSize {
20596 rows: 24,
20597 columns: 80,
20598 },
20599 prompt: None,
20600 bundle_id: None,
20601 context_id: None,
20602 environment_profile_id: Some(profile_id.clone()),
20603 },
20604 ])
20605 .unwrap();
20606 let catalog = active_registry().unwrap();
20607 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20608 let control = runtime.native_launch_profile_control();
20609 let node_profile = NodeEnvironmentProfile::new(
20610 profile_id.clone(),
20611 profile_revision.clone(),
20612 agent("claude"),
20613 [NativeLaunchProfile::new(
20614 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20615 agent("claude"),
20616 TransportKind::Pty,
20617 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20618 Arc::new(EmptyEnvironmentResolver),
20619 )
20620 .unwrap()],
20621 )
20622 .unwrap();
20623 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20624 for native_profile in native_profiles {
20625 runtime.upsert_native_launch_profile(native_profile).unwrap();
20626 }
20627 let workspace = WorkspaceConfig::new(
20628 WorkspaceId::new("primary").unwrap(),
20629 std::env::current_dir().unwrap(),
20630 )
20631 .unwrap();
20632 let shared = NodeShared::new_with_incarnation(
20633 handle,
20634 "fixture-token".to_owned(),
20635 NodeId::new("node-terminal-test").unwrap(),
20636 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20637 vec![workspace],
20638 vec![agent("claude")],
20639 ProviderRuntimeStatuses::default(),
20640 None,
20641 Vec::new(),
20642 Vec::new(),
20643 spawn_profiles,
20644 Some(control),
20645 None,
20646 None,
20647 None,
20648 Vec::new(),
20649 Vec::new(),
20650 Vec::new(),
20651 Vec::new(),
20652 None,
20653 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20654 );
20655 shared
20656 .environment_profiles
20657 .write()
20658 .unwrap_or_else(|poisoned| poisoned.into_inner())
20659 .insert(binding.id.clone(), binding);
20660 (
20661 shared,
20662 runtime,
20663 ResolvedEnvironmentProfileReceipt {
20664 profile_id,
20665 profile_revision,
20666 network_allowlist: None,
20667 browser_profile_id: None,
20668 },
20669 )
20670 }
20671
20672 fn materialization_test_shared(
20673 deny_secret: bool,
20674 ) -> (
20675 NodeShared,
20676 NativeRuntime,
20677 ResolvedEnvironmentProfileReceipt,
20678 PathBuf,
20679 Arc<AtomicBool>,
20680 ) {
20681 let workspace = WorkspaceConfig::new(
20682 WorkspaceId::new("primary").unwrap(),
20683 std::env::current_dir().unwrap(),
20684 )
20685 .unwrap();
20686 materialization_test_shared_with_workspace(deny_secret, workspace)
20687 }
20688
20689 fn materialization_test_shared_with_workspace(
20690 deny_secret: bool,
20691 workspace: WorkspaceConfig,
20692 ) -> (
20693 NodeShared,
20694 NativeRuntime,
20695 ResolvedEnvironmentProfileReceipt,
20696 PathBuf,
20697 Arc<AtomicBool>,
20698 ) {
20699 let root = temporary_workspace_root(if deny_secret {
20700 "materialization-denied"
20701 } else {
20702 "materialization-ready"
20703 });
20704 std::fs::create_dir_all(&root).unwrap();
20705 let materialization_root = root.join("session-environments");
20706 let deny = Arc::new(AtomicBool::new(deny_secret));
20707 let resolver = Arc::new(FixtureSecretResolver {
20708 deny: Arc::clone(&deny),
20709 });
20710 let materializer = SessionEnvironmentMaterializer::new(
20711 materialization_root,
20712 resolver,
20713 )
20714 .unwrap();
20715 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20716 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20717 "materialized-claude-r1",
20718 )
20719 .unwrap();
20720 let materialization = NodeSessionMaterializationProfile::new(
20721 vec![NodeSessionEnvironmentMutation::SetSecret {
20722 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20723 reference: NodeSecretReference::new("fixture-token").unwrap(),
20724 }],
20725 vec![NodeSessionPathBinding::new(
20726 "GATE4AGENT_PROVIDER_HOME",
20727 NodeSessionPathClass::ProviderHome,
20728 )
20729 .unwrap()],
20730 vec![NodeSessionFile::secret(
20731 NodeSessionPathClass::Config,
20732 "auth/token",
20733 NodeSecretReference::new("fixture-token").unwrap(),
20734 )
20735 .unwrap()],
20736 )
20737 .unwrap();
20738 let catalog = active_registry().unwrap();
20739 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20740 let control = runtime.native_launch_profile_control();
20741 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20742 profile_id.clone(),
20743 profile_revision.clone(),
20744 agent("claude"),
20745 [NativeLaunchProfile::new(
20746 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20747 agent("claude"),
20748 TransportKind::Pty,
20749 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20750 Arc::new(EmptyEnvironmentResolver),
20751 )
20752 .unwrap()],
20753 Some(materialization),
20754 )
20755 .unwrap();
20756 let (binding, native_profiles, materialization) = node_profile.into_parts();
20757 for native_profile in native_profiles {
20758 runtime.upsert_native_launch_profile(native_profile).unwrap();
20759 }
20760 let shared = NodeShared::new_with_incarnation(
20761 handle,
20762 "fixture-token".to_owned(),
20763 NodeId::new("node-materialization-test").unwrap(),
20764 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20765 vec![workspace],
20766 vec![agent("claude")],
20767 ProviderRuntimeStatuses::default(),
20768 None,
20769 Vec::new(),
20770 Vec::new(),
20771 SpawnProfileRegistry::default(),
20772 Some(control),
20773 None,
20774 Some(materializer),
20775 None,
20776 Vec::new(),
20777 Vec::new(),
20778 Vec::new(),
20779 Vec::new(),
20780 None,
20781 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20782 );
20783 shared
20784 .environment_profiles
20785 .write()
20786 .unwrap_or_else(|poisoned| poisoned.into_inner())
20787 .insert(binding.id.clone(), binding);
20788 shared
20789 .environment_materialization_profiles
20790 .write()
20791 .unwrap_or_else(|poisoned| poisoned.into_inner())
20792 .insert(profile_id.clone(), materialization.unwrap());
20793 (
20794 shared,
20795 runtime,
20796 ResolvedEnvironmentProfileReceipt {
20797 profile_id,
20798 profile_revision,
20799 network_allowlist: None,
20800 browser_profile_id: None,
20801 },
20802 root,
20803 deny,
20804 )
20805 }
20806
20807 #[cfg(feature = "fixture")]
20808 #[derive(Clone, Copy)]
20809 enum CodexHomeProfile {
20810 Missing,
20811 WrongClass,
20812 Exact,
20813 }
20814
20815 #[cfg(feature = "fixture")]
20816 fn codex_bundle_test_shared(
20817 home_profile: CodexHomeProfile,
20818 ) -> (
20819 NodeShared,
20820 NativeRuntime,
20821 ResolvedEnvironmentProfileReceipt,
20822 NodeBundle,
20823 PathBuf,
20824 ) {
20825 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20826 const CLAUDE_MANIFEST: &[u8] =
20827 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20828 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20829 const DIGEST: &str =
20830 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20831
20832 let (shared, mut runtime, _claude_receipt, root, _deny) =
20833 materialization_test_shared(false);
20834 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20835 CodexHomeProfile::Missing => "codex-home-missing",
20836 CodexHomeProfile::WrongClass => "codex-home-wrong",
20837 CodexHomeProfile::Exact => "codex-home-exact",
20838 })
20839 .unwrap();
20840 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20841 "codex-home-r1",
20842 )
20843 .unwrap();
20844 let materialization = match home_profile {
20845 CodexHomeProfile::Missing => None,
20846 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20847 Some(NodeSessionMaterializationProfile::new(
20848 Vec::new(),
20849 vec![NodeSessionPathBinding::new(
20850 "CODEX_HOME",
20851 if matches!(home_profile, CodexHomeProfile::Exact) {
20852 NodeSessionPathClass::ProviderHome
20853 } else {
20854 NodeSessionPathClass::Config
20855 },
20856 )
20857 .unwrap()],
20858 Vec::new(),
20859 )
20860 .unwrap())
20861 }
20862 };
20863 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20864 profile_id.clone(),
20865 profile_revision.clone(),
20866 agent("codex"),
20867 [NativeLaunchProfile::new(
20868 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20869 agent("codex"),
20870 TransportKind::Pty,
20871 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20872 Arc::new(EmptyEnvironmentResolver),
20873 )
20874 .unwrap()],
20875 materialization,
20876 )
20877 .unwrap();
20878 let (binding, native_profiles, materialization) = node_profile.into_parts();
20879 for native_profile in native_profiles {
20880 runtime.upsert_native_launch_profile(native_profile).unwrap();
20881 }
20882 shared
20883 .environment_profiles
20884 .write()
20885 .unwrap_or_else(|poisoned| poisoned.into_inner())
20886 .insert(binding.id.clone(), binding);
20887 if let Some(materialization) = materialization {
20888 shared
20889 .environment_materialization_profiles
20890 .write()
20891 .unwrap_or_else(|poisoned| poisoned.into_inner())
20892 .insert(profile_id.clone(), materialization);
20893 }
20894
20895 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20896 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20897 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20898 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20899 std::fs::write(
20900 bundle_source.join(".claude-plugin/plugin.json"),
20901 CLAUDE_MANIFEST,
20902 )
20903 .unwrap();
20904 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20905 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20906 let bundle = NodeBundle::new(
20907 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20908 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20909 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20910 &bundle_source,
20911 )
20912 .unwrap();
20913 *shared
20914 .bundle_catalog
20915 .write()
20916 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20917 BundleCatalog::new([bundle.clone()]).unwrap();
20918 (
20919 shared,
20920 runtime,
20921 ResolvedEnvironmentProfileReceipt {
20922 profile_id,
20923 profile_revision,
20924 network_allowlist: None,
20925 browser_profile_id: None,
20926 },
20927 bundle,
20928 root,
20929 )
20930 }
20931
20932 #[cfg(feature = "fixture")]
20933 #[test]
20934 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20935 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20936 let (shared, runtime, receipt, bundle, root) =
20937 codex_bundle_test_shared(home_profile);
20938 let error = shared
20939 .bundle_layout(
20940 &agent("codex"),
20941 SessionMode::Pty,
20942 Some(&receipt),
20943 &bundle,
20944 )
20945 .unwrap_err();
20946 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20947 assert!(shared
20948 .materializations
20949 .lock()
20950 .unwrap_or_else(|poisoned| poisoned.into_inner())
20951 .is_empty());
20952 let materialization_root = root.join("session-environments");
20953 assert!(!std::fs::read_dir(&materialization_root)
20954 .unwrap()
20955 .filter_map(Result::ok)
20956 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20957 drop(shared);
20958 drop(runtime);
20959 let _ = std::fs::remove_dir_all(root);
20960 }
20961 }
20962
20963 #[cfg(feature = "fixture")]
20964 #[test]
20965 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20966 let (shared, runtime, receipt, bundle, root) =
20967 codex_bundle_test_shared(CodexHomeProfile::Exact);
20968 let address = SessionAddress {
20969 workspace_id: WorkspaceId::new("primary").unwrap(),
20970 session: SessionKey {
20971 instance_id: AgentInstanceId(310),
20972 generation: SessionGeneration(1),
20973 },
20974 };
20975 let (overlay, guard) = shared
20976 .prepare_session_materialization(
20977 &address,
20978 &agent("codex"),
20979 SessionMode::Pty,
20980 Some(&receipt),
20981 Some(&bundle.receipt()),
20982 None,
20983 None,
20984 )
20985 .unwrap()
20986 .unwrap();
20987 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20988 let ownership = shared
20989 .materializations
20990 .lock()
20991 .unwrap_or_else(|poisoned| poisoned.into_inner())
20992 .get(guard.id().unwrap())
20993 .unwrap()
20994 .clone();
20995 let (profile, _, _) = shared
20996 .materialization_profile(
20997 &agent("codex"),
20998 SessionMode::Pty,
20999 Some(&receipt),
21000 Some(&bundle.receipt()),
21001 None,
21002 )
21003 .unwrap()
21004 .unwrap();
21005 let environment = shared
21006 .session_environment_materializer
21007 .as_ref()
21008 .unwrap()
21009 .resolve_environment(&ownership, &profile)
21010 .unwrap();
21011 assert!(environment.iter().any(|mutation| {
21012 mutation.key == OsString::from("CODEX_HOME")
21013 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21014 }));
21015 assert_eq!(
21016 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21017 .unwrap(),
21018 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21019 );
21020 assert!(std::fs::read_dir(ownership.bundle_root())
21021 .unwrap()
21022 .next()
21023 .is_none());
21024 drop(guard);
21025 drop(shared);
21026 drop(runtime);
21027 let _ = std::fs::remove_dir_all(root);
21028 }
21029
21030 #[cfg(feature = "fixture")]
21031 #[test]
21032 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21033 let (shared, runtime, receipt, bundle, root) =
21034 codex_bundle_test_shared(CodexHomeProfile::Exact);
21035 let bundle_receipt = bundle.receipt();
21036 let address = SessionAddress {
21037 workspace_id: WorkspaceId::new("primary").unwrap(),
21038 session: SessionKey {
21039 instance_id: AgentInstanceId(311),
21040 generation: SessionGeneration(1),
21041 },
21042 };
21043 let (_overlay, guard) = shared
21044 .prepare_session_materialization(
21045 &address,
21046 &agent("codex"),
21047 SessionMode::Pty,
21048 Some(&receipt),
21049 Some(&bundle_receipt),
21050 None,
21051 None,
21052 )
21053 .unwrap()
21054 .unwrap();
21055 let materialization_id = guard.id().unwrap().clone();
21056 let skill_path = shared
21057 .materializations
21058 .lock()
21059 .unwrap_or_else(|poisoned| poisoned.into_inner())
21060 .get(&materialization_id)
21061 .unwrap()
21062 .provider_home()
21063 .join("skills/review-code/SKILL.md");
21064 let record_id = shared
21065 .bind_spawn_session_with_materialization(
21066 &address,
21067 agent("codex"),
21068 SessionMode::Pty,
21069 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21070 SpawnRecordPolicy::ProviderIdentityOnly,
21071 Some(receipt.clone()),
21072 Some(bundle_receipt.clone()),
21073 None,
21074 Some(materialization_id.clone()),
21075 )
21076 .unwrap()
21077 .unwrap();
21078 guard.retain();
21079 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21080
21081 let error = match shared.resolve_record_materialization(
21082 &record_id,
21083 &agent("codex"),
21084 SessionMode::Pty,
21085 Some(&receipt),
21086 Some(&bundle_receipt),
21087 None,
21088 ) {
21089 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21090 Err(error) => error,
21091 };
21092 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21093 assert_eq!(
21094 shared
21095 .materializations
21096 .lock()
21097 .unwrap_or_else(|poisoned| poisoned.into_inner())
21098 .get(&materialization_id)
21099 .unwrap()
21100 .state(),
21101 MaterializationState::RecoveryRequired,
21102 );
21103 drop(shared);
21104 drop(runtime);
21105 let _ = std::fs::remove_dir_all(root);
21106 }
21107
21108 fn bind_record_owned_materialization_fixture(
21109 shared: &NodeShared,
21110 receipt: &ResolvedEnvironmentProfileReceipt,
21111 instance_id: u64,
21112 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21113 let address = SessionAddress {
21114 workspace_id: WorkspaceId::new("primary").unwrap(),
21115 session: SessionKey {
21116 instance_id: AgentInstanceId(instance_id),
21117 generation: SessionGeneration(1),
21118 },
21119 };
21120 let (overlay, materialization_guard) = shared
21121 .prepare_session_materialization(
21122 &address,
21123 &agent("claude"),
21124 SessionMode::Pty,
21125 Some(receipt),
21126 None,
21127 None,
21128 None,
21129 )
21130 .unwrap()
21131 .unwrap();
21132 let materialization_id = materialization_guard.id().unwrap().clone();
21133 let materialization_root = shared
21134 .materializations
21135 .lock()
21136 .unwrap_or_else(|poisoned| poisoned.into_inner())
21137 .get(&materialization_id)
21138 .unwrap()
21139 .root()
21140 .to_path_buf();
21141 let selection = shared
21142 .select_environment_profile(
21143 address.session.instance_id,
21144 &agent("claude"),
21145 SessionMode::Pty,
21146 Some(receipt),
21147 )
21148 .unwrap()
21149 .unwrap();
21150 assert!(shared
21151 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21152 .unwrap()
21153 .is_none());
21154 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21155 let record_id = shared
21156 .bind_spawn_session_with_materialization(
21157 &address,
21158 agent("claude"),
21159 SessionMode::Pty,
21160 policy,
21161 SpawnRecordPolicy::ProviderIdentityOnly,
21162 Some(receipt.clone()),
21163 None,
21164 None,
21165 Some(materialization_id.clone()),
21166 )
21167 .unwrap()
21168 .unwrap();
21169 selection.retain();
21170 materialization_guard.retain();
21171 (address, record_id, materialization_id, materialization_root)
21172 }
21173
21174 #[test]
21175 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21176 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21177 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21178 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21179 }));
21180 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21181 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21182 let mut spec = spawn_spec_fixture();
21183 spec.expected_profile_revision =
21184 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21185 assert!(matches!(
21186 &spec.overrides.environment_profile_id,
21187 crate::protocol::SpawnOverride::Inherit,
21188 ));
21189 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21190 .requires_child_environment_profile_capability());
21191 assert!(request_requires_child_environment_profile(
21192 &shared,
21193 &NodeRequest::SpawnSpec { spec: spec.clone() },
21194 ));
21195
21196 let address = terminal_address(1);
21197 shared.bind_session_with_policy(
21198 &address,
21199 ProviderRuntimePolicy::raw_pty(),
21200 Some(environment_profile.clone()),
21201 );
21202 assert!(request_requires_child_environment_profile(
21203 &shared,
21204 &NodeRequest::Input {
21205 session: address.clone(),
21206 text: "x".to_owned(),
21207 },
21208 ));
21209
21210 let mut record = record("claude", "profile-record");
21211 record.active_session = Some(address.clone());
21212 record.environment_profile = Some(environment_profile.clone());
21213 let record_id = record.record_id.clone();
21214 shared.insert_record(record.clone()).unwrap();
21215 assert!(request_requires_child_environment_profile(
21216 &shared,
21217 &NodeRequest::ResumeSessionRecord {
21218 record_id,
21219 terminal_size: gate4agent_types::TerminalSize {
21220 rows: 24,
21221 columns: 80,
21222 },
21223 initial_prompt: None,
21224 },
21225 ));
21226
21227 let snapshot = snapshot_for_wire(
21228 &shared, false, true, true, true, false, false, false, false, true,
21229 );
21230 assert_eq!(snapshot.session_records.len(), 1);
21231 assert!(snapshot.session_records[0].environment_profile.is_none());
21232 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21233 sequence: 1,
21234 event: NodeEvent::SessionRecordUpserted { record },
21235 });
21236 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21237 panic!("record projection changed the event kind");
21238 };
21239 assert!(record.environment_profile.is_none());
21240
21241 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21242 let mut reply = ResponseEnvelope {
21243 request_id: 1,
21244 result: Ok(NodeResponse::SpawnSpecAccepted {
21245 receipt: resolved.receipt_with_environment(
21246 shared.incarnation_id,
21247 address,
21248 Some(environment_profile),
21249 ),
21250 }),
21251 };
21252 project_response_without_child_environment_profile(&mut reply);
21253 assert_eq!(
21254 reply.result.unwrap_err().code,
21255 NodeFailureCode::UnsupportedCapability,
21256 );
21257 }
21258
21259 #[test]
21260 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21261 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21262 let snapshot = shared.snapshot();
21263 let profiles = snapshot
21264 .launch_inventory
21265 .as_ref()
21266 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21267 .unwrap();
21268 assert_eq!(profiles.len(), 1);
21269 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21270 assert!(snapshot.requires_child_environment_profile_capability());
21271
21272 let mut legacy = snapshot.clone();
21273 clear_snapshot_child_environment_profiles(&mut legacy);
21274 assert!(legacy
21275 .launch_inventory
21276 .as_ref()
21277 .unwrap()
21278 .spawn_profiles
21279 .as_ref()
21280 .unwrap()[0]
21281 .environment_profile
21282 .is_none());
21283 assert!(!legacy.requires_child_environment_profile_capability());
21284
21285 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21286 let bindings = shared
21287 .environment_profiles
21288 .read()
21289 .unwrap_or_else(|poisoned| poisoned.into_inner())
21290 .clone();
21291 let mut provider_mismatch = bindings.clone();
21292 provider_mismatch
21293 .values_mut()
21294 .next()
21295 .unwrap()
21296 .provider = agent("codex");
21297 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21298 let mut mode_mismatch = profile;
21299 mode_mismatch.mode = SessionMode::Inline;
21300 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21301
21302 shared
21303 .environment_profiles
21304 .write()
21305 .unwrap_or_else(|poisoned| poisoned.into_inner())
21306 .clear();
21307 assert!(shared
21308 .snapshot()
21309 .launch_inventory
21310 .unwrap()
21311 .spawn_profiles
21312 .unwrap()
21313 .is_empty());
21314 }
21315
21316 #[test]
21317 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21318 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21319 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21320 }));
21321 let shared = terminal_test_shared();
21322 let bundle = ResolvedBundleReceipt {
21323 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21324 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21325 digest: crate::protocol::SpawnBundleDigest::new(format!(
21326 "sha256:{}",
21327 "0".repeat(64),
21328 ))
21329 .unwrap(),
21330 };
21331 let mut bundled = record("claude", "bundle-record");
21332 bundled.bundle = Some(bundle.clone());
21333 let record_id = bundled.record_id.clone();
21334 shared.insert_record(bundled.clone()).unwrap();
21335 assert!(request_requires_session_bundle(
21336 &shared,
21337 &NodeRequest::ForgetSessionRecord {
21338 record_id: record_id.clone(),
21339 },
21340 ));
21341
21342 let snapshot = snapshot_for_wire(
21343 &shared, false, true, true, true, true, false, false, false, true,
21344 );
21345 assert!(snapshot.session_records[0].bundle.is_none());
21346 let launch = snapshot.launch_inventory.as_ref().unwrap();
21347 assert!(launch.spawn_profiles.is_some());
21348 assert!(launch.bundles.is_none());
21349 let event = project_event_without_session_bundle(NodeEventEnvelope {
21350 sequence: 1,
21351 event: NodeEvent::SessionRecordUpserted { record: bundled },
21352 });
21353 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21354 panic!("record projection changed the event kind");
21355 };
21356 assert!(record.bundle.is_none());
21357
21358 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21359 let mut reply = ResponseEnvelope {
21360 request_id: 1,
21361 result: Ok(NodeResponse::SpawnSpecAccepted {
21362 receipt: resolved.receipt_with_materialization(
21363 shared.incarnation_id,
21364 terminal_address(17),
21365 None,
21366 Some(bundle),
21367 None,
21368 ),
21369 }),
21370 };
21371 project_response_without_session_bundle(&mut reply);
21372 assert_eq!(
21373 reply.result.unwrap_err().code,
21374 NodeFailureCode::UnsupportedCapability,
21375 );
21376 }
21377
21378 #[test]
21379 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21380 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21381 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21382 }));
21383 let context_id = SpawnContextId::new("context-review").unwrap();
21384 let context = ResolvedContextPackReceipt {
21385 id: context_id.clone(),
21386 digest: crate::protocol::SpawnContextDigest::new(format!(
21387 "sha256:{}",
21388 "c".repeat(64),
21389 ))
21390 .unwrap(),
21391 lineage: ContextPackLineageReceipt {
21392 source_node_id: NodeId::new("node-source").unwrap(),
21393 source_session: terminal_address(1),
21394 source_provider: agent("kimi"),
21395 },
21396 source_message_count: 2,
21397 retained_message_count: 2,
21398 byte_len: 256,
21399 truncated: false,
21400 };
21401 assert!(context.is_valid());
21402 let mut bound_record = record("codex", "context-record");
21403 bound_record.context_id = Some(context_id);
21404 bound_record.context = Some(context.clone());
21405 let session = gate4agent_types::SessionSnapshot {
21406 instance_id: AgentInstanceId(91),
21407 agent_id: agent("kimi"),
21408 transport: TransportKind::Pty,
21409 generation: SessionGeneration(3),
21410 status: SessionStatus::Running,
21411 pending_operation: None,
21412 pending_input: None,
21413 process_id: Some(9001),
21414 terminal_size: None,
21415 terminal_frame: None,
21416 terminal_stale: None,
21417 session_options: None,
21418 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21419 history: gate4agent_types::HistorySnapshot {
21420 pending: None,
21421 candidates: vec![HistoryCandidateSummary {
21422 id: "candidate-opaque".to_owned(),
21423 session_id_hint: "session-hint".to_owned(),
21424 modified_at_unix_ms: Some(1),
21425 }],
21426 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21427 loaded: Some(HistorySessionRecord {
21428 session_id: "private-vendor-session".to_owned(),
21429 title: Some("private title".to_owned()),
21430 cwd: Some(r"C:\private\history-root".to_owned()),
21431 model: Some("private-model".to_owned()),
21432 message_count: 2,
21433 completed_turn_count: None,
21434 total_tokens: 19,
21435 messages: vec![
21436 gate4agent_types::HistoryMessageRecord {
21437 role: gate4agent_types::HistoryMessageRole::User,
21438 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21439 },
21440 gate4agent_types::HistoryMessageRecord {
21441 role: gate4agent_types::HistoryMessageRole::Assistant,
21442 text: "private answer".to_owned(),
21443 },
21444 ],
21445 }),
21446 last_error: None,
21447 },
21448 resume: gate4agent_types::ResumeSnapshot::default(),
21449 foreground: gate4agent_types::ForegroundSnapshot::default(),
21450 provider: gate4agent_types::ProviderSnapshot::default(),
21451 screen_state: gate4agent_types::PtyScreenState::default(),
21452 };
21453 let original = NodeSnapshot {
21454 node_id: NodeId::new("node-terminal-test").unwrap(),
21455 enabled_providers: vec![agent("kimi"), agent("codex")],
21456 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21457 workspaces: vec![WorkspaceSnapshot {
21458 workspace_id: WorkspaceId::new("primary").unwrap(),
21459 canonical_root: opaque_windows_path(
21460 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21461 ),
21462 sessions: vec![session],
21463 worktree_service_mode: None,
21464 managed_worktree_profiles: None,
21465 }],
21466 session_records: vec![bound_record],
21467 managed_worktrees: Vec::new(),
21468 launch_inventory: None,
21469 agent_progress: Vec::new(),
21470 };
21471
21472 let mut negotiated = original.clone();
21473 project_snapshot_history_for_wire(&mut negotiated, true);
21474 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21475 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21476 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21477 assert!(!serde_json::to_string(&negotiated)
21478 .unwrap()
21479 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21480
21481 let mut legacy = original;
21482 project_snapshot_history_for_wire(&mut legacy, false);
21483 clear_snapshot_context_packs(&mut legacy);
21484 assert_eq!(
21485 legacy.workspaces[0].sessions[0].history,
21486 gate4agent_types::HistorySnapshot::default(),
21487 );
21488 assert!(legacy.session_records[0].context_id.is_none());
21489 assert!(legacy.session_records[0].context.is_none());
21490 let encoded = serde_json::to_string(&legacy).unwrap();
21491 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21492 assert!(!encoded.contains("context-review"));
21493
21494 let mut reply = ResponseEnvelope {
21495 request_id: 7,
21496 result: Ok(NodeResponse::ContextPackExported { context }),
21497 };
21498 project_response_without_context_pack(&mut reply);
21499 assert_eq!(
21500 reply.result.unwrap_err().code,
21501 NodeFailureCode::UnsupportedCapability,
21502 );
21503 }
21504
21505 #[test]
21506 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21507 let shared = terminal_test_shared();
21508 let history = HistorySessionRecord {
21509 session_id: "fixture-history".to_owned(),
21510 title: None,
21511 cwd: None,
21512 model: None,
21513 message_count: 1,
21514 completed_turn_count: None,
21515 total_tokens: 0,
21516 messages: vec![gate4agent_types::HistoryMessageRecord {
21517 role: gate4agent_types::HistoryMessageRole::User,
21518 text: "bounded handoff".to_owned(),
21519 }],
21520 };
21521 let pack = NodeContextPack::export(
21522 ContextPackLineageReceipt {
21523 source_node_id: shared.node_id.clone(),
21524 source_session: terminal_address(1),
21525 source_provider: agent("claude"),
21526 },
21527 &history,
21528 )
21529 .unwrap();
21530 let context = shared
21531 .context_catalog
21532 .write()
21533 .unwrap_or_else(|poisoned| poisoned.into_inner())
21534 .insert(pack)
21535 .unwrap();
21536 let mut retained = record("codex", "context-retained-record");
21537 retained.context_id = Some(context.id.clone());
21538 retained.context = Some(context.clone());
21539 let record_id = retained.record_id.clone();
21540 shared.insert_record(retained).unwrap();
21541 assert_eq!(
21542 shared.forget_context_pack(&context.id).unwrap_err().code,
21543 NodeFailureCode::ContextPackBusy,
21544 );
21545 assert!(shared
21546 .context_catalog
21547 .read()
21548 .unwrap_or_else(|poisoned| poisoned.into_inner())
21549 .get(&context.id)
21550 .is_some());
21551 shared
21552 .session_records
21553 .lock()
21554 .unwrap_or_else(|poisoned| poisoned.into_inner())
21555 .records
21556 .remove(&record_id);
21557
21558 let mut spec = spawn_spec_fixture();
21559 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21560 value: context.id.clone(),
21561 };
21562 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21563 let receipt = resolved.receipt_with_materialization(
21564 shared.incarnation_id,
21565 terminal_address(2),
21566 None,
21567 None,
21568 Some(context.clone()),
21569 );
21570 assert!(receipt.context_binding_is_valid());
21571 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21572
21573 shared.forget_context_pack(&context.id).unwrap();
21574 assert!(shared
21575 .context_catalog
21576 .read()
21577 .unwrap_or_else(|poisoned| poisoned.into_inner())
21578 .get(&context.id)
21579 .is_none());
21580 assert!(shared
21581 .replay_spawn_spec(&spec, Instant::now())
21582 .unwrap()
21583 .is_none());
21584 }
21585
21586 #[test]
21587 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21588 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21589 let address = terminal_address(1);
21590 let selection = shared
21591 .select_environment_profile(
21592 address.session.instance_id,
21593 &agent("claude"),
21594 SessionMode::Pty,
21595 Some(&environment_profile),
21596 )
21597 .unwrap()
21598 .unwrap();
21599 shared.bind_session_with_policy(
21600 &address,
21601 ProviderRuntimePolicy::raw_pty(),
21602 Some(environment_profile.clone()),
21603 );
21604 selection.retain();
21605 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21606 assert!(matches!(
21607 runtime.remove_native_launch_profile(&native_id),
21608 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21609 ));
21610 assert!(shared.remove_binding(&address).is_some());
21611 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21612
21613 let mismatched = ResolvedEnvironmentProfileReceipt {
21614 profile_id: environment_profile.profile_id,
21615 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21616 "local-claude-r2",
21617 )
21618 .unwrap(),
21619 network_allowlist: None,
21620 browser_profile_id: None,
21621 };
21622 let error = match shared.select_environment_profile(
21623 AgentInstanceId(42),
21624 &agent("claude"),
21625 SessionMode::Pty,
21626 Some(&mismatched),
21627 ) {
21628 Ok(_) => panic!("mismatched environment profile revision was selected"),
21629 Err(error) => error,
21630 };
21631 assert_eq!(
21632 error.code,
21633 NodeFailureCode::EnvironmentProfileBindingMismatch,
21634 );
21635 assert!(!shared
21636 .session_bindings
21637 .lock()
21638 .unwrap_or_else(|poisoned| poisoned.into_inner())
21639 .contains_key(&AgentInstanceId(42)));
21640 }
21641
21642 #[test]
21643 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21644 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21645 let address = terminal_address(1);
21646 let selection = shared
21647 .select_environment_profile(
21648 address.session.instance_id,
21649 &agent("claude"),
21650 SessionMode::Pty,
21651 Some(&environment_profile),
21652 )
21653 .unwrap()
21654 .unwrap();
21655 let overlay = NativeInstanceLaunchOverlay::new(
21656 agent("claude"),
21657 TransportKind::Pty,
21658 vec![EnvMutation {
21659 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21660 value: Some(OsString::from("fixture-secret")),
21661 }],
21662 vec![
21663 OsString::from("--plugin-dir"),
21664 std::env::current_dir().unwrap().into_os_string(),
21665 ],
21666 )
21667 .unwrap();
21668 let instance_overlay = shared
21669 .install_prepared_launch_overlay(
21670 address.session.instance_id,
21671 PreparedNativeLaunchOverlay::Instance(overlay),
21672 )
21673 .unwrap()
21674 .unwrap();
21675 let bundle = ResolvedBundleReceipt {
21676 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21677 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21678 digest: crate::protocol::SpawnBundleDigest::new(format!(
21679 "sha256:{}",
21680 "0".repeat(64),
21681 ))
21682 .unwrap(),
21683 };
21684 shared.bind_session_with_materialization(
21685 &address,
21686 ProviderRuntimePolicy::raw_pty(),
21687 Some(environment_profile),
21688 Some(bundle),
21689 None,
21690 None,
21691 );
21692 selection.retain();
21693 instance_overlay.retain();
21694
21695 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21696 assert!(matches!(
21697 runtime.remove_native_launch_profile(&native_id),
21698 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21699 ));
21700 assert!(shared.remove_binding(&address).is_some());
21701 assert!(!shared
21702 .native_launch_profile_control
21703 .as_ref()
21704 .unwrap()
21705 .clear_native_instance_launch_overlay(address.session.instance_id));
21706 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21707 }
21708
21709 #[test]
21710 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21711 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21712 let address = SessionAddress {
21713 workspace_id: WorkspaceId::new("primary").unwrap(),
21714 session: SessionKey {
21715 instance_id: AgentInstanceId(201),
21716 generation: SessionGeneration(1),
21717 },
21718 };
21719 let error = match shared.prepare_session_materialization(
21720 &address,
21721 &agent("claude"),
21722 SessionMode::Pty,
21723 Some(&receipt),
21724 None,
21725 None,
21726 None,
21727 ) {
21728 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21729 Err(error) => error,
21730 };
21731 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21732 assert!(shared.handle.snapshot().sessions.is_empty());
21733 assert!(shared
21734 .session_bindings
21735 .lock()
21736 .unwrap_or_else(|poisoned| poisoned.into_inner())
21737 .is_empty());
21738 assert!(shared
21739 .materializations
21740 .lock()
21741 .unwrap_or_else(|poisoned| poisoned.into_inner())
21742 .is_empty());
21743 drop(shared);
21744 drop(runtime);
21745 std::fs::remove_dir_all(root).unwrap();
21746 }
21747
21748 #[test]
21749 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21750 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21751 let address = SessionAddress {
21752 workspace_id: WorkspaceId::new("primary").unwrap(),
21753 session: SessionKey {
21754 instance_id: AgentInstanceId(202),
21755 generation: SessionGeneration(1),
21756 },
21757 };
21758 let (overlay, materialization_guard) = shared
21759 .prepare_session_materialization(
21760 &address,
21761 &agent("claude"),
21762 SessionMode::Pty,
21763 Some(&receipt),
21764 None,
21765 None,
21766 None,
21767 )
21768 .unwrap()
21769 .unwrap();
21770 let materialization_id = materialization_guard.id().unwrap().clone();
21771 let materialization_root = shared
21772 .materializations
21773 .lock()
21774 .unwrap_or_else(|poisoned| poisoned.into_inner())
21775 .get(&materialization_id)
21776 .unwrap()
21777 .root()
21778 .to_path_buf();
21779 let selection = shared
21780 .select_environment_profile(
21781 address.session.instance_id,
21782 &agent("claude"),
21783 SessionMode::Pty,
21784 Some(&receipt),
21785 )
21786 .unwrap()
21787 .unwrap();
21788 assert!(shared
21789 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21790 .unwrap()
21791 .is_none());
21792 shared.bind_session_with_materialization(
21793 &address,
21794 ProviderRuntimePolicy::raw_pty(),
21795 Some(receipt),
21796 None,
21797 None,
21798 Some(materialization_id),
21799 );
21800 selection.retain();
21801 materialization_guard.retain();
21802
21803 assert!(materialization_root.is_dir());
21804 assert!(shared
21805 .session_bindings
21806 .lock()
21807 .unwrap_or_else(|poisoned| poisoned.into_inner())
21808 .contains_key(&address.session.instance_id));
21809
21810 let removed = shared.remove_binding(&address).unwrap();
21811 shared
21812 .cleanup_session_owned_materialization(&address, &removed)
21813 .unwrap();
21814 assert!(!materialization_root.exists());
21815 assert!(shared
21816 .materializations
21817 .lock()
21818 .unwrap_or_else(|poisoned| poisoned.into_inner())
21819 .is_empty());
21820 drop(shared);
21821 drop(runtime);
21822 std::fs::remove_dir_all(root).unwrap();
21823 }
21824
21825 #[tokio::test]
21826 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21827 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21828 let address = SessionAddress {
21829 workspace_id: WorkspaceId::new("primary").unwrap(),
21830 session: SessionKey {
21831 instance_id: AgentInstanceId(203),
21832 generation: SessionGeneration(1),
21833 },
21834 };
21835 let (overlay, materialization_guard) = shared
21836 .prepare_session_materialization(
21837 &address,
21838 &agent("claude"),
21839 SessionMode::Pty,
21840 Some(&receipt),
21841 None,
21842 None,
21843 None,
21844 )
21845 .unwrap()
21846 .unwrap();
21847 let materialization_id = materialization_guard.id().unwrap().clone();
21848 let materialization_root = shared
21849 .materializations
21850 .lock()
21851 .unwrap_or_else(|poisoned| poisoned.into_inner())
21852 .get(&materialization_id)
21853 .unwrap()
21854 .root()
21855 .to_path_buf();
21856 let selection = shared
21857 .select_environment_profile(
21858 address.session.instance_id,
21859 &agent("claude"),
21860 SessionMode::Pty,
21861 Some(&receipt),
21862 )
21863 .unwrap()
21864 .unwrap();
21865 assert!(shared
21866 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21867 .unwrap()
21868 .is_none());
21869 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21870 let record_id = shared
21871 .bind_spawn_session_with_materialization(
21872 &address,
21873 agent("claude"),
21874 SessionMode::Pty,
21875 policy,
21876 SpawnRecordPolicy::ProviderIdentityOnly,
21877 Some(receipt.clone()),
21878 None,
21879 None,
21880 Some(materialization_id.clone()),
21881 )
21882 .unwrap()
21883 .unwrap();
21884 selection.retain();
21885 materialization_guard.retain();
21886 assert_eq!(
21887 shared
21888 .materializations
21889 .lock()
21890 .unwrap_or_else(|poisoned| poisoned.into_inner())
21891 .get(&materialization_id)
21892 .unwrap()
21893 .owner(),
21894 &MaterializationOwner::Record {
21895 record_id: record_id.clone(),
21896 },
21897 );
21898
21899 let removed = shared.remove_binding(&address).unwrap();
21900 shared
21901 .cleanup_session_owned_materialization(&address, &removed)
21902 .unwrap();
21903 assert!(materialization_root.is_dir());
21904 assert!(shared
21905 .resolve_record_materialization(
21906 &record_id,
21907 &agent("claude"),
21908 SessionMode::Pty,
21909 Some(&receipt),
21910 None,
21911 None,
21912 )
21913 .unwrap()
21914 .is_some());
21915 {
21916 let mut records = shared
21917 .session_records
21918 .lock()
21919 .unwrap_or_else(|poisoned| poisoned.into_inner());
21920 let record = records.records.get_mut(&record_id).unwrap();
21921 record.active_session = None;
21922 record.state = ManagedSessionState::Unavailable;
21923 }
21924 shared.forget_session_record(&record_id).await.unwrap();
21925 assert!(!materialization_root.exists());
21926 assert!(shared
21927 .materializations
21928 .lock()
21929 .unwrap_or_else(|poisoned| poisoned.into_inner())
21930 .is_empty());
21931 drop(shared);
21932 drop(runtime);
21933 std::fs::remove_dir_all(root).unwrap();
21934 }
21935
21936 #[test]
21937 fn record_materialization_revalidation_failure_persists_recovery_required() {
21938 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21939 let state_path = root.join("node-state-v6.json");
21940 shared.state_path = Some(state_path.clone());
21941 let (_address, _record_id, materialization_id, materialization_root) =
21942 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21943 std::fs::write(
21944 materialization_root.join(".gate4agent-materialization-owner"),
21945 b"tampered",
21946 )
21947 .unwrap();
21948
21949 let error = match shared.resolve_record_materialization(
21950 &_record_id,
21951 &agent("claude"),
21952 SessionMode::Pty,
21953 Some(&receipt),
21954 None,
21955 None,
21956 ) {
21957 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21958 Err(error) => error,
21959 };
21960 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21961 assert_eq!(
21962 shared
21963 .materializations
21964 .lock()
21965 .unwrap_or_else(|poisoned| poisoned.into_inner())
21966 .get(&materialization_id)
21967 .unwrap()
21968 .state(),
21969 MaterializationState::RecoveryRequired,
21970 );
21971 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21972 assert_eq!(loaded.materializations.len(), 1);
21973 assert_eq!(
21974 loaded.materializations[0].state(),
21975 MaterializationState::RecoveryRequired,
21976 );
21977 drop(shared);
21978 drop(runtime);
21979 std::fs::remove_dir_all(root).unwrap();
21980 }
21981
21982 #[test]
21983 fn transient_record_materialization_resolution_failure_remains_ready() {
21984 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21985 let (_address, record_id, materialization_id, _materialization_root) =
21986 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21987 deny.store(true, Ordering::Release);
21988
21989 let error = match shared.resolve_record_materialization(
21990 &record_id,
21991 &agent("claude"),
21992 SessionMode::Pty,
21993 Some(&receipt),
21994 None,
21995 None,
21996 ) {
21997 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21998 Err(error) => error,
21999 };
22000 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22001 assert_eq!(
22002 shared
22003 .materializations
22004 .lock()
22005 .unwrap_or_else(|poisoned| poisoned.into_inner())
22006 .get(&materialization_id)
22007 .unwrap()
22008 .state(),
22009 MaterializationState::Ready,
22010 );
22011 drop(shared);
22012 drop(runtime);
22013 std::fs::remove_dir_all(root).unwrap();
22014 }
22015
22016 #[test]
22017 fn startup_missing_record_materialization_is_not_resumable() {
22018 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22019 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22020 shared
22021 .insert_record(ManagedSessionRecord {
22022 record_id: record_id.clone(),
22023 display_name: "missing materialization".to_owned(),
22024 provider: agent("claude"),
22025 mode: SessionMode::Pty,
22026 state: ManagedSessionState::Dormant,
22027 workspace_id: WorkspaceId::new("primary").unwrap(),
22028 canonical_root: opaque_windows_path(
22029 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22030 ),
22031 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22032 key: gate4agent_types::ProviderSessionKey::SessionId,
22033 id: "provider-session-missing-materialization".to_owned(),
22034 transcript_path: None,
22035 }),
22036 active_session: None,
22037 environment_profile: Some(receipt),
22038 bundle: None,
22039 context_id: None,
22040 context: None,
22041 exported_context: None,
22042 task_binding: None,
22043 created_at_unix_ms: 1,
22044 updated_at_unix_ms: 1,
22045 last_error: None,
22046 })
22047 .unwrap();
22048
22049 shared.reconcile_materializations();
22050 let record = shared.record(&record_id).unwrap();
22051 assert_eq!(record.state, ManagedSessionState::Unavailable);
22052 assert!(record.provider_session.is_none());
22053 assert_eq!(
22054 record.last_error.as_deref(),
22055 Some("environment-profile-unavailable"),
22056 );
22057 drop(shared);
22058 drop(runtime);
22059 std::fs::remove_dir_all(root).unwrap();
22060 }
22061
22062 #[tokio::test]
22063 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22064 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22065 let lease = install_managed_lease(&shared);
22066 let address = SessionAddress {
22067 workspace_id: WorkspaceId::new("primary").unwrap(),
22068 session: SessionKey {
22069 instance_id: AgentInstanceId(204),
22070 generation: SessionGeneration(1),
22071 },
22072 };
22073 let (overlay, materialization_guard) = shared
22074 .prepare_session_materialization(
22075 &address,
22076 &agent("claude"),
22077 SessionMode::Pty,
22078 Some(&receipt),
22079 None,
22080 None,
22081 Some(lease.lease_id.clone()),
22082 )
22083 .unwrap()
22084 .unwrap();
22085 let materialization_id = materialization_guard.id().unwrap().clone();
22086 let materialization_root = shared
22087 .materializations
22088 .lock()
22089 .unwrap_or_else(|poisoned| poisoned.into_inner())
22090 .get(&materialization_id)
22091 .unwrap()
22092 .root()
22093 .to_path_buf();
22094 let selection = shared
22095 .select_environment_profile(
22096 address.session.instance_id,
22097 &agent("claude"),
22098 SessionMode::Pty,
22099 Some(&receipt),
22100 )
22101 .unwrap()
22102 .unwrap();
22103 assert!(shared
22104 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22105 .unwrap()
22106 .is_none());
22107 shared.bind_session_with_materialization(
22108 &address,
22109 ProviderRuntimePolicy::raw_pty(),
22110 Some(receipt),
22111 None,
22112 None,
22113 Some(materialization_id),
22114 );
22115 selection.retain();
22116 materialization_guard.retain();
22117
22118 let blocked = shared
22119 .cleanup_managed_worktree(&lease.lease_id, false)
22120 .await
22121 .unwrap_err();
22122 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22123 assert!(materialization_root.is_dir());
22124 let removed = shared.remove_binding(&address).unwrap();
22125 shared
22126 .cleanup_session_owned_materialization(&address, &removed)
22127 .unwrap();
22128 assert!(!materialization_root.exists());
22129 drop(shared);
22130 drop(runtime);
22131 std::fs::remove_dir_all(root).unwrap();
22132 }
22133
22134 #[tokio::test]
22135 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22136 let git_root = temporary_workspace_root("raw-managed-inventory");
22137 let source = git_root.join("source");
22138 let allocation = git_root.join("allocation");
22139 std::fs::create_dir_all(&source).unwrap();
22140 std::fs::create_dir_all(&allocation).unwrap();
22141 run_test_git(&source, &["init"]);
22142 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22143 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22144 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22145 run_test_git(&source, &["add", "seed.txt"]);
22146 run_test_git(&source, &["commit", "-m", "seed"]);
22147 let profile = ManagedWorktreeProfile::new(
22148 WorktreeProfileId::new("default").unwrap(),
22149 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22150 &allocation,
22151 "gate4agent",
22152 "HEAD",
22153 ManagedWorktreeRetention::RemoveWhenReleased,
22154 )
22155 .unwrap();
22156 let workspace = WorkspaceConfig::new(
22157 WorkspaceId::new("primary").unwrap(),
22158 &source,
22159 )
22160 .unwrap()
22161 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22162 .with_managed_worktree_profile(profile.clone())
22163 .unwrap();
22164 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22165 materialization_test_shared_with_workspace(false, workspace);
22166 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22167 let source_text = source.to_string_lossy().into_owned();
22168 let target_text = target.to_string_lossy().into_owned();
22169 let created = create_git_worktree(
22170 &source_text,
22171 &target_text,
22172 "gate4agent/mw-raw-inventory",
22173 Some("HEAD"),
22174 )
22175 .await
22176 .unwrap();
22177 let lease = ManagedWorktreeLeaseRecord {
22178 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22179 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22180 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22181 profile_id: profile.profile_id().clone(),
22182 profile_revision: profile.revision().clone(),
22183 target_root: created.path.clone(),
22184 branch: "gate4agent/mw-raw-inventory".to_owned(),
22185 base_commit: created.head.clone(),
22186 expected_head: Some(created.head.clone()),
22187 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22188 state: ManagedWorktreeLeaseState::Ready,
22189 session_holders: Vec::new(),
22190 record_holders: Vec::new(),
22191 cleanup_failure: None,
22192 created_at_unix_ms: 1,
22193 updated_at_unix_ms: 1,
22194 };
22195 shared
22196 .workspaces
22197 .write()
22198 .unwrap_or_else(|poisoned| poisoned.into_inner())
22199 .insert(lease.workspace_id.clone(), created.path.clone());
22200 *shared
22201 .managed_worktrees
22202 .lock()
22203 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22204 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22205 let address = SessionAddress {
22206 workspace_id: lease.workspace_id.clone(),
22207 session: SessionKey {
22208 instance_id: AgentInstanceId(205),
22209 generation: SessionGeneration(1),
22210 },
22211 };
22212 let (overlay, materialization_guard) = shared
22213 .prepare_session_materialization(
22214 &address,
22215 &agent("claude"),
22216 SessionMode::Pty,
22217 Some(&receipt),
22218 None,
22219 None,
22220 Some(lease.lease_id.clone()),
22221 )
22222 .unwrap()
22223 .unwrap();
22224 let materialization_id = materialization_guard.id().unwrap().clone();
22225 let materialization_root = shared
22226 .materializations
22227 .lock()
22228 .unwrap_or_else(|poisoned| poisoned.into_inner())
22229 .get(&materialization_id)
22230 .unwrap()
22231 .root()
22232 .to_path_buf();
22233 let selection = shared
22234 .select_environment_profile(
22235 address.session.instance_id,
22236 &agent("claude"),
22237 SessionMode::Pty,
22238 Some(&receipt),
22239 )
22240 .unwrap()
22241 .unwrap();
22242 assert!(shared
22243 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22244 .unwrap()
22245 .is_none());
22246 let record_id = shared
22247 .bind_spawn_session_with_materialization(
22248 &address,
22249 agent("claude"),
22250 SessionMode::Pty,
22251 ProviderRuntimePolicy::raw_pty(),
22252 SpawnRecordPolicy::Always,
22253 Some(receipt.clone()),
22254 None,
22255 None,
22256 Some(materialization_id.clone()),
22257 )
22258 .unwrap()
22259 .unwrap();
22260 selection.retain();
22261 materialization_guard.retain();
22262 let record = shared.record(&record_id).unwrap();
22263 assert_eq!(record.state, ManagedSessionState::Live);
22264 assert!(record.provider_session.is_none());
22265 assert!(record.environment_profile.is_none());
22266 assert!(record.bundle.is_none());
22267 assert!(record.context_id.is_none());
22268 assert!(record.context.is_none());
22269 assert_eq!(record.active_session.as_ref(), Some(&address));
22270 assert_eq!(
22271 shared
22272 .materializations
22273 .lock()
22274 .unwrap_or_else(|poisoned| poisoned.into_inner())
22275 .get(&materialization_id)
22276 .unwrap()
22277 .owner(),
22278 &MaterializationOwner::Session {
22279 incarnation_id: shared.incarnation_id,
22280 instance_id: address.session.instance_id,
22281 generation: address.session.generation,
22282 },
22283 );
22284 {
22285 let mut bindings = shared
22286 .session_bindings
22287 .lock()
22288 .unwrap_or_else(|poisoned| poisoned.into_inner());
22289 bindings
22290 .get_mut(&address.session.instance_id)
22291 .unwrap()
22292 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22293 }
22294 let lease_snapshot = shared
22295 .managed_worktrees
22296 .lock()
22297 .unwrap_or_else(|poisoned| poisoned.into_inner())
22298 .bind_session(
22299 &lease.lease_id,
22300 ManagedWorktreeSessionHolder {
22301 incarnation_id: shared.incarnation_id,
22302 instance_id: address.session.instance_id,
22303 generation: address.session.generation,
22304 },
22305 None,
22306 unix_time_ms(),
22307 )
22308 .unwrap();
22309 assert_eq!(lease_snapshot.active_session_count, 1);
22310 assert_eq!(lease_snapshot.managed_record_count, 0);
22311 shared.persist_state().unwrap();
22312 assert!(shared
22313 .managed_worktrees
22314 .lock()
22315 .unwrap_or_else(|poisoned| poisoned.into_inner())
22316 .get(&lease.lease_id)
22317 .unwrap()
22318 .record_holders
22319 .is_empty());
22320 shared
22321 .handle
22322 .dispatch(shared.prepare_command(ControlCommand::Register {
22323 instance_id: address.session.instance_id,
22324 agent_id: agent("claude"),
22325 transport: TransportKind::Pty,
22326 }))
22327 .unwrap();
22328 runtime.tick().await;
22329 shared.reconcile_managed_record(
22330 &record_id,
22331 &address,
22332 &ControlEvent {
22333 sequence: 1,
22334 command_id: None,
22335 instance_id: address.session.instance_id,
22336 generation: address.session.generation,
22337 event: ControlEventKind::Removed,
22338 },
22339 false,
22340 false,
22341 );
22342 let remove = shared.remove_session(&address);
22343 let drive_runtime = async {
22344 runtime.tick().await;
22345 };
22346 let (removed, ()) = tokio::join!(remove, drive_runtime);
22347 removed.unwrap();
22348
22349 assert!(!materialization_root.exists());
22350 assert!(!Path::new(&created.path).exists());
22351 assert_eq!(
22352 shared
22353 .managed_worktrees
22354 .lock()
22355 .unwrap_or_else(|poisoned| poisoned.into_inner())
22356 .get(&lease.lease_id)
22357 .unwrap()
22358 .state,
22359 ManagedWorktreeLeaseState::Removed,
22360 );
22361 let renamed = shared
22362 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22363 .unwrap();
22364 assert_eq!(renamed.record_id, record_id);
22365 assert_eq!(renamed.display_name, "raw managed inventory");
22366 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22367 assert!(renamed.active_session.is_none());
22368
22369 drop(shared);
22370 drop(runtime);
22371 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22372 std::fs::remove_dir_all(git_root).unwrap();
22373 }
22374
22375 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22376 let root = std::env::current_dir().unwrap()
22377 .join("managed-test-target")
22378 .to_string_lossy()
22379 .into_owned();
22380 let lease = ManagedWorktreeLeaseRecord {
22381 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22382 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22383 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22384 profile_id: WorktreeProfileId::new("default").unwrap(),
22385 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22386 target_root: root.clone(),
22387 branch: "gate4agent/mw-test".to_owned(),
22388 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22389 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22390 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22391 state: ManagedWorktreeLeaseState::Ready,
22392 session_holders: Vec::new(),
22393 record_holders: Vec::new(),
22394 cleanup_failure: None,
22395 created_at_unix_ms: 1,
22396 updated_at_unix_ms: 1,
22397 };
22398 shared.workspaces.write()
22399 .unwrap_or_else(|poisoned| poisoned.into_inner())
22400 .insert(lease.workspace_id.clone(), root);
22401 *shared.managed_worktrees.lock()
22402 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22403 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22404 lease
22405 }
22406
22407 #[cfg(feature = "fixture")]
22408 #[test]
22409 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22410 let mut shared = terminal_test_shared();
22411 shared.fixture_semantic_hook_policy = true;
22412 let lease = install_managed_lease(&shared);
22413 let address = SessionAddress {
22414 workspace_id: lease.workspace_id.clone(),
22415 session: SessionKey {
22416 instance_id: AgentInstanceId(206),
22417 generation: SessionGeneration(1),
22418 },
22419 };
22420 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22421 let effective_runtime_policy =
22422 shared.effective_spawn_runtime_policy(pre_admission_policy);
22423 assert_ne!(effective_runtime_policy, pre_admission_policy);
22424 assert!(effective_runtime_policy.provider_session_identity);
22425
22426 let record_id = shared
22427 .bind_spawn_session(
22428 &address,
22429 agent("claude"),
22430 SessionMode::Pty,
22431 effective_runtime_policy,
22432 SpawnRecordPolicy::Always,
22433 None,
22434 )
22435 .unwrap()
22436 .unwrap();
22437 assert_eq!(
22438 shared.record(&record_id).unwrap().state,
22439 ManagedSessionState::IdentityPending,
22440 );
22441 let (record_holder, raw_inventory_record_id) = {
22442 let mut bindings = shared
22443 .session_bindings
22444 .lock()
22445 .unwrap_or_else(|poisoned| poisoned.into_inner());
22446 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22447 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22448 managed_spawn_record_ownership(binding, effective_runtime_policy)
22449 };
22450 assert_eq!(record_holder.as_ref(), Some(&record_id));
22451 assert!(raw_inventory_record_id.is_none());
22452 let snapshot = shared
22453 .managed_worktrees
22454 .lock()
22455 .unwrap_or_else(|poisoned| poisoned.into_inner())
22456 .bind_session(
22457 &lease.lease_id,
22458 ManagedWorktreeSessionHolder {
22459 incarnation_id: shared.incarnation_id,
22460 instance_id: address.session.instance_id,
22461 generation: address.session.generation,
22462 },
22463 record_holder,
22464 unix_time_ms(),
22465 )
22466 .unwrap();
22467 assert_eq!(snapshot.managed_record_count, 1);
22468 assert_eq!(
22469 shared
22470 .managed_worktrees
22471 .lock()
22472 .unwrap_or_else(|poisoned| poisoned.into_inner())
22473 .get(&lease.lease_id)
22474 .unwrap()
22475 .record_holders,
22476 vec![record_id.clone()],
22477 );
22478
22479 let identity = ProviderSessionIdentity {
22480 key: ProviderSessionKey::SessionId,
22481 id: "monitoring-fixture-managed-session".to_owned(),
22482 transcript_path: None,
22483 };
22484 shared.publish_control(ControlEvent {
22485 sequence: 1,
22486 command_id: None,
22487 instance_id: address.session.instance_id,
22488 generation: address.session.generation,
22489 event: ControlEventKind::ProviderEvent {
22490 sequence: 1,
22491 source: gate4agent_types::ProviderSource {
22492 family: AdapterFamily::Hook,
22493 binding: AdapterBinding::new(
22494 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22495 "fixture/v1",
22496 gate4agent_types::AdapterVerification::SyntheticFixture,
22497 )
22498 .unwrap(),
22499 },
22500 source_sequence: 1,
22501 event: ProviderEvent::SessionIdentityObserved {
22502 identity: identity.clone(),
22503 },
22504 },
22505 });
22506
22507 let live = shared.record(&record_id).unwrap();
22508 assert_eq!(live.state, ManagedSessionState::Live);
22509 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22510 assert_eq!(live.active_session.as_ref(), Some(&address));
22511 assert_eq!(
22512 shared
22513 .managed_worktrees
22514 .lock()
22515 .unwrap_or_else(|poisoned| poisoned.into_inner())
22516 .get(&lease.lease_id)
22517 .unwrap()
22518 .record_holders,
22519 vec![record_id],
22520 );
22521 }
22522
22523 fn managed_v2_test_shared(
22524 allocation_root: &Path,
22525 state_path: Option<PathBuf>,
22526 profile_revision: &str,
22527 ) -> NodeShared {
22528 let profile = ManagedWorktreeProfile::new(
22529 WorktreeProfileId::new("default").unwrap(),
22530 WorktreeProfileRevision::new(profile_revision).unwrap(),
22531 allocation_root,
22532 "gate4agent",
22533 "HEAD",
22534 ManagedWorktreeRetention::RemoveWhenReleased,
22535 )
22536 .unwrap();
22537 let workspace = WorkspaceConfig::new(
22538 WorkspaceId::new("primary").unwrap(),
22539 std::env::current_dir().unwrap(),
22540 )
22541 .unwrap()
22542 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22543 .with_managed_worktree_profile(profile)
22544 .unwrap();
22545 let catalog = active_registry().unwrap();
22546 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22547 let mut shared = NodeShared::new(
22548 handle,
22549 "fixture-token".to_owned(),
22550 NodeId::new("node-terminal-test").unwrap(),
22551 vec![workspace],
22552 vec![agent("claude")],
22553 );
22554 shared.state_path = state_path;
22555 shared
22556 }
22557
22558 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22559 let mut spawn_spec = spawn_spec_fixture();
22560 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22561 ManagedWorktreeSpawnRequestV2 {
22562 spawn_spec,
22563 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22564 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22565 }
22566 }
22567
22568 #[test]
22569 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22570 let shared = terminal_test_shared();
22571 let mut lease = install_managed_lease(&shared);
22572 let session = SessionAddress {
22573 workspace_id: lease.workspace_id.clone(),
22574 session: SessionKey {
22575 instance_id: AgentInstanceId(73),
22576 generation: SessionGeneration(4),
22577 },
22578 };
22579 lease.state = ManagedWorktreeLeaseState::InUse;
22580 lease.session_holders.push(ManagedWorktreeSessionHolder {
22581 incarnation_id: shared.incarnation_id,
22582 instance_id: session.session.instance_id,
22583 generation: session.session.generation,
22584 });
22585 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22586 assert!(resolved.target.worktree_id.is_none());
22587
22588 let receipt = managed_spawn_receipt(
22589 &resolved,
22590 shared.incarnation_id,
22591 session.clone(),
22592 lease.snapshot(),
22593 None,
22594 None,
22595 None,
22596 );
22597 assert!(resolved.target.worktree_id.is_none());
22598 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22599 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22600 assert_eq!(receipt.spawn.session, session);
22601 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22602 let encoded = serde_json::to_vec(&receipt).unwrap();
22603 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22604 assert_eq!(decoded, receipt);
22605 }
22606
22607 #[tokio::test]
22608 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22609 let root = temporary_workspace_root("managed-v2-revision");
22610 let allocation = root.join("allocation");
22611 std::fs::create_dir_all(&allocation).unwrap();
22612 let shared = managed_v2_test_shared(
22613 &allocation,
22614 Some(root.join("node-state.json")),
22615 "v1",
22616 );
22617 let mut request = managed_v2_request("managed-v2-revision");
22618 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22619
22620 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22621
22622 assert_eq!(
22623 failure.code,
22624 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22625 );
22626 assert!(shared
22627 .managed_worktrees
22628 .lock()
22629 .unwrap_or_else(|poisoned| poisoned.into_inner())
22630 .records()
22631 .is_empty());
22632 assert!(shared
22633 .managed_spawn_replays
22634 .lock()
22635 .unwrap_or_else(|poisoned| poisoned.into_inner())
22636 .is_empty());
22637 std::fs::remove_dir_all(root).unwrap();
22638 }
22639
22640 #[test]
22641 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22642 let root = temporary_workspace_root("managed-v2-concurrent");
22643 let allocation = root.join("allocation");
22644 std::fs::create_dir_all(&allocation).unwrap();
22645 let shared = Arc::new(managed_v2_test_shared(
22646 &allocation,
22647 Some(root.join("node-state.json")),
22648 "v1",
22649 ));
22650 let request = managed_v2_request("managed-v2-concurrent");
22651 let barrier = Arc::new(std::sync::Barrier::new(3));
22652 let mut joins = Vec::new();
22653 for _ in 0..2 {
22654 let shared = Arc::clone(&shared);
22655 let request = request.clone();
22656 let barrier = Arc::clone(&barrier);
22657 joins.push(std::thread::spawn(move || {
22658 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22659 barrier.wait();
22660 shared.acquire_durable_managed_spawn_v2(&request, digest)
22661 }));
22662 }
22663 barrier.wait();
22664 let results = joins
22665 .into_iter()
22666 .map(|join| join.join().unwrap())
22667 .collect::<Vec<_>>();
22668 assert_eq!(
22669 results
22670 .iter()
22671 .filter(|result| {
22672 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22673 })
22674 .count(),
22675 1,
22676 );
22677 assert_eq!(
22678 results
22679 .iter()
22680 .filter(|result| {
22681 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22682 })
22683 .count(),
22684 1,
22685 );
22686 assert_eq!(
22687 shared
22688 .managed_spawn_replays
22689 .lock()
22690 .unwrap_or_else(|poisoned| poisoned.into_inner())
22691 .len(),
22692 1,
22693 );
22694 assert!(shared
22695 .managed_worktrees
22696 .lock()
22697 .unwrap_or_else(|poisoned| poisoned.into_inner())
22698 .records()
22699 .is_empty());
22700 drop(shared);
22701 std::fs::remove_dir_all(root).unwrap();
22702 }
22703
22704 #[tokio::test]
22705 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22706 let root = temporary_workspace_root("managed-v2-linked-pending");
22707 let allocation = root.join("allocation");
22708 std::fs::create_dir_all(&allocation).unwrap();
22709 let state_path = root.join("node-state.json");
22710 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22711 let lease = install_managed_lease(&shared);
22712 let request = managed_v2_request("managed-v2-linked-pending");
22713 assert!(matches!(
22714 shared
22715 .acquire_durable_managed_spawn_v2(
22716 &request,
22717 managed_spawn_request_digest_v2(&request).unwrap(),
22718 )
22719 .unwrap(),
22720 DurableManagedSpawnReplayDecision::Reserved,
22721 ));
22722 {
22723 let _transaction = shared
22724 .state_transaction
22725 .lock()
22726 .unwrap_or_else(|poisoned| poisoned.into_inner());
22727 shared
22728 .link_durable_managed_spawn_v2_lease_locked(
22729 &request.spawn_spec.idempotency_key,
22730 &lease.lease_id,
22731 )
22732 .unwrap();
22733 shared.persist_state_locked().unwrap();
22734 }
22735 drop(shared);
22736
22737 let mut loaded = session_registry::load(
22738 Some(&state_path),
22739 &NodeId::new("node-terminal-test").unwrap(),
22740 )
22741 .unwrap();
22742 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22743 *reopened
22744 .managed_worktrees
22745 .lock()
22746 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22747 ManagedWorktreeRegistry::from_records(
22748 std::mem::take(&mut loaded.managed_worktrees),
22749 std::mem::take(&mut loaded.managed_worktree_tombstones),
22750 )
22751 .unwrap();
22752 *reopened
22753 .managed_spawn_replays
22754 .lock()
22755 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22756 .managed_spawn_replays
22757 .into_iter()
22758 .map(|record| (record.idempotency_key.clone(), record))
22759 .collect();
22760 let lease_count = reopened
22761 .managed_worktrees
22762 .lock()
22763 .unwrap_or_else(|poisoned| poisoned.into_inner())
22764 .records()
22765 .len();
22766
22767 let failure = reopened
22768 .spawn_managed_worktree_v2(request)
22769 .await
22770 .unwrap_err();
22771
22772 assert_eq!(
22773 failure.code,
22774 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22775 );
22776 assert_eq!(
22777 reopened
22778 .managed_worktrees
22779 .lock()
22780 .unwrap_or_else(|poisoned| poisoned.into_inner())
22781 .records()
22782 .len(),
22783 lease_count,
22784 );
22785 std::fs::remove_dir_all(root).unwrap();
22786 }
22787
22788 #[tokio::test]
22789 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22790 let root = temporary_workspace_root("managed-v2-reopen");
22791 let allocation = root.join("allocation");
22792 std::fs::create_dir_all(&allocation).unwrap();
22793 let state_path = root.join("node-state.json");
22794 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22795 let lease = install_managed_lease(&shared);
22796 let request = managed_v2_request("managed-v2-reopen");
22797 let session = SessionAddress {
22798 workspace_id: lease.workspace_id.clone(),
22799 session: SessionKey {
22800 instance_id: AgentInstanceId(91),
22801 generation: SessionGeneration(1),
22802 },
22803 };
22804 let snapshot = {
22805 let mut registry = shared
22806 .managed_worktrees
22807 .lock()
22808 .unwrap_or_else(|poisoned| poisoned.into_inner());
22809 let current = registry.get_mut(&lease.lease_id).unwrap();
22810 current.state = ManagedWorktreeLeaseState::InUse;
22811 current.session_holders.push(ManagedWorktreeSessionHolder {
22812 incarnation_id: shared.incarnation_id,
22813 instance_id: session.session.instance_id,
22814 generation: session.session.generation,
22815 });
22816 current.snapshot()
22817 };
22818 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22819 let receipt = managed_spawn_receipt(
22820 &resolved,
22821 shared.incarnation_id,
22822 session,
22823 snapshot,
22824 None,
22825 None,
22826 None,
22827 );
22828 assert!(matches!(
22829 shared
22830 .acquire_durable_managed_spawn_v2(
22831 &request,
22832 managed_spawn_request_digest_v2(&request).unwrap(),
22833 )
22834 .unwrap(),
22835 DurableManagedSpawnReplayDecision::Reserved,
22836 ));
22837 {
22838 let _transaction = shared
22839 .state_transaction
22840 .lock()
22841 .unwrap_or_else(|poisoned| poisoned.into_inner());
22842 shared
22843 .link_durable_managed_spawn_v2_lease_locked(
22844 &request.spawn_spec.idempotency_key,
22845 &lease.lease_id,
22846 )
22847 .unwrap();
22848 shared.persist_state_locked().unwrap();
22849 }
22850 shared
22851 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22852 .unwrap();
22853 let legacy_same_key = ManagedWorktreeSpawnRequest {
22854 spawn_spec: request.spawn_spec.clone(),
22855 worktree_profile_id: request.worktree_profile_id.clone(),
22856 };
22857 shared.remember_managed_spawn_attempt(
22858 Some(&request.spawn_spec.idempotency_key),
22859 legacy_same_key.clone(),
22860 Ok(receipt.clone()),
22861 Instant::now(),
22862 );
22863 assert!(shared
22864 .replay_managed_spawn(&legacy_same_key, Instant::now())
22865 .unwrap()
22866 .is_none());
22867 drop(shared);
22868
22869 let mut loaded = session_registry::load(
22870 Some(&state_path),
22871 &NodeId::new("node-terminal-test").unwrap(),
22872 )
22873 .unwrap();
22874 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22875 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22876 *reopened
22877 .managed_worktrees
22878 .lock()
22879 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22880 ManagedWorktreeRegistry::from_records(
22881 std::mem::take(&mut loaded.managed_worktrees),
22882 std::mem::take(&mut loaded.managed_worktree_tombstones),
22883 )
22884 .unwrap();
22885 *reopened
22886 .managed_spawn_replays
22887 .lock()
22888 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22889 .managed_spawn_replays
22890 .into_iter()
22891 .map(|record| (record.idempotency_key.clone(), record))
22892 .collect();
22893 let lease_count_before = reopened
22894 .managed_worktrees
22895 .lock()
22896 .unwrap_or_else(|poisoned| poisoned.into_inner())
22897 .records()
22898 .len();
22899
22900 assert_eq!(
22901 reopened
22902 .spawn_managed_worktree_v2(request.clone())
22903 .await
22904 .unwrap(),
22905 receipt,
22906 );
22907 assert_eq!(
22908 reopened
22909 .managed_worktrees
22910 .lock()
22911 .unwrap_or_else(|poisoned| poisoned.into_inner())
22912 .records()
22913 .len(),
22914 lease_count_before,
22915 );
22916 let mut changed = request;
22917 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22918 let failure = reopened
22919 .spawn_managed_worktree_v2(changed)
22920 .await
22921 .unwrap_err();
22922 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22923 assert_eq!(
22924 reopened
22925 .managed_worktrees
22926 .lock()
22927 .unwrap_or_else(|poisoned| poisoned.into_inner())
22928 .records()
22929 .len(),
22930 lease_count_before,
22931 );
22932 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22933 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22934 let failure = reopened
22935 .spawn_managed_worktree_v2(new_key)
22936 .await
22937 .unwrap_err();
22938 assert_eq!(
22939 failure.code,
22940 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22941 );
22942 assert_eq!(
22943 reopened
22944 .managed_worktrees
22945 .lock()
22946 .unwrap_or_else(|poisoned| poisoned.into_inner())
22947 .records()
22948 .len(),
22949 lease_count_before,
22950 );
22951 assert_eq!(
22952 reopened
22953 .managed_spawn_replays
22954 .lock()
22955 .unwrap_or_else(|poisoned| poisoned.into_inner())
22956 .len(),
22957 1,
22958 );
22959 std::fs::remove_dir_all(root).unwrap();
22960 }
22961
22962 #[test]
22963 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22964 let shared = terminal_test_shared();
22965 let request = ManagedWorktreeSpawnRequest {
22966 spawn_spec: spawn_spec_fixture(),
22967 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22968 };
22969 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22970 shared.remember_managed_spawn(
22971 request.clone(),
22972 Err(expected.clone()),
22973 Instant::now(),
22974 );
22975
22976 assert_eq!(
22977 shared
22978 .replay_managed_spawn(&request, Instant::now())
22979 .unwrap()
22980 .unwrap(),
22981 Err(expected),
22982 );
22983 assert!(shared
22984 .managed_spawn_replays
22985 .lock()
22986 .unwrap_or_else(|poisoned| poisoned.into_inner())
22987 .is_empty());
22988 }
22989
22990 #[tokio::test]
22991 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22992 let shared = terminal_test_shared();
22993 let lease = install_managed_lease(&shared);
22994 let raw = shared.spawn_session(
22995 lease.workspace_id.clone(),
22996 agent("claude"),
22997 SessionMode::Pty,
22998 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22999 None,
23000 ).await.unwrap_err();
23001 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23002 let mut spec = spawn_spec_fixture();
23003 spec.target.workspace_id = lease.workspace_id.clone();
23004 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23005 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23006 let mut selected = spawn_spec_fixture();
23007 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23008 selected.target.worktree_id = Some(lease.workspace_id);
23009 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23010 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23011 assert!(shared.handle.snapshot().sessions.is_empty());
23012 }
23013
23014 #[test]
23015 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23016 let shared = terminal_test_shared();
23017 let lease = install_managed_lease(&shared);
23018 let snapshot = snapshot_for_wire(
23019 &shared, true, true, true, false, true, true, false, false, true,
23020 );
23021 assert!(snapshot.managed_worktrees.is_empty());
23022 assert!(snapshot.workspaces.iter().all(|workspace| {
23023 workspace.managed_worktree_profiles.is_none()
23024 }));
23025 let mut reply = ResponseEnvelope {
23026 request_id: 1,
23027 result: Ok(NodeResponse::Resync {
23028 event_sequence: 1,
23029 oldest_available_sequence: 1,
23030 snapshot: shared.snapshot(),
23031 events: vec![NodeEventEnvelope {
23032 sequence: 1,
23033 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23034 }],
23035 }),
23036 };
23037 project_response_without_managed_worktrees(&mut reply);
23038 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23039 panic!("projection changed the response kind");
23040 };
23041 assert!(snapshot.managed_worktrees.is_empty());
23042 assert!(events.is_empty());
23043 let mut git = git_snapshot_for_parser();
23044 git.branch = Some(lease.branch.clone());
23045 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23046 lease_id: lease.lease_id.clone(),
23047 source_workspace_id: lease.source_workspace_id.clone(),
23048 branch: lease.branch.clone(),
23049 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23050 active_session_count: 1,
23051 managed_record_count: 0,
23052 });
23053 let mut inspection_reply = ResponseEnvelope {
23054 request_id: 2,
23055 result: Ok(NodeResponse::WorkspaceInspected {
23056 inspection: WorkspaceInspection {
23057 workspace_id: lease.workspace_id.clone(),
23058 entries: Vec::new(),
23059 tree_truncated: false,
23060 git,
23061 truncation: None,
23062 },
23063 }),
23064 };
23065 project_response_without_managed_worktrees(&mut inspection_reply);
23066 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23067 panic!("projection changed the inspection response kind");
23068 };
23069 assert!(inspection.git.managed_worktree.is_none());
23070 let failure = managed_git_worktree_failure(GitWorktreeError {
23071 kind: GitWorktreeErrorKind::Failed,
23072 message: r"git failed at C:\private\managed-target".to_owned(),
23073 });
23074 assert!(!failure.message.contains("private"));
23075 assert!(!failure.message.contains("managed-target"));
23076 }
23077
23078 #[tokio::test]
23079 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23080 let root = temporary_workspace_root("managed-restart");
23081 let source = root.join("source");
23082 let allocation = root.join("allocation");
23083 std::fs::create_dir_all(&source).unwrap();
23084 std::fs::create_dir_all(&allocation).unwrap();
23085 run_test_git(&source, &["init"]);
23086 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23087 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23088 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23089 run_test_git(&source, &["add", "seed.txt"]);
23090 run_test_git(&source, &["commit", "-m", "seed"]);
23091 let profile = ManagedWorktreeProfile::new(
23092 WorktreeProfileId::new("default").unwrap(),
23093 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23094 &allocation,
23095 "gate4agent",
23096 "HEAD",
23097 ManagedWorktreeRetention::Retain,
23098 ).unwrap();
23099 let workspace = WorkspaceConfig::new(
23100 WorkspaceId::new("primary").unwrap(),
23101 &source,
23102 ).unwrap()
23103 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23104 .with_managed_worktree_profile(profile.clone()).unwrap();
23105 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23106 let source_text = workspace.canonical_root().to_owned();
23107 let target_text = target.to_string_lossy().into_owned();
23108 let created = create_git_worktree(
23109 &source_text,
23110 &target_text,
23111 "gate4agent/mw-restart",
23112 Some("HEAD"),
23113 ).await.unwrap();
23114 let lease = ManagedWorktreeLeaseRecord {
23115 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23116 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23117 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23118 profile_id: profile.profile_id().clone(),
23119 profile_revision: profile.revision().clone(),
23120 target_root: created.path.clone(),
23121 branch: "gate4agent/mw-restart".to_owned(),
23122 base_commit: created.head.clone(),
23123 expected_head: None,
23124 retention: ManagedWorktreeRetention::Retain,
23125 state: ManagedWorktreeLeaseState::Allocating,
23126 session_holders: Vec::new(),
23127 record_holders: Vec::new(),
23128 cleanup_failure: None,
23129 created_at_unix_ms: 1,
23130 updated_at_unix_ms: 1,
23131 };
23132 let catalog = active_registry().unwrap();
23133 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23134 let shared = NodeShared::new_with_incarnation(
23135 handle,
23136 "fixture-token".to_owned(),
23137 NodeId::new("node-managed-restart").unwrap(),
23138 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23139 vec![workspace],
23140 vec![agent("claude")],
23141 ProviderRuntimeStatuses::default(),
23142 None,
23143 Vec::new(),
23144 Vec::new(),
23145 SpawnProfileRegistry::default(),
23146 None,
23147 None,
23148 None,
23149 None,
23150 Vec::new(),
23151 vec![lease.clone()],
23152 Vec::new(),
23153 Vec::new(),
23154 None,
23155 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23156 );
23157 shared.reconcile_managed_worktrees().await;
23158 let retained = shared.managed_worktrees.lock()
23159 .unwrap_or_else(|poisoned| poisoned.into_inner())
23160 .get(&lease.lease_id).unwrap().clone();
23161 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23162 assert!(shared.workspaces.read()
23163 .unwrap_or_else(|poisoned| poisoned.into_inner())
23164 .contains_key(&lease.workspace_id));
23165 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23166 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23167 assert!(!Path::new(&created.path).exists());
23168 std::fs::remove_dir_all(root).unwrap();
23169 }
23170
23171 fn run_test_git(root: &Path, arguments: &[&str]) {
23172 let output = std::process::Command::new("git")
23173 .arg("-C").arg(root).args(arguments).output().unwrap();
23174 assert!(
23175 output.status.success(),
23176 "git {:?} failed: {}",
23177 arguments,
23178 String::from_utf8_lossy(&output.stderr),
23179 );
23180 }
23181
23182 #[tokio::test]
23183 async fn spawn_spec_validation_fails_before_session_mutation() {
23184 let shared = terminal_test_shared();
23185 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23186 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23187 }));
23188 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23189 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23190 }));
23191 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23192 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23193 }));
23194
23195 let mut target_mismatch = spawn_spec_fixture();
23196 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23197 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23198 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23199
23200 let mut unknown_profile = spawn_spec_fixture();
23201 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23202 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23203 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23204 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23205
23206 let mut revision_mismatch = spawn_spec_fixture();
23207 revision_mismatch.idempotency_key =
23208 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23209 revision_mismatch.expected_profile_revision =
23210 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23211 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23212 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23213
23214 let mut unsupported_materializer = spawn_spec_fixture();
23215 unsupported_materializer.idempotency_key =
23216 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23217 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23218 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23219 };
23220 let failure = shared
23221 .spawn_from_spec(unsupported_materializer)
23222 .await
23223 .unwrap_err();
23224 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23225
23226 let failure = shared
23227 .spawn_session_with_deadline(
23228 WorkspaceId::new("primary").unwrap(),
23229 agent("claude"),
23230 SessionMode::Pty,
23231 gate4agent_types::TerminalSize {
23232 rows: 24,
23233 columns: 80,
23234 },
23235 None,
23236 None,
23237 None,
23238 None,
23239 None,
23240 None,
23241 SpawnRecordPolicy::ProviderIdentityOnly,
23242 Some(Instant::now()),
23243 &[],
23244 None,
23245 ApprovalLevel::default(),
23246 None,
23247 None,
23248 )
23249 .await
23250 .unwrap_err();
23251 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23252 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23253 assert!(shared.handle.snapshot().sessions.is_empty());
23254 }
23255
23256 #[test]
23257 fn worktree_selection_requires_its_separate_negotiated_capability() {
23258 let mut spec = spawn_spec_fixture();
23259 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23260 let request = NodeRequest::SpawnSpec { spec };
23261 let spawn_spec_capability =
23262 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23263 assert!(request_uses_unnegotiated_capability(
23264 &request,
23265 &[spawn_spec_capability.clone()],
23266 ));
23267 let worktree_capability =
23268 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23269 assert!(request_uses_unnegotiated_capability(
23270 &request,
23271 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23272 ));
23273 let profile_revision_capability =
23274 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23275 assert!(request_uses_unnegotiated_capability(
23276 &request,
23277 &[
23278 spawn_spec_capability.clone(),
23279 worktree_capability.clone(),
23280 profile_revision_capability.clone(),
23281 ],
23282 ));
23283 let bundle_materialization_capability =
23290 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23291 assert!(request_uses_unnegotiated_capability(
23292 &request,
23293 &[
23294 spawn_spec_capability.clone(),
23295 worktree_capability.clone(),
23296 profile_revision_capability.clone(),
23297 bundle_materialization_capability.clone(),
23298 ],
23299 ));
23300 let history_context_pack_capability =
23301 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23302 assert!(!request_uses_unnegotiated_capability(
23303 &request,
23304 &[
23305 spawn_spec_capability,
23306 worktree_capability,
23307 profile_revision_capability,
23308 bundle_materialization_capability,
23309 history_context_pack_capability,
23310 ],
23311 ));
23312 }
23313
23314 #[tokio::test]
23315 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23316 let mut shared = terminal_test_shared();
23317 let spec = spawn_spec_fixture();
23318 let receipt = shared
23319 .resolve_spawn_spec(&spec)
23320 .unwrap()
23321 .receipt(shared.incarnation_id, terminal_address(1));
23322 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23323
23324 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23325 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23326 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23327
23328 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23329
23330 let mut revision_conflicting = spec.clone();
23331 revision_conflicting.expected_profile_revision =
23332 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23333 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23334 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23335
23336 let mut stale_cache_miss = spec.clone();
23337 stale_cache_miss.idempotency_key =
23338 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23339 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23340 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23341
23342 let mut conflicting = spec;
23343 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23344 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23345 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23346 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23347 assert!(shared.handle.snapshot().sessions.is_empty());
23348 }
23349
23350 #[tokio::test]
23351 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23352 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23353 let mut spec = spawn_spec_fixture();
23354 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23355 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23356 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23357
23358 let failure = shared
23359 .create_worktree(
23360 WorkspaceId::new("primary").unwrap(),
23361 WorkspaceId::new("new-worktree").unwrap(),
23362 "not-an-absolute-path".to_owned(),
23363 "invalid branch".to_owned(),
23364 None,
23365 )
23366 .await
23367 .unwrap_err();
23368 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23369
23370 let failure = shared
23371 .remove_worktree(
23372 WorkspaceId::new("primary").unwrap(),
23373 "not-an-absolute-path".to_owned(),
23374 )
23375 .await
23376 .unwrap_err();
23377 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23378 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23379 assert!(shared.handle.snapshot().sessions.is_empty());
23380 }
23381
23382 #[tokio::test]
23383 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23384 let root = temporary_workspace_root("manual-worktree-compensation");
23385 let source = root.join("source");
23386 let target = root.join("target");
23387 let invalid_state_parent = root.join("state-parent-is-a-file");
23388 let invalid_state_path = invalid_state_parent.join("state.json");
23389 std::fs::create_dir_all(&source).unwrap();
23390 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23391 run_test_git(&source, &["init"]);
23392 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23393 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23394 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23395 run_test_git(&source, &["add", "seed.txt"]);
23396 run_test_git(&source, &["commit", "-m", "seed"]);
23397
23398 let catalog = active_registry().unwrap();
23399 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23400 let workspace = WorkspaceConfig::new(
23401 WorkspaceId::new("primary").unwrap(),
23402 &source,
23403 ).unwrap();
23404 let mut shared = NodeShared::new(
23405 handle,
23406 "fixture-token".to_owned(),
23407 NodeId::new("node-worktree-compensation").unwrap(),
23408 vec![workspace],
23409 vec![agent("claude")],
23410 );
23411 shared.state_path = Some(invalid_state_path);
23412
23413 let error = shared.create_worktree(
23414 WorkspaceId::new("primary").unwrap(),
23415 WorkspaceId::new("compensated").unwrap(),
23416 target.to_string_lossy().into_owned(),
23417 "gate4agent/compensated".to_owned(),
23418 Some("HEAD".to_owned()),
23419 ).await.unwrap_err();
23420
23421 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23422 assert!(!target.exists());
23423 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23424 .iter()
23425 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23426 std::fs::remove_dir_all(root).unwrap();
23427 }
23428
23429 #[test]
23430 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23431 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23432 NativeGitWorktreeSnapshot {
23433 path: path.to_owned(),
23434 head: "0123456789abcdef".to_owned(),
23435 branch: Some("refs/heads/test".to_owned()),
23436 is_bare,
23437 is_main,
23438 locked: false,
23439 lock_reason: None,
23440 prunable: false,
23441 prunable_reason: None,
23442 workspace_id: None,
23443 }
23444 }
23445
23446 let source = r"C:\repo";
23447 let selected = r"C:\repo-worktree";
23448 let listed = vec![
23449 worktree(source, true, false),
23450 worktree(selected, false, false),
23451 ];
23452 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23453
23454 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23455 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23456
23457 let bare = vec![
23458 worktree(source, true, false),
23459 worktree(selected, false, true),
23460 ];
23461 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23462 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23463
23464 let foreign_failure =
23465 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23466 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23467 }
23468
23469 fn terminal_frame(sequence: u64) -> TerminalFrame {
23470 TerminalFrame {
23471 sequence,
23472 size: gate4agent_types::TerminalSize {
23473 rows: 24,
23474 columns: 80,
23475 },
23476 cursor_row: 2,
23477 cursor_column: 3,
23478 contents: format!("frame-{sequence}"),
23479 formatted: vec![1, 2, 3],
23480 scrollback_formatted: Vec::new(),
23481 alternate_screen: false,
23482 mouse_protocol_enabled: false,
23483 mouse_protocol_encoding: Default::default(),
23484 produced_at_unix_ms: 0,
23485 screen_state: gate4agent_types::PtyScreenState::default(),
23486 bracketed_paste: None,
23487 }
23488 }
23489
23490 #[test]
23491 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23492 let shared = terminal_test_shared();
23493 let mut receiver = shared.terminal_event_tx.subscribe();
23494 let original = terminal_address(1);
23495
23496 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23497 let first = receiver.try_recv().unwrap();
23498 assert_eq!(first.len(), 1);
23499 assert!(matches!(
23500 &first[0].event,
23501 NodeEvent::TerminalFrame { address, frame }
23502 if address == &original && frame.sequence == 7
23503 ));
23504
23505 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23506 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23507 assert!(matches!(
23508 receiver.try_recv(),
23509 Err(broadcast::error::TryRecvError::Empty)
23510 ));
23511
23512 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23513 let advanced = receiver.try_recv().unwrap();
23514 assert_eq!(advanced[0].sequence, 2);
23515
23516 let resumed = terminal_address(2);
23517 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23518 let reset = receiver.try_recv().unwrap();
23519 assert_eq!(reset[0].sequence, 3);
23520 assert!(matches!(
23521 &reset[0].event,
23522 NodeEvent::TerminalFrame { address, frame }
23523 if address == &resumed && frame.sequence == 1
23524 ));
23525 shared.clear_terminal_frame_watermark(&resumed);
23526 assert!(!shared
23527 .terminal_frame_watermarks
23528 .lock()
23529 .unwrap_or_else(|poisoned| poisoned.into_inner())
23530 .contains_key(&resumed.session.instance_id));
23531 }
23532
23533 #[test]
23534 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23535 let shared = terminal_test_shared();
23536 let mut receiver = shared.terminal_event_tx.subscribe();
23537 let address = terminal_address(1);
23538 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23539
23540 for sequence in 1..=count {
23541 shared.publish_terminal_frame_candidates(vec![(
23542 address.clone(),
23543 terminal_frame(sequence),
23544 )]);
23545 }
23546
23547 let history = shared
23548 .history
23549 .lock()
23550 .unwrap_or_else(|poisoned| poisoned.into_inner());
23551 assert!(history.events.is_empty());
23552 assert_eq!(history.last_sequence, count);
23553 assert_eq!(history.replay_floor_sequence, 1);
23554 drop(history);
23555 let NodeResponse::Resync {
23556 event_sequence,
23557 oldest_available_sequence,
23558 events,
23559 ..
23560 } = shared.resync(0) else {
23561 unreachable!("resync helper returned another response")
23562 };
23563 assert_eq!(event_sequence, count);
23564 assert_eq!(oldest_available_sequence, 1);
23565 assert!(events.is_empty());
23566 assert!(matches!(
23567 resync_required_event(&shared).event,
23568 NodeEvent::ResyncRequired {
23569 oldest_available_sequence: 1,
23570 }
23571 ));
23572 assert!(matches!(
23573 receiver.try_recv(),
23574 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23575 ));
23576 let latest = receiver.try_recv().unwrap();
23577 assert_eq!(latest.len(), 1);
23578 assert_eq!(latest[0].sequence, count);
23579 }
23580
23581 #[test]
23582 fn real_durable_eviction_advances_authoritative_replay_floor() {
23583 let shared = terminal_test_shared();
23584 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23585 shared.publish(NodeEvent::ControllerChanged { controller: None });
23586 }
23587
23588 let NodeResponse::Resync {
23589 event_sequence,
23590 oldest_available_sequence,
23591 events,
23592 ..
23593 } = shared.resync(0) else {
23594 unreachable!("resync helper returned another response")
23595 };
23596 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23597 assert_eq!(oldest_available_sequence, 2);
23598 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23599 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23600 assert!(matches!(
23601 resync_required_event(&shared).event,
23602 NodeEvent::ResyncRequired {
23603 oldest_available_sequence: 2,
23604 }
23605 ));
23606 }
23607
23608 #[test]
23609 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23610 let shared = terminal_test_shared();
23611 let without_capability = terminal_event_subscription(&shared, false);
23612 let with_capability = terminal_event_subscription(&shared, true);
23613 assert!(without_capability.is_none());
23614 assert!(with_capability.is_some());
23615 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23616 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23617 }));
23618 }
23619
23620 #[test]
23621 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23622 let mut pending = Vec::new();
23623 let mut event_sequence = 0_u64;
23624 for frame_sequence in 1..=4 {
23625 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23626 event_sequence += 1;
23627 let mut address = terminal_address(1);
23628 address.session.instance_id = AgentInstanceId(instance);
23629 queue_connection_event(
23630 &mut pending,
23631 NodeEventEnvelope {
23632 sequence: event_sequence,
23633 event: NodeEvent::TerminalFrame {
23634 address,
23635 frame: terminal_frame(frame_sequence),
23636 },
23637 },
23638 0,
23639 );
23640 }
23641 }
23642 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23643 pending.sort_unstable_by_key(|event| event.sequence);
23644
23645 let mut uninterrupted = pending.clone();
23646 let first_burst = connection_event_burst_len(uninterrupted.len());
23647 uninterrupted.drain(..first_burst);
23648 let second_burst = connection_event_burst_len(uninterrupted.len());
23649 uninterrupted.drain(..second_burst);
23650 assert!(uninterrupted.is_empty());
23651
23652 let (frame_tx, mut frame_rx) =
23653 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23654 frame_tx
23655 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23656 request_id: 77,
23657 request: NodeRequest::Snapshot,
23658 })))
23659 .unwrap();
23660 let burst_len = connection_event_burst_len(pending.len());
23661 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23662 pending.drain(..burst_len);
23663 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23664 assert!(matches!(
23665 frame_rx.try_recv(),
23666 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23667 request_id: 77,
23668 request: NodeRequest::Snapshot,
23669 })))
23670 ));
23671 }
23672
23673 #[test]
23674 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23675 let mut pending = Vec::new();
23676 for sequence in [7_u64, 8] {
23677 let mut address = terminal_address(1);
23678 address.session.instance_id = AgentInstanceId(sequence);
23679 queue_connection_event(
23680 &mut pending,
23681 NodeEventEnvelope {
23682 sequence,
23683 event: NodeEvent::TerminalFrame {
23684 address,
23685 frame: terminal_frame(sequence),
23686 },
23687 },
23688 0,
23689 );
23690 }
23691 let mut discard_through = 0;
23692 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23693 assert!(pending.is_empty());
23694
23695 let replayed = NodeEventEnvelope {
23696 sequence: 8,
23697 event: NodeEvent::TerminalFrame {
23698 address: terminal_address(1),
23699 frame: terminal_frame(8),
23700 },
23701 };
23702 let advanced = NodeEventEnvelope {
23703 sequence: 9,
23704 event: NodeEvent::TerminalFrame {
23705 address: terminal_address(2),
23706 frame: terminal_frame(1),
23707 },
23708 };
23709 queue_connection_event_batch(
23710 &mut pending,
23711 &[replayed, advanced.clone()],
23712 discard_through,
23713 );
23714 assert_eq!(pending, vec![advanced]);
23715 }
23716
23717 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23718 ManagedSessionRecord {
23719 record_id: SessionRecordId::new(record_id).unwrap(),
23720 display_name: format!("{provider} record"),
23721 provider: agent(provider),
23722 mode: SessionMode::Pty,
23723 state: ManagedSessionState::Unavailable,
23724 workspace_id: WorkspaceId::new("primary").unwrap(),
23725 canonical_root: opaque_windows_path(
23726 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23727 ),
23728 provider_session: None,
23729 active_session: None,
23730 environment_profile: None,
23731 bundle: None,
23732 context_id: None,
23733 context: None,
23734 exported_context: None,
23735 task_binding: None,
23736 created_at_unix_ms: 1,
23737 updated_at_unix_ms: 1,
23738 last_error: None,
23739 }
23740 }
23741
23742 fn retention_test_record(
23743 workspace_id: &str,
23744 record_id: &str,
23745 state: ManagedSessionState,
23746 updated_at_unix_ms: u64,
23747 ) -> ManagedSessionRecord {
23748 ManagedSessionRecord {
23749 record_id: SessionRecordId::new(record_id).unwrap(),
23750 display_name: format!("{record_id} display"),
23751 provider: agent("claude"),
23752 mode: SessionMode::Pty,
23753 state,
23754 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23755 canonical_root: opaque_windows_path(
23756 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23757 ),
23758 provider_session: None,
23759 active_session: None,
23760 environment_profile: None,
23761 bundle: None,
23762 context_id: None,
23763 context: None,
23764 exported_context: None,
23765 task_binding: None,
23766 created_at_unix_ms: updated_at_unix_ms,
23767 updated_at_unix_ms,
23768 last_error: None,
23769 }
23770 }
23771
23772 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23780 vec![
23781 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23782 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23783 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23784 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23785 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23786 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23787 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23788 retention_test_record(
23789 "workspace-a",
23790 "decoy-pending",
23791 ManagedSessionState::IdentityPending,
23792 now - 500_000,
23793 ),
23794 retention_test_record(
23795 "workspace-a",
23796 "decoy-dormant",
23797 ManagedSessionState::Dormant,
23798 now - 500_000,
23799 ),
23800 ]
23801 }
23802
23803 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23804 let root = temporary_workspace_root("session-record-retention");
23805 std::fs::create_dir_all(&root).unwrap();
23806 let catalog = active_registry().unwrap();
23807 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23808 let workspace_a =
23809 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23810 let workspace_b =
23811 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23812 let mut shared = NodeShared::new(
23813 handle,
23814 "fixture-token".to_owned(),
23815 NodeId::new("node-retention-test").unwrap(),
23816 vec![workspace_a, workspace_b],
23817 vec![agent("claude")],
23818 );
23819 shared.state_path = Some(root.join("node-state-v10.json"));
23820 let now = unix_time_ms();
23821 for record in retention_fixture_records(now) {
23822 shared.insert_record(record).unwrap();
23823 }
23824 shared
23825 .persist_state_locked()
23826 .expect("fixture-only, empty-history persist against a fresh temp state path");
23827 (shared, runtime, root, now)
23828 }
23829
23830 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23831 shared
23832 .history
23833 .lock()
23834 .unwrap_or_else(|poisoned| poisoned.into_inner())
23835 .events
23836 .iter()
23837 .filter_map(|envelope| match &envelope.event {
23838 NodeEvent::SessionRecordRemoved { record_id } => {
23839 Some(record_id.as_str().to_owned())
23840 }
23841 _ => None,
23842 })
23843 .collect()
23844 }
23845
23846 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23847 shared
23848 .session_records
23849 .lock()
23850 .unwrap_or_else(|poisoned| poisoned.into_inner())
23851 .records
23852 .keys()
23853 .map(|record_id| record_id.as_str().to_owned())
23854 .collect()
23855 }
23856
23857 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23858 values.iter().map(|value| (*value).to_owned()).collect()
23859 }
23860
23861 #[tokio::test]
23862 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23863 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23864 shared.session_record_retention = SessionRecordRetentionConfig {
23865 age_ms: 0,
23866 keep_per_workspace: 1,
23867 };
23868 shared.retire_unavailable_session_records().await;
23869
23870 assert_eq!(
23871 retention_remaining_record_ids(&shared),
23872 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23873 );
23874 assert_eq!(
23875 retention_removed_record_ids(&shared),
23876 owned(&["a1", "a2", "b1", "b2"]),
23877 );
23878 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23879
23880 drop(shared);
23881 drop(runtime);
23882 std::fs::remove_dir_all(root).unwrap();
23883 }
23884
23885 #[tokio::test]
23891 async fn session_record_retention_both_set_retires_only_the_intersection() {
23892 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23893 shared.session_record_retention = SessionRecordRetentionConfig {
23894 age_ms: 250_000,
23895 keep_per_workspace: 1,
23896 };
23897 shared.retire_unavailable_session_records().await;
23898
23899 assert_eq!(
23900 retention_remaining_record_ids(&shared),
23901 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23902 );
23903 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23904 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23905
23906 drop(shared);
23907 drop(runtime);
23908 std::fs::remove_dir_all(root).unwrap();
23909 }
23910
23911 #[tokio::test]
23912 async fn session_record_retention_both_disabled_is_a_no_op() {
23913 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23914 shared.session_record_retention = SessionRecordRetentionConfig::default();
23915 shared.retire_unavailable_session_records().await;
23916
23917 assert_eq!(
23918 retention_remaining_record_ids(&shared),
23919 owned(&[
23920 "a1", "a2", "a3", "b1", "b2", "b3",
23921 "decoy-live", "decoy-pending", "decoy-dormant",
23922 ]),
23923 );
23924 assert!(retention_removed_record_ids(&shared).is_empty());
23925 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23926
23927 drop(shared);
23928 drop(runtime);
23929 std::fs::remove_dir_all(root).unwrap();
23930 }
23931
23932 #[test]
23933 fn session_record_context_export_selects_one_exact_history_identity() {
23934 let candidates = vec![
23935 HistoryCandidateSummary {
23936 id: "candidate-other".to_owned(),
23937 session_id_hint: "provider-other".to_owned(),
23938 modified_at_unix_ms: Some(1),
23939 },
23940 HistoryCandidateSummary {
23941 id: "candidate-exact".to_owned(),
23942 session_id_hint: "provider-exact".to_owned(),
23943 modified_at_unix_ms: Some(2),
23944 },
23945 ];
23946 assert_eq!(
23947 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23948 "candidate-exact",
23949 );
23950 }
23951
23952 #[test]
23953 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23954 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23955 id: id.to_owned(),
23956 session_id_hint: hint.to_owned(),
23957 modified_at_unix_ms: None,
23958 };
23959 let missing = select_history_candidate(
23960 &[candidate("candidate-other", "provider-other")],
23961 Some("provider-exact"),
23962 )
23963 .unwrap_err();
23964 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23965 let ambiguous = select_history_candidate(
23966 &[
23967 candidate("candidate-a", "provider-exact"),
23968 candidate("candidate-b", "provider-exact"),
23969 ],
23970 Some("provider-exact"),
23971 )
23972 .unwrap_err();
23973 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23974 }
23975
23976 #[test]
23984 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23985 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23986 id: id.to_owned(),
23987 session_id_hint: hint.to_owned(),
23988 modified_at_unix_ms: None,
23989 };
23990 assert_eq!(
23991 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23992 .unwrap(),
23993 "candidate-sole",
23994 );
23995 let missing = select_history_candidate(&[], None).unwrap_err();
23996 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23997 let ambiguous = select_history_candidate(
23998 &[
23999 candidate("candidate-a", "sidecar-session-a"),
24000 candidate("candidate-b", "sidecar-session-b"),
24001 ],
24002 None,
24003 )
24004 .unwrap_err();
24005 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24006 }
24007
24008 #[test]
24009 fn session_record_context_export_revalidation_rejects_record_change() {
24010 let session = terminal_address(1);
24011 let identity = ProviderSessionIdentity {
24012 key: ProviderSessionKey::SessionId,
24013 id: "provider-exact".to_owned(),
24014 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24015 };
24016 let mut expected = record("claude", "record-context-export");
24017 expected.state = ManagedSessionState::Live;
24018 expected.provider_session = Some(identity.clone());
24019 expected.active_session = Some(session.clone());
24020 let binding = SessionBinding {
24021 workspace_id: session.workspace_id.clone(),
24022 generation: session.session.generation,
24023 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24024 pending_resume: None,
24025 record_id: Some(expected.record_id.clone()),
24026 managed_worktree_lease_id: None,
24027 environment_profile: None,
24028 bundle: None,
24029 context: None,
24030 materialization_id: None,
24031 };
24032 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24033 assert!(session_record_context_export_binding_is_exact(
24034 &expected,
24035 &expected,
24036 Some(&identity),
24037 &session,
24038 &binding,
24039 &expected.provider,
24040 ¤t_root,
24041 false,
24042 ));
24043 let mut changed = expected.clone();
24044 changed.updated_at_unix_ms += 1;
24045 assert!(!session_record_context_export_binding_is_exact(
24046 &expected,
24047 &changed,
24048 Some(&identity),
24049 &session,
24050 &binding,
24051 &expected.provider,
24052 ¤t_root,
24053 false,
24054 ));
24055 assert!(!session_record_context_export_binding_is_exact(
24060 &expected,
24061 &changed,
24062 Some(&identity),
24063 &session,
24064 &binding,
24065 &expected.provider,
24066 ¤t_root,
24067 true,
24068 ));
24069 let mut detached = expected.clone();
24070 detached.state = ManagedSessionState::Dormant;
24071 detached.active_session = None;
24072 detached.updated_at_unix_ms += 1;
24073 assert!(!session_record_context_export_binding_is_exact(
24074 &expected,
24075 &detached,
24076 Some(&identity),
24077 &session,
24078 &binding,
24079 &expected.provider,
24080 ¤t_root,
24081 false,
24082 ));
24083 assert!(session_record_context_export_binding_is_exact(
24084 &expected,
24085 &detached,
24086 Some(&identity),
24087 &session,
24088 &binding,
24089 &expected.provider,
24090 ¤t_root,
24091 true,
24092 ));
24093 }
24094
24095 #[test]
24102 fn session_record_context_export_binding_is_exact_without_identity() {
24103 let session = terminal_address(1);
24104 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24105 expected.state = ManagedSessionState::Live;
24106 expected.active_session = Some(session.clone());
24107 let binding = SessionBinding {
24108 workspace_id: session.workspace_id.clone(),
24109 generation: session.session.generation,
24110 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24111 pending_resume: None,
24112 record_id: Some(expected.record_id.clone()),
24113 managed_worktree_lease_id: None,
24114 environment_profile: None,
24115 bundle: None,
24116 context: None,
24117 materialization_id: None,
24118 };
24119 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24120 assert!(session_record_context_export_binding_is_exact(
24121 &expected,
24122 &expected,
24123 None,
24124 &session,
24125 &binding,
24126 &expected.provider,
24127 ¤t_root,
24128 false,
24129 ));
24130 let mut drifted = expected.clone();
24134 drifted.provider_session = Some(ProviderSessionIdentity {
24135 key: ProviderSessionKey::SessionId,
24136 id: "unexpected-identity".to_owned(),
24137 transcript_path: None,
24138 });
24139 assert!(!session_record_context_export_binding_is_exact(
24140 &expected,
24141 &drifted,
24142 None,
24143 &session,
24144 &binding,
24145 &expected.provider,
24146 ¤t_root,
24147 false,
24148 ));
24149 }
24150
24151 #[test]
24152 fn session_record_context_export_preflight_requires_live_usable_source() {
24153 let mut source = record("claude", "record-context-export-preflight");
24154 source.state = ManagedSessionState::Live;
24155 assert!(session_record_context_export_source_is_usable(
24156 &source,
24157 &SessionStatus::Running,
24158 false,
24159 ));
24160 assert!(session_record_context_export_source_is_usable(
24161 &source,
24162 &SessionStatus::Exited { exit_code: Some(0) },
24163 false,
24164 ));
24165
24166 source.state = ManagedSessionState::Dormant;
24167 assert!(!session_record_context_export_source_is_usable(
24168 &source,
24169 &SessionStatus::Running,
24170 false,
24171 ));
24172 assert!(session_record_context_export_source_is_usable(
24176 &source,
24177 &SessionStatus::Exited { exit_code: Some(0) },
24178 true,
24179 ));
24180 assert!(!session_record_context_export_source_is_usable(
24181 &source,
24182 &SessionStatus::Exited { exit_code: Some(0) },
24183 false,
24184 ));
24185 source.state = ManagedSessionState::Live;
24186 assert!(!session_record_context_export_source_is_usable(
24187 &source,
24188 &SessionStatus::Starting,
24189 false,
24190 ));
24191 }
24192
24193 #[test]
24194 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24195 let catalog = active_registry().unwrap();
24196 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24197 let workspace = WorkspaceConfig::new(
24198 WorkspaceId::new("primary").unwrap(),
24199 std::env::current_dir().unwrap(),
24200 )
24201 .unwrap();
24202 let shared = NodeShared::new(
24203 handle,
24204 "fixture-token".to_owned(),
24205 NodeId::new("node-context-export-conflict").unwrap(),
24206 vec![workspace],
24207 vec![agent("claude")],
24208 );
24209 let history = HistorySessionRecord {
24210 session_id: "provider-exact".to_owned(),
24211 title: None,
24212 cwd: None,
24213 model: None,
24214 message_count: 1,
24215 completed_turn_count: None,
24216 total_tokens: 0,
24217 messages: vec![gate4agent_types::HistoryMessageRecord {
24218 role: gate4agent_types::HistoryMessageRole::User,
24219 text: "bounded context".to_owned(),
24220 }],
24221 };
24222 let pack = NodeContextPack::export(
24223 ContextPackLineageReceipt {
24224 source_node_id: shared.node_id.clone(),
24225 source_session: terminal_address(1),
24226 source_provider: agent("claude"),
24227 },
24228 &history,
24229 )
24230 .unwrap();
24231 let context_id = pack.receipt().id.clone();
24232 let session = terminal_address(1);
24233 let identity = ProviderSessionIdentity {
24234 key: ProviderSessionKey::SessionId,
24235 id: history.session_id.clone(),
24236 transcript_path: None,
24237 };
24238 let mut expected = record("claude", "record-context-export-final-conflict");
24239 expected.state = ManagedSessionState::Live;
24240 expected.provider_session = Some(identity.clone());
24241 expected.active_session = Some(session.clone());
24242 let mut changed = expected.clone();
24243 changed.updated_at_unix_ms += 1;
24244 shared.insert_record(changed).unwrap();
24245
24246 let error = shared
24247 .commit_context_pack_for_session_record(
24248 &expected,
24249 Some(&identity),
24250 &session,
24251 "candidate-exact",
24252 &history,
24253 pack,
24254 false,
24255 )
24256 .unwrap_err();
24257
24258 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24259 assert!(shared
24260 .context_catalog
24261 .read()
24262 .unwrap_or_else(|poisoned| poisoned.into_inner())
24263 .get(&context_id)
24264 .is_none());
24265 }
24266
24267 #[tokio::test]
24268 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24269 let root = temporary_workspace_root("session-task-binding");
24270 std::fs::create_dir_all(&root).unwrap();
24271 let state_path = root.join("node-state.json");
24272 let catalog = active_registry().unwrap();
24273 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24274 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24275 let node_id = NodeId::new("node-1").unwrap();
24276 let mut shared = NodeShared::new(
24277 handle,
24278 "fixture-token".to_owned(),
24279 node_id.clone(),
24280 vec![workspace],
24281 vec![agent("claude")],
24282 );
24283 shared.state_path = Some(state_path.clone());
24284 let record = record("claude", "sr-task");
24285 let record_id = record.record_id.clone();
24286 shared.insert_record(record).unwrap();
24287 shared.persist_state().unwrap();
24288 shared
24289 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24290 .unwrap();
24291
24292 let minted = process_request_inner(
24293 &shared,
24294 77,
24295 ClientRole::Operator,
24296 NodeRequest::SetSessionTask {
24297 record_id: record_id.clone(),
24298 expected_revision: 0,
24299 target: SessionTaskTargetV1::New,
24300 },
24301 )
24302 .await
24303 .unwrap();
24304 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24305 panic!("session task mutation returned another response");
24306 };
24307 let minted_binding = minted.task_binding.clone().unwrap();
24308 let minted_task_id = minted_binding.task_id.clone().unwrap();
24309 assert_eq!(minted_binding.revision, 1);
24310 assert!(minted_task_id.as_str().starts_with("task-"));
24311 assert_eq!(minted_task_id.as_str().len(), 29);
24312
24313 let event_count = shared.current_sequence();
24314 let repeated = process_request_inner(
24315 &shared,
24316 77,
24317 ClientRole::Operator,
24318 NodeRequest::SetSessionTask {
24319 record_id: record_id.clone(),
24320 expected_revision: 1,
24321 target: SessionTaskTargetV1::Existing {
24322 task_id: minted_task_id.clone(),
24323 },
24324 },
24325 )
24326 .await
24327 .unwrap();
24328 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24329 assert_eq!(shared.current_sequence(), event_count);
24330
24331 let stale = process_request_inner(
24332 &shared,
24333 77,
24334 ClientRole::Operator,
24335 NodeRequest::SetSessionTask {
24336 record_id: record_id.clone(),
24337 expected_revision: 0,
24338 target: SessionTaskTargetV1::Existing {
24339 task_id: minted_task_id,
24340 },
24341 },
24342 )
24343 .await
24344 .unwrap_err();
24345 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24346
24347 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24348 let assigned = process_request_inner(
24349 &shared,
24350 77,
24351 ClientRole::Operator,
24352 NodeRequest::SetSessionTask {
24353 record_id: record_id.clone(),
24354 expected_revision: 1,
24355 target: SessionTaskTargetV1::Existing {
24356 task_id: assigned_id.clone(),
24357 },
24358 },
24359 )
24360 .await
24361 .unwrap();
24362 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24363 panic!("session task assignment returned another response");
24364 };
24365 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24366 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24367
24368 let cleared = process_request_inner(
24369 &shared,
24370 77,
24371 ClientRole::Operator,
24372 NodeRequest::SetSessionTask {
24373 record_id: record_id.clone(),
24374 expected_revision: 2,
24375 target: SessionTaskTargetV1::Clear,
24376 },
24377 )
24378 .await
24379 .unwrap();
24380 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24381 panic!("session task clear returned another response");
24382 };
24383 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24384 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24385 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24386 assert_eq!(
24387 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24388 cleared.updated_at_unix_ms,
24389 );
24390
24391 let persisted = std::fs::read(&state_path).unwrap();
24392 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24393 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24394 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24395 std::fs::remove_dir_all(root).unwrap();
24396 }
24397
24398 #[test]
24399 fn session_task_capability_strips_snapshots_events_and_replies() {
24400 let mut bound = record("claude", "sr-task-projection");
24401 bound.updated_at_unix_ms = 2;
24402 bound.task_binding = Some(SessionTaskBindingV1 {
24403 revision: 1,
24404 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24405 changed_at_unix_ms: 2,
24406 });
24407 let request = NodeRequest::SetSessionTask {
24408 record_id: bound.record_id.clone(),
24409 expected_revision: 1,
24410 target: SessionTaskTargetV1::Clear,
24411 };
24412 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24413 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24414 }));
24415 assert!(!request_uses_unnegotiated_capability(
24416 &request,
24417 &baseline_capabilities().unwrap(),
24418 ));
24419 assert!(request_uses_unnegotiated_capability(&request, &[]));
24420
24421 let mut snapshot = terminal_test_shared().snapshot();
24422 snapshot.session_records.push(bound.clone());
24423 let mut reply = ResponseEnvelope {
24424 request_id: 1,
24425 result: Ok(NodeResponse::Resync {
24426 event_sequence: 1,
24427 oldest_available_sequence: 1,
24428 snapshot,
24429 events: vec![NodeEventEnvelope {
24430 sequence: 1,
24431 event: NodeEvent::SessionRecordUpserted {
24432 record: bound.clone(),
24433 },
24434 }],
24435 }),
24436 };
24437 project_response_without_session_task_binding(&mut reply);
24438 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24439 panic!("task projection changed the response kind");
24440 };
24441 assert!(snapshot.session_records[0].task_binding.is_none());
24442 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24443 panic!("task projection changed the event kind");
24444 };
24445 assert!(record.task_binding.is_none());
24446
24447 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24448 sequence: 2,
24449 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24450 });
24451 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24452 panic!("task event projection changed the event kind");
24453 };
24454 assert!(record.task_binding.is_none());
24455
24456 let mut direct = ResponseEnvelope {
24457 request_id: 2,
24458 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24459 };
24460 project_response_without_session_task_binding(&mut direct);
24461 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24462 panic!("task reply projection changed the response kind");
24463 };
24464 assert!(record.task_binding.is_none());
24465 }
24466
24467 #[test]
24468 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24469 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24470 let workspace = WorkspaceConfig::new(
24471 WorkspaceId::new("primary").unwrap(),
24472 std::env::current_dir().unwrap(),
24473 )
24474 .unwrap();
24475 let config = NodeServerConfig::new(
24476 DEFAULT_NODE_ENDPOINT,
24477 "fixture-token",
24478 NodeId::new("node-1").unwrap(),
24479 [workspace],
24480 )
24481 .unwrap();
24482 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24483 }
24484
24485 #[cfg(all(feature = "fixture", windows))]
24486 #[test]
24487 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24488 let root = temporary_workspace_root("provider-bundle-argv-hold");
24489 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24490 std::fs::create_dir_all(&root).unwrap();
24491 std::fs::create_dir_all(&outside).unwrap();
24492 let proof_path = root.join("provider.proof");
24493 let release_signal = root.join("release.signal");
24494 let config = NodeServerConfig::new(
24495 format!(
24496 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24497 std::process::id(),
24498 ),
24499 "fixture-token",
24500 NodeId::new("provider-bundle-hold-node").unwrap(),
24501 [WorkspaceConfig::new(
24502 WorkspaceId::new("primary").unwrap(),
24503 std::env::current_dir().unwrap(),
24504 )
24505 .unwrap()],
24506 )
24507 .unwrap()
24508 .with_spawn_profiles(SpawnProfileRegistry::new([
24509 crate::protocol::SpawnProfileDefaults {
24510 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24511 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24512 .unwrap(),
24513 provider: agent("kimi"),
24514 mode: SessionMode::Pty,
24515 terminal_size: gate4agent_types::TerminalSize {
24516 rows: 24,
24517 columns: 80,
24518 },
24519 prompt: None,
24520 bundle_id: None,
24521 context_id: None,
24522 environment_profile_id: None,
24523 },
24524 ])
24525 .unwrap());
24526 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24527 config,
24528 agent("kimi"),
24529 proof_path.clone(),
24530 release_signal.clone(),
24531 )
24532 .unwrap();
24533 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24534
24535 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24536 agent("kimi"),
24537 proof_path.clone(),
24538 Some(release_signal.clone()),
24539 )
24540 .unwrap();
24541 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24542 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24543 let release_wait = wrapper
24544 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24545 .unwrap();
24546 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24547 assert!(proof_write < release_wait);
24548 assert!(release_wait < fixture_ready);
24549 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24550 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24551 assert_eq!(
24552 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24553 &[
24554 proof_path.to_string_lossy().into_owned(),
24555 release_signal.to_string_lossy().into_owned(),
24556 ],
24557 );
24558
24559 let error = NodeServer::provider_bundle_argv_fixture_spec(
24560 agent("kimi"),
24561 proof_path,
24562 Some(outside.join("release.signal")),
24563 )
24564 .unwrap_err();
24565 assert!(matches!(
24566 error,
24567 NodeServerError::Registry(message)
24568 if message == "provider bundle argv release path must share the proof parent"
24569 ));
24570 drop(server);
24571 std::fs::remove_dir_all(root).unwrap();
24572 std::fs::remove_dir_all(outside).unwrap();
24573 }
24574
24575 #[cfg(feature = "fixture")]
24576 #[test]
24577 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24578 let root = temporary_workspace_root("context-fixture-registry");
24579 std::fs::create_dir_all(&root).unwrap();
24580 let proof_path = root.join("context.proof");
24581 let config = NodeServerConfig::new(
24582 format!(
24583 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24584 std::process::id(),
24585 ),
24586 "fixture-token",
24587 NodeId::new("context-fixture-node").unwrap(),
24588 [WorkspaceConfig::new(
24589 WorkspaceId::new("primary").unwrap(),
24590 std::env::current_dir().unwrap(),
24591 )
24592 .unwrap()],
24593 )
24594 .unwrap();
24595 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24596 let mut enabled = server
24597 .shared
24598 .enabled_providers
24599 .iter()
24600 .map(AgentId::as_str)
24601 .collect::<Vec<_>>();
24602 enabled.sort_unstable();
24603 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24604 for provider in &server.shared.enabled_providers {
24605 assert!(server
24606 .shared
24607 .provider_adapter_contracts
24608 .iter()
24609 .any(|contract| {
24610 &contract.provider == provider
24611 && contract.family == AdapterFamily::History
24612 }));
24613 }
24614 drop(server);
24615 std::fs::remove_dir_all(root).unwrap();
24616 }
24617
24618 #[cfg(feature = "fixture")]
24619 #[test]
24620 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24621 let root = temporary_workspace_root("monitoring-context-fixture");
24622 std::fs::create_dir_all(&root).unwrap();
24623 let proof_path = root.join("context.proof");
24624 let standard = NodeServer::context_pack_fixture_catalog(
24625 proof_path.clone(),
24626 None,
24627 false,
24628 ).unwrap();
24629 let monitoring = NodeServer::context_pack_fixture_catalog(
24630 proof_path.clone(),
24631 None,
24632 true,
24633 ).unwrap();
24634 let claude = AgentId::new("claude").unwrap();
24635 let standard_claude = standard.get(&claude).unwrap();
24636 let monitoring_claude = monitoring.get(&claude).unwrap();
24637 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24641 assert_eq!(
24642 monitoring_claude.capabilities.adapters.history,
24643 standard_claude.capabilities.adapters.history,
24644 );
24645 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24646 let codex = AgentId::new("codex").unwrap();
24647 let standard_codex = standard.get(&codex).unwrap();
24648 let monitoring_codex = monitoring.get(&codex).unwrap();
24649 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24650 assert_eq!(
24651 monitoring_codex.capabilities.adapters.history,
24652 standard_codex.capabilities.adapters.history,
24653 );
24654 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24655 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24656 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24657 .unwrap();
24658 for required in [
24659 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24660 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24661 "@('role', 'text')",
24662 "g4a-private-provider-session-canary",
24663 "private-provider-login-identity",
24664 "$rawProof",
24665 "$env:GATE4AGENT_HOOK_URL",
24666 "g4a-private-provider-session-canary",
24667 ] {
24668 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24669 }
24670 for provider in ["grok", "kimi"] {
24671 let provider = AgentId::new(provider).unwrap();
24672 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24673 }
24674
24675 let config = NodeServerConfig::new(
24676 format!(
24677 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24678 std::process::id(),
24679 ),
24680 "fixture-token",
24681 NodeId::new("monitoring-context-fixture-node").unwrap(),
24682 [WorkspaceConfig::new(
24683 WorkspaceId::new("primary").unwrap(),
24684 std::env::current_dir().unwrap(),
24685 )
24686 .unwrap()],
24687 )
24688 .unwrap();
24689 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24690 assert!(server.shared.fixture_semantic_hook_policy);
24691 drop(server);
24692 std::fs::remove_dir_all(root).unwrap();
24693 }
24694
24695 #[cfg(feature = "fixture")]
24696 #[test]
24697 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24698 let root = temporary_workspace_root("kimi-context-only-fixture");
24699 std::fs::create_dir_all(&root).unwrap();
24700 let proof_path = root.join("context.proof");
24701 let standard = NodeServer::context_pack_fixture_catalog(
24702 proof_path.clone(),
24703 None,
24704 false,
24705 ).unwrap();
24706 let kimi = AgentId::new("kimi").unwrap();
24707 let context_only = NodeServer::context_pack_fixture_catalog(
24708 proof_path,
24709 Some(&kimi),
24710 false,
24711 )
24712 .unwrap();
24713
24714 let codex = AgentId::new("codex").unwrap();
24715 assert_eq!(
24716 standard.get(&codex).unwrap().launch.fixed_args,
24717 context_only.get(&codex).unwrap().launch.fixed_args,
24718 );
24719 let script = context_only
24720 .get(&kimi)
24721 .unwrap()
24722 .launch
24723 .fixed_args
24724 .iter()
24725 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24726 .expect("Kimi context-only validation script is missing");
24727 for required in [
24728 "$bundleArgs.Count -ne 0",
24729 "GATE4AGENT_CONTEXT_ROOT",
24730 "context-pack.json",
24731 "$entries.Count -ne 1",
24732 "g4a-context-pack-v1",
24733 "$document.PSObject.Properties['cwd']",
24734 "'claude', 'codex', 'grok', 'kimi'",
24735 "$hasUser",
24736 "$hasAssistant",
24737 "SHA256",
24738 "context-only",
24739 ] {
24740 assert!(script.contains(required), "missing validation: {required}");
24741 }
24742 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24743 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24744 }
24745 assert!(!standard
24746 .get(&kimi)
24747 .unwrap()
24748 .launch
24749 .fixed_args
24750 .iter()
24751 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24752
24753 std::fs::remove_dir_all(root).unwrap();
24754 }
24755
24756 #[cfg(all(feature = "fixture", windows))]
24757 #[tokio::test]
24758 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24759 let root = temporary_workspace_root("kimi-context-only-spawn");
24760 let workspace_root = root.join("workspace");
24761 std::fs::create_dir_all(&workspace_root).unwrap();
24762 let materialization_root = root.join("private-materializations");
24763 let proof_path = root.join("context.proof");
24764 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24765 let workspace_id = WorkspaceId::new("primary").unwrap();
24766 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24767 let endpoint = format!(
24768 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24769 std::process::id(),
24770 unix_time_ms(),
24771 );
24772 let spawn_profiles = SpawnProfileRegistry::new([
24773 crate::protocol::SpawnProfileDefaults {
24774 profile_id: profile_id.clone(),
24775 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24776 provider: agent("kimi"),
24777 mode: SessionMode::Pty,
24778 terminal_size: gate4agent_types::TerminalSize {
24779 rows: 30,
24780 columns: 120,
24781 },
24782 prompt: None,
24783 bundle_id: None,
24784 context_id: None,
24785 environment_profile_id: None,
24786 },
24787 ])
24788 .unwrap();
24789 let mut config = NodeServerConfig::new(
24790 &endpoint,
24791 "fixture-token",
24792 node_id.clone(),
24793 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24794 )
24795 .unwrap()
24796 .with_spawn_profiles(spawn_profiles)
24797 .with_session_environment_materialization(
24798 materialization_root,
24799 Arc::new(FixtureSecretResolver {
24800 deny: Arc::new(AtomicBool::new(false)),
24801 }),
24802 )
24803 .unwrap();
24804 config.fixture_raw_pty_runtime = true;
24805 let server = NodeServer::new_context_only_proof_fixture(
24806 config,
24807 agent("kimi"),
24808 proof_path.clone(),
24809 )
24810 .unwrap();
24811 let shared = Arc::clone(&server.shared);
24812 let shutdown = server.shutdown_handle();
24813
24814 let history = HistorySessionRecord {
24815 session_id: "context-only-source-history".to_owned(),
24816 title: None,
24817 cwd: None,
24818 model: None,
24819 message_count: 2,
24820 completed_turn_count: None,
24821 total_tokens: 0,
24822 messages: vec![
24823 gate4agent_types::HistoryMessageRecord {
24824 role: gate4agent_types::HistoryMessageRole::User,
24825 text: "continue the bounded parent run through native C2".to_owned(),
24826 },
24827 gate4agent_types::HistoryMessageRecord {
24828 role: gate4agent_types::HistoryMessageRole::Assistant,
24829 text: "the exact parent context is ready for the child run".to_owned(),
24830 },
24831 ],
24832 };
24833 let pack = NodeContextPack::export(
24834 ContextPackLineageReceipt {
24835 source_node_id: node_id.clone(),
24836 source_session: SessionAddress {
24837 workspace_id: workspace_id.clone(),
24838 session: SessionKey {
24839 instance_id: AgentInstanceId(71),
24840 generation: SessionGeneration(1),
24841 },
24842 },
24843 source_provider: agent("codex"),
24844 },
24845 &history,
24846 )
24847 .unwrap();
24848 let expected_context_hash = digest(&SHA256, pack.bytes())
24849 .as_ref()
24850 .iter()
24851 .map(|byte| format!("{byte:02x}"))
24852 .collect::<String>();
24853 let context = shared
24854 .context_catalog
24855 .write()
24856 .unwrap_or_else(|poisoned| poisoned.into_inner())
24857 .insert(pack)
24858 .unwrap();
24859 let spec = SpawnSpec {
24860 target: crate::protocol::SpawnTarget {
24861 node_id,
24862 workspace_id: workspace_id.clone(),
24863 worktree_id: None,
24864 },
24865 profile_id,
24866 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24867 "target-kimi-r1",
24868 ).unwrap(),
24869 overrides: crate::protocol::SpawnOverrides {
24870 context_id: crate::protocol::SpawnOverride::Set {
24871 value: context.id.clone(),
24872 },
24873 ..crate::protocol::SpawnOverrides::default()
24874 },
24875 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24876 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24877 required_capabilities: SpawnRequiredCapabilities::default(),
24878 };
24879 let resolved = match shared.resolve_spawn_spec(&spec) {
24880 Ok(resolved) => resolved,
24881 Err(error) => panic!(
24882 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24883 error.code,
24884 error.message,
24885 ),
24886 };
24887 let resolved_context = match shared.resolve_context(&resolved) {
24888 Ok(Some(resolved_context)) => resolved_context,
24889 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24890 Err(error) => panic!(
24891 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24892 error.code,
24893 error.message,
24894 ),
24895 };
24896 assert_eq!(resolved_context, context);
24897 let runtime_policy = match shared
24898 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24899 .await
24900 {
24901 Ok(runtime_policy) => runtime_policy,
24902 Err(error) => panic!(
24903 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24904 error.code,
24905 error.message,
24906 ),
24907 };
24908 assert!(runtime_policy.raw_pty_lifecycle);
24909
24910 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24911 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24912 agent("kimi"),
24913 TransportKind::Pty,
24914 vec![EnvMutation {
24915 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24916 value: Some(OsString::from("must-remain-rejected")),
24917 }],
24918 )
24919 .unwrap();
24920 assert_eq!(
24921 launch_control
24922 .install_native_launch_environment_overlay(
24923 AgentInstanceId(72),
24924 generic_overlay,
24925 )
24926 .unwrap_err(),
24927 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24928 );
24929
24930 let diagnostic_address = SessionAddress {
24931 workspace_id: workspace_id.clone(),
24932 session: SessionKey {
24933 instance_id: AgentInstanceId(73),
24934 generation: SessionGeneration(1),
24935 },
24936 };
24937 let (diagnostic_overlay, diagnostic_materialization) = match shared
24938 .prepare_session_materialization(
24939 &diagnostic_address,
24940 &agent("kimi"),
24941 SessionMode::Pty,
24942 None,
24943 None,
24944 Some(&resolved_context),
24945 None,
24946 )
24947 {
24948 Ok(Some(prepared)) => prepared,
24949 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24950 Err(error) => panic!(
24951 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24952 error.code,
24953 error.message,
24954 ),
24955 };
24956 let diagnostic_overlay = diagnostic_overlay
24957 .expect("Kimi context-only materialization omitted its environment overlay");
24958 assert!(matches!(
24959 &diagnostic_overlay,
24960 PreparedNativeLaunchOverlay::Environment(_),
24961 ));
24962 match shared.install_prepared_launch_overlay(
24963 diagnostic_address.session.instance_id,
24964 diagnostic_overlay,
24965 ) {
24966 Ok(None) => {}
24967 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24968 Err(error) => panic!(
24969 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24970 error.code,
24971 error.message,
24972 ),
24973 }
24974 assert!(launch_control.clear_native_instance_launch_overlay(
24975 diagnostic_address.session.instance_id,
24976 ));
24977 drop(diagnostic_materialization);
24978 assert!(shared
24979 .materializations
24980 .lock()
24981 .unwrap_or_else(|poisoned| poisoned.into_inner())
24982 .is_empty());
24983
24984 let node_task = tokio::spawn(server.run());
24985 let ready_client = timeout(Duration::from_secs(5), async {
24986 loop {
24987 match gate4agent_node_wire::LocalNodeClient::connect(
24988 &endpoint,
24989 &shared.node_id,
24990 ClientRole::Observer,
24991 "fixture-token",
24992 )
24993 .await
24994 {
24995 Ok(client) => break client,
24996 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24997 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24998 }
24999 }
25000 })
25001 .await
25002 .expect("Kimi context-only Node did not become ready");
25003 assert_eq!(
25004 ready_client
25005 .hello()
25006 .snapshot
25007 .provider_runtime_statuses
25008 .iter()
25009 .find(|status| status.provider().as_str() == "kimi")
25010 .expect("Kimi runtime status is missing")
25011 .mode(),
25012 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25013 );
25014 drop(ready_client);
25015
25016 let receipt = match shared.spawn_from_spec(spec).await {
25017 Ok(receipt) => receipt,
25018 Err(error) => panic!(
25019 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25020 error.code,
25021 error.message,
25022 ),
25023 };
25024 assert_eq!(receipt.provider, agent("kimi"));
25025 assert_eq!(receipt.context.as_ref(), Some(&context));
25026 assert!(receipt.context_binding_is_valid());
25027 timeout(Duration::from_secs(5), async {
25028 while !proof_path.is_file() {
25029 sleep(Duration::from_millis(10)).await;
25030 }
25031 })
25032 .await
25033 .expect("Kimi context-only fixture did not write its proof");
25034
25035 let proof = std::fs::read_to_string(&proof_path).unwrap();
25036 let lines = proof.lines().collect::<Vec<_>>();
25037 assert_eq!(lines.len(), 7);
25038 assert_eq!(lines[0], "context-only");
25039 assert!(Path::new(lines[1]).is_absolute());
25040 assert_ne!(
25041 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25042 std::fs::canonicalize(&workspace_root).unwrap(),
25043 );
25044 assert_eq!(
25045 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25046 std::fs::canonicalize(&workspace_root).unwrap(),
25047 );
25048 assert_eq!(lines[3], expected_context_hash);
25049 assert_eq!(lines[4], "g4a-context-pack-v1");
25050 assert_eq!(lines[5], "codex");
25051 assert_eq!(lines[6], "2");
25052 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25053 session.instance_id == receipt.session.session.instance_id
25054 && session.agent_id.as_str() == "kimi"
25055 }));
25056
25057 shutdown.request_shutdown().await.unwrap();
25058 timeout(Duration::from_secs(5), node_task)
25059 .await
25060 .unwrap()
25061 .unwrap()
25062 .unwrap();
25063 drop(shutdown);
25064 drop(shared);
25065 std::fs::remove_dir_all(root).unwrap();
25066 }
25067
25068 #[cfg(feature = "fixture")]
25069 #[test]
25070 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25071 let root = temporary_workspace_root("clean-exit-constructor");
25072 std::fs::create_dir_all(&root).unwrap();
25073 let config = construction_test_config("clean-exit-constructor")
25074 .with_state_path(root.join("node-state.json"))
25075 .unwrap();
25076 let server = NodeServer::new_clean_exit_fixture(
25077 config,
25078 root.clone(),
25079 root.join("started.marker"),
25080 root.join("release.signal"),
25081 )
25082 .unwrap();
25083 assert_eq!(
25084 server
25085 .shared
25086 .enabled_providers
25087 .iter()
25088 .map(AgentId::as_str)
25089 .collect::<Vec<_>>(),
25090 ["claude"],
25091 );
25092 drop(server);
25093 std::fs::remove_dir_all(root).unwrap();
25094 }
25095
25096 #[test]
25097 fn production_registry_provider_contract_manifest_is_exact() {
25098 let registry = active_registry().unwrap();
25099 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25100 assert_eq!(
25101 providers
25102 .iter()
25103 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25104 .collect::<Vec<_>>(),
25105 vec![
25106 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25107 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25108 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25109 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25110 ]
25111 );
25112 assert_eq!(
25113 adapters
25114 .iter()
25115 .map(|contract| (
25116 contract.provider.as_str(),
25117 contract.family,
25118 contract.adapter_id.as_str(),
25119 contract.revision.as_str(),
25120 ))
25121 .collect::<Vec<_>>(),
25122 vec![
25123 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25124 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25125 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25126 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25127 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25128 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25129 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25130 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25131 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25132 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25133 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25134 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25135 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25136 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25137 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25138 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25139 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25140 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25141 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25142 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25143 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25144 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25145 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25146 ]
25147 );
25148 }
25149
25150 fn construction_test_config(name: &str) -> NodeServerConfig {
25151 let workspace = WorkspaceConfig::new(
25152 WorkspaceId::new("primary").unwrap(),
25153 std::env::current_dir().unwrap(),
25154 )
25155 .unwrap();
25156 NodeServerConfig::new(
25157 format!(r"\\.\pipe\gate4agent-{name}"),
25158 "fixture-token",
25159 NodeId::new(name).unwrap(),
25160 [workspace],
25161 )
25162 .unwrap()
25163 }
25164
25165 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25166 let adapter = AdapterBinding::new(
25167 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25168 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25169 gate4agent_types::AdapterVerification::SyntheticFixture,
25170 )
25171 .unwrap();
25172 let agent_ids = ["claude", "codex", "kimi"]
25173 .into_iter()
25174 .map(|agent_id| AgentId::new(agent_id).unwrap())
25175 .collect::<Vec<_>>();
25176 let adapters = gate4agent_catalog::AdapterRegistry::new(
25177 [
25178 AdapterFamily::PtySemantic,
25179 AdapterFamily::Pipe,
25180 AdapterFamily::OneShot,
25181 AdapterFamily::Acp,
25182 AdapterFamily::Hook,
25183 AdapterFamily::ManagedHook,
25184 AdapterFamily::History,
25185 AdapterFamily::Resume,
25186 AdapterFamily::SessionOptions,
25187 AdapterFamily::CapabilityProbe,
25188 ]
25189 .into_iter()
25190 .map(|family| gate4agent_catalog::AdapterDescriptor {
25191 family,
25192 binding: adapter.clone(),
25193 agents: agent_ids.clone(),
25194 }),
25195 )
25196 .unwrap();
25197 let specs = agent_ids
25198 .iter()
25199 .map(|agent_id| {
25200 let agent_id = agent_id.as_str();
25201 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25202 spec.revision =
25203 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25204 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25205 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25206 adapter: adapter.clone(),
25207 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25208 launch_override: None,
25209 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25210 });
25211 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25212 adapter: adapter.clone(),
25213 launch_override: None,
25214 });
25215 spec.capabilities.adapters.hook = Some(adapter.clone());
25216 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25217 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25218 spec.capabilities.adapters.history = Some(adapter.clone());
25219 spec.capabilities.adapters.resume = Some(adapter.clone());
25220 spec.capabilities.adapters.session_options = Some(adapter.clone());
25221 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25222 spec
25223 })
25224 .collect::<Vec<_>>();
25225 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25226 }
25227
25228 #[test]
25236 fn node_server_construction_accepts_the_production_manifest() {
25237 let registry = active_registry().unwrap();
25238 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25239 assert_eq!(providers.len(), registry.iter().count());
25240 assert!(!providers.is_empty());
25241 let server = NodeServer::new_with_registry(
25242 construction_test_config("manifest-production-bound"),
25243 registry,
25244 )
25245 .unwrap();
25246 drop(server);
25247 }
25248
25249 #[test]
25250 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25251 let registry = maximum_size_provider_manifest_registry();
25252 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25253 assert_eq!(providers.len(), 3);
25254 assert_eq!(adapters.len(), 30);
25255 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25256 let error = match NodeServer::new_with_registry(
25257 construction_test_config("manifest-handshake-overflow"),
25258 registry,
25259 ) {
25260 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25261 Err(error) => error,
25262 };
25263 assert!(matches!(
25264 error,
25265 NodeServerError::ProviderContractManifest(message)
25266 if message.contains("authentication binding length")
25267 && message.contains("8192-byte limit")
25268 ));
25269 }
25270
25271 #[test]
25272 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25273 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25274 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25275 let registry = AgentRegistry::new([spec]).unwrap();
25276 assert!(matches!(
25277 provider_contract_manifest(®istry),
25278 Err(NodeServerError::ProviderContractManifest(message))
25279 if message.contains("exceeds the 128-byte limit")
25280 ));
25281 }
25282
25283 #[test]
25284 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25285 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25286 spec.revision = "invalid revision".to_owned();
25287 let registry = AgentRegistry::new([spec]).unwrap();
25288 assert!(matches!(
25289 provider_contract_manifest(®istry),
25290 Err(NodeServerError::ProviderContractManifest(message))
25291 if message.contains("bounded lowercase ASCII")
25292 ));
25293 }
25294
25295 #[test]
25296 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25297 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25298 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25299 let conflicting_binding = builtin_registry()
25300 .get_by_id("codex")
25301 .unwrap()
25302 .capabilities
25303 .adapters
25304 .one_shot
25305 .clone()
25306 .unwrap();
25307 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25308 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25309 pipe.adapter = transport_binding;
25310 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25311 let registry = AgentRegistry::new([spec]).unwrap();
25312 assert!(matches!(
25313 provider_contract_manifest(®istry),
25314 Err(NodeServerError::ProviderContractManifest(message))
25315 if message.contains("conflicting OneShot adapter bindings")
25316 ));
25317 }
25318
25319 #[test]
25320 fn public_node_failures_expose_only_fixed_categories() {
25321 let provider = failure(
25322 NodeFailureCode::BackendOperationFailed,
25323 r"provider bearer-secret failed at C:\private\session.jsonl",
25324 );
25325 assert_eq!(provider.message, "backend-operation-failed");
25326 assert!(!provider.message.contains("bearer-secret"));
25327 assert!(!provider.message.contains(r"C:\private"));
25328 let persistence = persistence_failure(io::Error::new(
25329 io::ErrorKind::Other,
25330 r"state write failed at C:\private\state-v1.json",
25331 ));
25332 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25333 assert!(!persistence.message.contains(r"C:\private"));
25334
25335 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25336 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25337 assert_eq!(
25338 generic.to_string(),
25339 "node durable state failed: durable-state-load-failed",
25340 );
25341 }
25342 }
25343
25344 #[test]
25345 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25346 let sequence = b"\x1b[1;5D".to_vec();
25347 assert_eq!(
25348 terminal_bytes_action(sequence.clone()).unwrap(),
25349 InputAction::TerminalBytes(sequence),
25350 );
25351 assert_eq!(
25352 terminal_bytes_action(Vec::new()).unwrap_err().code,
25353 NodeFailureCode::InvalidRequest,
25354 );
25355 assert_eq!(
25356 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25357 .unwrap_err()
25358 .code,
25359 NodeFailureCode::InvalidRequest,
25360 );
25361 }
25362
25363 #[test]
25364 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25365 let root = temporary_workspace_root("exclusive-state-owner");
25366 std::fs::create_dir_all(&root).unwrap();
25367 let workspace = WorkspaceConfig::new(
25368 WorkspaceId::new("primary").unwrap(),
25369 &root,
25370 )
25371 .unwrap();
25372 let state_path = root.join("state-v1.json");
25373 let endpoint = format!(
25374 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25375 std::process::id(),
25376 std::time::SystemTime::now()
25377 .duration_since(std::time::UNIX_EPOCH)
25378 .unwrap()
25379 .as_nanos(),
25380 );
25381 let config = NodeServerConfig::new(
25382 endpoint,
25383 "fixture-token",
25384 NodeId::new("node-1").unwrap(),
25385 [workspace],
25386 )
25387 .unwrap()
25388 .with_state_path(&state_path)
25389 .unwrap();
25390
25391 let first = NodeServer::new(config.clone()).unwrap();
25392 let error = match NodeServer::new(config.clone()) {
25393 Ok(_) => panic!("a second node server acquired the same durable state path"),
25394 Err(error) => error,
25395 };
25396 assert!(matches!(
25397 &error,
25398 NodeServerError::DurableState(error)
25399 if error.kind() == io::ErrorKind::WouldBlock
25400 ));
25401 assert_eq!(
25402 error.to_string(),
25403 "node durable state failed: durable-state-lock-failed",
25404 );
25405 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25406 drop(first);
25407 let second = NodeServer::new(config).unwrap();
25408 drop(second);
25409 std::fs::remove_dir_all(root).unwrap();
25410 }
25411
25412 #[test]
25413 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25414 let catalog = active_registry().unwrap();
25415 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25416 let workspace_id = WorkspaceId::new("primary").unwrap();
25417 let workspace = WorkspaceConfig::new(
25418 workspace_id.clone(),
25419 std::env::current_dir().unwrap(),
25420 )
25421 .unwrap();
25422 let shared = NodeShared::new(
25423 handle,
25424 "fixture-token".to_owned(),
25425 NodeId::new("node-1").unwrap(),
25426 vec![workspace.clone()],
25427 vec![agent("claude"), agent("codex"), agent("kimi")],
25428 );
25429 let instance_id = AgentInstanceId(17);
25430 let generation = SessionGeneration(3);
25431 assert!(shared.address_for(instance_id, generation).is_none());
25432 shared.bind_session(instance_id, workspace_id.clone(), generation);
25433 let address = SessionAddress {
25434 workspace_id,
25435 session: SessionKey { instance_id, generation },
25436 };
25437 assert_eq!(
25438 shared.workspace_root(&address.workspace_id).unwrap(),
25439 workspace.canonical_root,
25440 );
25441 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25442 shared.remove_binding(&address);
25443 assert!(shared.address_for(instance_id, generation).is_none());
25444 }
25445
25446 #[test]
25447 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25448 let address = SessionAddress {
25449 workspace_id: WorkspaceId::new("primary").unwrap(),
25450 session: SessionKey {
25451 instance_id: AgentInstanceId(7),
25452 generation: SessionGeneration(2),
25453 },
25454 };
25455 let record_id = SessionRecordId::new("record-a").unwrap();
25456 let size = gate4agent_types::TerminalSize {
25457 rows: 24,
25458 columns: 80,
25459 };
25460 let address_requests = [
25461 NodeRequest::Resume {
25462 session: address.clone(),
25463 terminal_size: size,
25464 initial_prompt: None,
25465 },
25466 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25467 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25468 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25469 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25470 NodeRequest::TerminalControl {
25471 session: address.clone(),
25472 control: TerminalControl::Enter,
25473 },
25474 NodeRequest::Resize { session: address.clone(), size },
25475 NodeRequest::Interrupt { session: address.clone() },
25476 NodeRequest::Stop { session: address.clone(), force: false },
25477 NodeRequest::Remove { session: address },
25478 ];
25479 for request in &address_requests {
25480 assert!(request_requires_open_provider_ids_with(
25481 request,
25482 |_| Some(agent("third-party-agent")),
25483 |_| Some(agent("claude")),
25484 ));
25485 assert!(!request_requires_open_provider_ids_with(
25486 request,
25487 |_| Some(agent("claude")),
25488 |_| Some(agent("claude")),
25489 ));
25490 assert!(request_requires_open_provider_ids_with(
25491 request,
25492 |_| None,
25493 |_| Some(agent("claude")),
25494 ));
25495 }
25496
25497 let record_requests = [
25498 NodeRequest::RenameSessionRecord {
25499 record_id: record_id.clone(),
25500 display_name: "renamed".to_owned(),
25501 },
25502 NodeRequest::ResumeSessionRecord {
25503 record_id: record_id.clone(),
25504 terminal_size: size,
25505 initial_prompt: None,
25506 },
25507 NodeRequest::ForgetSessionRecord { record_id },
25508 ];
25509 for request in &record_requests {
25510 assert!(request_requires_open_provider_ids_with(
25511 request,
25512 |_| Some(agent("claude")),
25513 |_| Some(agent("grok")),
25514 ));
25515 assert!(!request_requires_open_provider_ids_with(
25516 request,
25517 |_| Some(agent("claude")),
25518 |_| Some(agent("kimi")),
25519 ));
25520 assert!(request_requires_open_provider_ids_with(
25521 request,
25522 |_| Some(agent("claude")),
25523 |_| None,
25524 ));
25525 }
25526
25527 assert!(request_requires_open_provider_ids_with(
25528 &NodeRequest::Spawn {
25529 workspace_id: WorkspaceId::new("primary").unwrap(),
25530 provider: agent("third-party-agent"),
25531 mode: SessionMode::Pty,
25532 terminal_size: size,
25533 initial_prompt: None,
25534 },
25535 |_| None,
25536 |_| None,
25537 ));
25538 assert!(!request_requires_open_provider_ids_with(
25539 &NodeRequest::Snapshot,
25540 |_| None,
25541 |_| None,
25542 ));
25543 assert!(request_requires_open_provider_ids_with(
25544 &NodeRequest::ForgetContextPack {
25545 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25546 },
25547 |_| None,
25548 |_| None,
25549 ));
25550 }
25551
25552 #[test]
25553 fn legacy_projection_filters_open_record_replies_and_event_history() {
25554 let catalog = active_registry().unwrap();
25555 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25556 let workspace = WorkspaceConfig::new(
25557 WorkspaceId::new("primary").unwrap(),
25558 std::env::current_dir().unwrap(),
25559 )
25560 .unwrap();
25561 let shared = NodeShared::new(
25562 handle,
25563 "fixture-token".to_owned(),
25564 NodeId::new("node-1").unwrap(),
25565 vec![workspace],
25566 vec![agent("claude"), agent("grok")],
25567 );
25568 let legacy = record("claude", "legacy-record");
25569 let open = record("grok", "open-record");
25570 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25571 record: legacy.clone(),
25572 });
25573 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25574 record: open.clone(),
25575 });
25576 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25577 record_id: legacy.record_id.clone(),
25578 });
25579 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25580 record_id: open.record_id.clone(),
25581 });
25582 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25583 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25584 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25585 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25586
25587 let mut record_reply = ResponseEnvelope {
25588 request_id: 1,
25589 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25590 };
25591 project_response_legacy_provider_ids(&shared, &mut record_reply);
25592 assert!(matches!(
25593 record_reply.result,
25594 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25595 ));
25596
25597 let mut resync = ResponseEnvelope {
25598 request_id: 2,
25599 result: Ok(NodeResponse::Resync {
25600 event_sequence: open_removed.sequence,
25601 oldest_available_sequence: 1,
25602 snapshot: shared.snapshot(),
25603 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25604 }),
25605 };
25606 project_response_legacy_provider_ids(&shared, &mut resync);
25607 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25608 panic!("legacy projection changed the resync response kind");
25609 };
25610 assert!(snapshot.session_records.iter().all(|item| {
25611 provider_id_is_legacy(&item.provider)
25612 }));
25613 assert_eq!(events.len(), 2);
25614 assert!(events.iter().all(|event| matches!(
25615 &event.event,
25616 NodeEvent::SessionRecordUpserted { record }
25617 if provider_id_is_legacy(&record.provider)
25618 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25619 }
25620
25621 #[test]
25622 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25623 let catalog = active_registry().unwrap();
25624 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25625 let workspace_id = WorkspaceId::new("primary").unwrap();
25626 let workspace = WorkspaceConfig::new(
25627 workspace_id.clone(),
25628 std::env::current_dir().unwrap(),
25629 )
25630 .unwrap();
25631 let shared = NodeShared::new(
25632 handle,
25633 "fixture-token".to_owned(),
25634 NodeId::new("node-1").unwrap(),
25635 vec![workspace],
25636 vec![agent("claude")],
25637 );
25638 for index in 0..CONTROL_SESSIONS_MAX {
25639 shared.bind_session(
25640 AgentInstanceId((index + 1) as u64),
25641 workspace_id.clone(),
25642 SessionGeneration(1),
25643 );
25644 }
25645 let error = shared.ensure_binding_capacity().unwrap_err();
25646 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25647 }
25648
25649 #[tokio::test]
25650 async fn provider_spawn_admission_rejects_before_session_mutation() {
25651 let catalog = active_registry().unwrap();
25652 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25653 let workspace = WorkspaceConfig::new(
25654 WorkspaceId::new("primary").unwrap(),
25655 std::env::current_dir().unwrap(),
25656 )
25657 .unwrap();
25658 let mut shared = NodeShared::new(
25659 handle,
25660 "fixture-token".to_owned(),
25661 NodeId::new("node-provider-admission").unwrap(),
25662 vec![workspace],
25663 vec![agent("claude")],
25664 );
25665 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25666 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25667 ])
25668 .unwrap();
25669 let terminal_size = gate4agent_types::TerminalSize {
25670 rows: 24,
25671 columns: 80,
25672 };
25673
25674 let unavailable = shared
25675 .spawn_session(
25676 WorkspaceId::new("primary").unwrap(),
25677 agent("claude"),
25678 SessionMode::Pty,
25679 terminal_size,
25680 None,
25681 )
25682 .await
25683 .unwrap_err();
25684 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25685 assert_eq!(unavailable.message, "backend-operation-failed");
25686
25687 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25688 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25689 agent("claude"),
25690 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25691 ),
25692 ])
25693 .unwrap();
25694 let semantic = shared
25695 .spawn_session(
25696 WorkspaceId::new("primary").unwrap(),
25697 agent("claude"),
25698 SessionMode::Inline,
25699 terminal_size,
25700 None,
25701 )
25702 .await
25703 .unwrap_err();
25704 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25705 assert_eq!(semantic.message, "unsupported-capability");
25706
25707 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25708 assert!(shared
25709 .session_bindings
25710 .lock()
25711 .unwrap_or_else(|poisoned| poisoned.into_inner())
25712 .is_empty());
25713 assert!(shared
25714 .session_records
25715 .lock()
25716 .unwrap_or_else(|poisoned| poisoned.into_inner())
25717 .records
25718 .is_empty());
25719 assert!(shared.handle.snapshot().sessions.is_empty());
25720 }
25721
25722 #[tokio::test]
25723 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25724 let catalog = active_registry().unwrap();
25725 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25726 let workspace_id = WorkspaceId::new("primary").unwrap();
25727 let workspace = WorkspaceConfig::new(
25728 workspace_id.clone(),
25729 std::env::current_dir().unwrap(),
25730 )
25731 .unwrap();
25732 let shared = NodeShared::new(
25733 handle,
25734 "fixture-token".to_owned(),
25735 NodeId::new("node-raw-spawn-record").unwrap(),
25736 vec![workspace],
25737 vec![agent("claude")],
25738 );
25739 let address = SessionAddress {
25740 workspace_id,
25741 session: SessionKey {
25742 instance_id: AgentInstanceId(71),
25743 generation: SessionGeneration::default(),
25744 },
25745 };
25746
25747 let record_id = shared
25748 .bind_spawn_session(
25749 &address,
25750 agent("claude"),
25751 SessionMode::Pty,
25752 ProviderRuntimePolicy::raw_pty(),
25753 SpawnRecordPolicy::Always,
25754 None,
25755 )
25756 .unwrap()
25757 .unwrap();
25758 shared
25759 .handle
25760 .dispatch(shared.prepare_command(ControlCommand::Register {
25761 instance_id: address.session.instance_id,
25762 agent_id: agent("claude"),
25763 transport: TransportKind::Pty,
25764 }))
25765 .unwrap();
25766 runtime.tick().await;
25767
25768 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25769 session.instance_id == address.session.instance_id
25770 && session.generation == address.session.generation
25771 }));
25772 let record = shared.record(&record_id).unwrap();
25773 assert_eq!(record.display_name, "claude #1");
25774 assert_eq!(record.state, ManagedSessionState::Live);
25775 assert_eq!(record.active_session.as_ref(), Some(&address));
25776 assert!(record.provider_session.is_none());
25777 let binding_record_id = shared
25778 .session_bindings
25779 .lock()
25780 .unwrap_or_else(|poisoned| poisoned.into_inner())
25781 .get(&address.session.instance_id)
25782 .and_then(|binding| binding.record_id.clone());
25783 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25784
25785 let renamed = shared
25786 .rename_session_record(&record_id, "raw review".to_owned())
25787 .unwrap();
25788 assert_eq!(renamed.record_id, record_id);
25789 assert_eq!(renamed.display_name, "raw review");
25790 shared.reconcile_managed_record(
25791 &record_id,
25792 &address,
25793 &ControlEvent {
25794 sequence: 1,
25795 command_id: None,
25796 instance_id: address.session.instance_id,
25797 generation: address.session.generation,
25798 event: ControlEventKind::Running { process_id: None },
25799 },
25800 false,
25801 false,
25802 );
25803 let running = shared.record(&record_id).unwrap();
25804 assert_eq!(running.state, ManagedSessionState::Live);
25805 assert_eq!(running.active_session.as_ref(), Some(&address));
25806 assert_eq!(running.display_name, "raw review");
25807
25808 shared.reconcile_managed_record(
25809 &record_id,
25810 &address,
25811 &ControlEvent {
25812 sequence: 2,
25813 command_id: None,
25814 instance_id: address.session.instance_id,
25815 generation: address.session.generation,
25816 event: ControlEventKind::Exited {
25817 exit_code: Some(0),
25818 forced: false,
25819 },
25820 },
25821 false,
25822 false,
25823 );
25824 let detached = shared.record(&record_id).unwrap();
25825 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25826 assert!(detached.active_session.is_none());
25827 assert_eq!(detached.display_name, "raw review");
25828 let renamed_detached = shared
25829 .rename_session_record(&record_id, "raw review detached".to_owned())
25830 .unwrap();
25831 assert_eq!(renamed_detached.record_id, record_id);
25832 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25833 assert!(renamed_detached.active_session.is_none());
25834 assert_eq!(renamed_detached.display_name, "raw review detached");
25835 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25836 }
25837
25838 #[test]
25839 fn legacy_raw_spawn_binding_remains_untracked() {
25840 let catalog = active_registry().unwrap();
25841 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25842 let workspace_id = WorkspaceId::new("primary").unwrap();
25843 let workspace = WorkspaceConfig::new(
25844 workspace_id.clone(),
25845 std::env::current_dir().unwrap(),
25846 )
25847 .unwrap();
25848 let shared = NodeShared::new(
25849 handle,
25850 "fixture-token".to_owned(),
25851 NodeId::new("node-legacy-raw-spawn").unwrap(),
25852 vec![workspace],
25853 vec![agent("claude")],
25854 );
25855 let address = SessionAddress {
25856 workspace_id,
25857 session: SessionKey {
25858 instance_id: AgentInstanceId(72),
25859 generation: SessionGeneration::default(),
25860 },
25861 };
25862
25863 let record_id = shared
25864 .bind_spawn_session(
25865 &address,
25866 agent("claude"),
25867 SessionMode::Pty,
25868 ProviderRuntimePolicy::raw_pty(),
25869 SpawnRecordPolicy::ProviderIdentityOnly,
25870 None,
25871 )
25872 .unwrap();
25873
25874 assert!(record_id.is_none());
25875 let binding_record_id = shared
25876 .session_bindings
25877 .lock()
25878 .unwrap_or_else(|poisoned| poisoned.into_inner())
25879 .get(&address.session.instance_id)
25880 .and_then(|binding| binding.record_id.clone());
25881 assert!(binding_record_id.is_none());
25882 assert!(shared.snapshot().session_records.is_empty());
25883 }
25884
25885 #[tokio::test]
25886 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25887 let catalog = active_registry().unwrap();
25888 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25889 let workspace_id = WorkspaceId::new("primary").unwrap();
25890 let workspace = WorkspaceConfig::new(
25891 workspace_id.clone(),
25892 std::env::current_dir().unwrap(),
25893 )
25894 .unwrap();
25895 let mut shared = NodeShared::new(
25896 handle,
25897 "fixture-token".to_owned(),
25898 NodeId::new("node-raw-semantic-gate").unwrap(),
25899 vec![workspace],
25900 vec![agent("claude")],
25901 );
25902 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25903 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25904 ])
25905 .unwrap();
25906 shared
25907 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25908 .unwrap();
25909 let address = SessionAddress {
25910 workspace_id,
25911 session: SessionKey {
25912 instance_id: AgentInstanceId(72),
25913 generation: SessionGeneration::default(),
25914 },
25915 };
25916 shared.bind_session_with_policy(
25917 &address,
25918 ProviderRuntimePolicy::raw_pty(),
25919 None,
25920 );
25921 shared
25922 .handle
25923 .dispatch(shared.prepare_command(ControlCommand::Register {
25924 instance_id: address.session.instance_id,
25925 agent_id: agent("claude"),
25926 transport: TransportKind::Pty,
25927 }))
25928 .unwrap();
25929 runtime.tick().await;
25930 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25931 let revision_before = shared.handle.snapshot().revision;
25932 let history_sequence_before = shared.current_sequence();
25933 let terminal_size = gate4agent_types::TerminalSize {
25934 rows: 24,
25935 columns: 80,
25936 };
25937
25938 for request in [
25939 NodeRequest::Prompt {
25940 session: address.clone(),
25941 text: "prompt".to_owned(),
25942 },
25943 NodeRequest::Paste {
25944 session: address.clone(),
25945 text: "paste".to_owned(),
25946 },
25947 NodeRequest::Resume {
25953 session: address.clone(),
25954 terminal_size,
25955 initial_prompt: Some("resume".to_owned()),
25956 },
25957 ] {
25958 let error = process_request_inner(
25959 &shared,
25960 77,
25961 ClientRole::Operator,
25962 request,
25963 )
25964 .await
25965 .unwrap_err();
25966 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25967 }
25968
25969 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25970 assert_eq!(shared.handle.snapshot().revision, revision_before);
25971 assert_eq!(shared.current_sequence(), history_sequence_before);
25972 let bindings = shared
25973 .session_bindings
25974 .lock()
25975 .unwrap_or_else(|poisoned| poisoned.into_inner());
25976 let binding = bindings.get(&address.session.instance_id).unwrap();
25977 assert_eq!(binding.generation, address.session.generation);
25978 assert!(binding.pending_resume.is_none());
25979 assert!(binding.record_id.is_none());
25980 }
25981
25982 #[tokio::test]
25983 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25984 let catalog = active_registry().unwrap();
25985 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25986 let primary = WorkspaceConfig::new(
25987 WorkspaceId::new("primary").unwrap(),
25988 std::env::current_dir().unwrap(),
25989 )
25990 .unwrap();
25991 let shared = NodeShared::new(
25992 handle,
25993 "fixture-token".to_owned(),
25994 NodeId::new("node-1").unwrap(),
25995 vec![primary],
25996 vec![agent("claude")],
25997 );
25998 shared
25999 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26000 .unwrap();
26001 let before = shared.snapshot();
26002 let error = process_request_inner(
26003 &shared,
26004 77,
26005 ClientRole::Operator,
26006 NodeRequest::RegisterWorkspace {
26007 workspace_id: WorkspaceId::new("foreign").unwrap(),
26008 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26009 },
26010 )
26011 .await
26012 .unwrap_err();
26013 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26014 assert_eq!(error.message, "invalid-request");
26015 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26016 }
26017
26018 #[tokio::test]
26019 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26020 let catalog = active_registry().unwrap();
26021 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26022 let primary_id = WorkspaceId::new("primary").unwrap();
26023 let primary = WorkspaceConfig::new(
26024 primary_id.clone(),
26025 std::env::current_dir().unwrap(),
26026 )
26027 .unwrap();
26028 let shared = NodeShared::new(
26029 handle,
26030 "fixture-token".to_owned(),
26031 NodeId::new("node-1").unwrap(),
26032 vec![primary.clone()],
26033 vec![agent("claude")],
26034 );
26035 let secondary_id = WorkspaceId::new("secondary").unwrap();
26036 let secondary = shared
26037 .register_workspace(
26038 secondary_id.clone(),
26039 std::env::temp_dir().to_string_lossy().into_owned(),
26040 )
26041 .await
26042 .unwrap();
26043 assert_eq!(secondary.workspace_id, secondary_id);
26044 assert!(secondary.sessions.is_empty());
26045 assert_eq!(shared.snapshot().workspaces.len(), 2);
26046
26047 let duplicate_id = shared
26048 .register_workspace(
26049 secondary_id.clone(),
26050 primary.canonical_root().to_owned(),
26051 )
26052 .await
26053 .unwrap_err();
26054 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26055 let duplicate_root = shared
26056 .register_workspace(
26057 WorkspaceId::new("duplicate-root").unwrap(),
26058 windows_path_text(&secondary.canonical_root).to_owned(),
26059 )
26060 .await
26061 .unwrap_err();
26062 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26063
26064 let address = SessionAddress {
26065 workspace_id: secondary_id.clone(),
26066 session: SessionKey {
26067 instance_id: AgentInstanceId(41),
26068 generation: SessionGeneration(1),
26069 },
26070 };
26071 shared.bind_session(
26072 address.session.instance_id,
26073 secondary_id.clone(),
26074 address.session.generation,
26075 );
26076 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26077 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26078 shared.remove_binding(&address);
26079 shared.unregister_workspace(&secondary_id).unwrap();
26080 assert_eq!(shared.snapshot().workspaces.len(), 1);
26081
26082 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26083 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26084 let history = shared
26085 .history
26086 .lock()
26087 .unwrap_or_else(|poisoned| poisoned.into_inner());
26088 assert!(matches!(
26089 &history.events[0],
26090 NodeEventEnvelope {
26091 sequence: 1,
26092 event: NodeEvent::WorkspaceAdded { workspace },
26093 } if workspace.workspace_id == secondary_id
26094 ));
26095 assert!(matches!(
26096 &history.events[1],
26097 NodeEventEnvelope {
26098 sequence: 2,
26099 event: NodeEvent::WorkspaceRemoved { workspace_id },
26100 } if workspace_id == &secondary_id
26101 ));
26102 }
26103
26104 #[tokio::test]
26105 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26106 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26107 std::fs::create_dir_all(&secondary_root).unwrap();
26108 let catalog = active_registry().unwrap();
26109 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26110 let primary_id = WorkspaceId::new("primary").unwrap();
26111 let secondary_id = WorkspaceId::new("secondary").unwrap();
26112 let shared = NodeShared::new(
26113 handle,
26114 "fixture-token".to_owned(),
26115 NodeId::new("node-session-reference-index").unwrap(),
26116 vec![
26117 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26118 .unwrap(),
26119 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26120 ],
26121 vec![agent("claude"), agent("codex")],
26122 );
26123 let identity = ProviderSessionIdentity {
26124 key: gate4agent_types::ProviderSessionKey::SessionId,
26125 id: "provider-session-42".to_owned(),
26126 transcript_path: None,
26127 };
26128 let index_request = |workspace_id: WorkspaceId,
26129 provider: AgentId,
26130 identity: ProviderSessionIdentity,
26131 display_name: &str| {
26132 NodeRequest::IndexProviderSession {
26133 workspace_id,
26134 provider,
26135 identity,
26136 display_name: display_name.to_owned(),
26137 }
26138 };
26139 let request = index_request(
26140 primary_id.clone(),
26141 agent("claude"),
26142 identity.clone(),
26143 "release shepherd",
26144 );
26145 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26146 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26147 }));
26148 let observer = process_request_inner(
26149 &shared,
26150 77,
26151 ClientRole::Observer,
26152 request.clone(),
26153 )
26154 .await
26155 .unwrap_err();
26156 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26157 let uncontrolled = process_request_inner(
26158 &shared,
26159 77,
26160 ClientRole::Operator,
26161 request.clone(),
26162 )
26163 .await
26164 .unwrap_err();
26165 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26166 shared
26167 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26168 .unwrap();
26169
26170 let indexed = process_request_inner(
26171 &shared,
26172 77,
26173 ClientRole::Operator,
26174 request.clone(),
26175 )
26176 .await
26177 .unwrap();
26178 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26179 panic!("provider session reference index returned another response");
26180 };
26181 assert_eq!(record.state, ManagedSessionState::Dormant);
26182 assert_eq!(record.mode, SessionMode::Pty);
26183 assert_eq!(record.workspace_id, primary_id);
26184 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26185 assert!(record.active_session.is_none());
26186 assert!(record.environment_profile.is_none());
26187 assert!(record.bundle.is_none());
26188 assert!(record.context.is_none());
26189
26190 let repeated = process_request_inner(
26191 &shared,
26192 77,
26193 ClientRole::Operator,
26194 index_request(
26195 primary_id.clone(),
26196 agent("claude"),
26197 identity.clone(),
26198 "ignored replacement",
26199 ),
26200 )
26201 .await
26202 .unwrap();
26203 assert!(matches!(
26204 repeated,
26205 NodeResponse::ProviderSessionIndexed { record: ref existing }
26206 if existing.record_id == record.record_id
26207 && existing.display_name == "release shepherd"
26208 ));
26209 let conflicting_workspace = process_request_inner(
26210 &shared,
26211 77,
26212 ClientRole::Operator,
26213 index_request(
26214 secondary_id,
26215 agent("claude"),
26216 identity.clone(),
26217 "release shepherd",
26218 ),
26219 )
26220 .await
26221 .unwrap_err();
26222 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26223 let conflicting_provider = process_request_inner(
26224 &shared,
26225 77,
26226 ClientRole::Operator,
26227 index_request(
26228 primary_id.clone(),
26229 agent("codex"),
26230 identity.clone(),
26231 "release shepherd",
26232 ),
26233 )
26234 .await
26235 .unwrap_err();
26236 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26237 let transcript_rejected = process_request_inner(
26238 &shared,
26239 77,
26240 ClientRole::Operator,
26241 index_request(
26242 primary_id,
26243 agent("claude"),
26244 ProviderSessionIdentity {
26245 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26246 ..identity
26247 },
26248 "release shepherd",
26249 ),
26250 )
26251 .await
26252 .unwrap_err();
26253 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26254 let upserts = shared
26255 .history
26256 .lock()
26257 .unwrap_or_else(|poisoned| poisoned.into_inner())
26258 .events
26259 .iter()
26260 .filter(|event| matches!(
26261 &event.event,
26262 NodeEvent::SessionRecordUpserted { record: published }
26263 if published.record_id == record.record_id
26264 ))
26265 .count();
26266 assert_eq!(upserts, 1);
26267 std::fs::remove_dir_all(secondary_root).unwrap();
26268 }
26269
26270 #[tokio::test]
26271 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26272 let fixture_root = temporary_workspace_root("standalone-independent");
26273 let target = fixture_root.join("independent");
26274 std::fs::create_dir_all(&fixture_root).unwrap();
26275 let catalog = active_registry().unwrap();
26276 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26277 let selected = WorkspaceConfig::new(
26278 WorkspaceId::new("primary").unwrap(),
26279 std::env::current_dir().unwrap(),
26280 )
26281 .unwrap();
26282 let selected_root = selected.canonical_root().to_owned();
26283 let shared = NodeShared::new(
26284 handle,
26285 "fixture-token".to_owned(),
26286 NodeId::new("node-standalone").unwrap(),
26287 vec![selected],
26288 vec![agent("claude")],
26289 );
26290 let workspace_id = WorkspaceId::new("independent").unwrap();
26291 let request = NodeRequest::CreateStandaloneWorkspace {
26292 workspace_id: workspace_id.clone(),
26293 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26294 initial_branch: Some("main".to_owned()),
26295 };
26296 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26297 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26298 }));
26299 assert!(!request_uses_unnegotiated_capability(
26300 &request,
26301 &baseline_capabilities().unwrap(),
26302 ));
26303 assert!(request_uses_unnegotiated_capability(&request, &[]));
26304
26305 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26306 .await
26307 .unwrap_err();
26308 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26309 assert!(!target.exists());
26310 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26311 .await
26312 .unwrap_err();
26313 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26314 assert!(!target.exists());
26315 shared
26316 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26317 .unwrap();
26318
26319 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26320 .await
26321 .unwrap();
26322 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26323 panic!("standalone workspace creation returned another response");
26324 };
26325 assert_eq!(workspace.workspace_id, workspace_id);
26326 assert!(target.join(".git").is_dir());
26327 let branch = run_git_bounded(
26328 target.to_str().unwrap(),
26329 &["branch", "--show-current"],
26330 GIT_OUTPUT_MAX_BYTES,
26331 )
26332 .await
26333 .unwrap();
26334 assert!(branch.success);
26335 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26336 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26337 assert_eq!(worktrees.len(), 1);
26338 let listed_root = WorkspaceConfig::new(
26339 WorkspaceId::new("listed-independent").unwrap(),
26340 &worktrees[0].path,
26341 )
26342 .unwrap();
26343 assert!(platform::roots_equal(
26344 listed_root.canonical_root(),
26345 windows_path_text(&workspace.canonical_root),
26346 ));
26347 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26348 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26349 snapshot.workspace_id == workspace_id
26350 && snapshot.canonical_root == workspace.canonical_root
26351 }));
26352 assert!(shared
26353 .history
26354 .lock()
26355 .unwrap_or_else(|poisoned| poisoned.into_inner())
26356 .events
26357 .iter()
26358 .any(|event| matches!(
26359 &event.event,
26360 NodeEvent::WorkspaceAdded { workspace }
26361 if workspace.workspace_id == workspace_id
26362 )));
26363 std::fs::remove_dir_all(fixture_root).unwrap();
26364 }
26365
26366 #[tokio::test]
26367 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26368 let fixture_root = temporary_workspace_root("standalone-compensation");
26369 let created_target = fixture_root.join("created-target");
26370 let existing_target = fixture_root.join("existing-target");
26371 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26372 let invalid_state_path = invalid_state_parent.join("state.json");
26373 std::fs::create_dir_all(&existing_target).unwrap();
26374 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26375 let catalog = active_registry().unwrap();
26376 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26377 let primary = WorkspaceConfig::new(
26378 WorkspaceId::new("primary").unwrap(),
26379 std::env::current_dir().unwrap(),
26380 )
26381 .unwrap();
26382 let mut shared = NodeShared::new(
26383 handle,
26384 "fixture-token".to_owned(),
26385 NodeId::new("node-standalone-compensation").unwrap(),
26386 vec![primary],
26387 vec![agent("claude")],
26388 );
26389 shared.state_path = Some(invalid_state_path);
26390
26391 let created_recovery = shared
26392 .create_standalone_workspace(
26393 WorkspaceId::new("compensated").unwrap(),
26394 created_target.to_string_lossy().into_owned(),
26395 Some("main".to_owned()),
26396 )
26397 .await
26398 .unwrap_err();
26399 assert_eq!(
26400 created_recovery.code,
26401 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26402 );
26403 assert!(created_target.join(".git").is_dir());
26404 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26405 workspace.workspace_id.as_str() != "compensated"
26406 }));
26407
26408 let existing_recovery = shared
26409 .create_standalone_workspace(
26410 WorkspaceId::new("restored").unwrap(),
26411 existing_target.to_string_lossy().into_owned(),
26412 None,
26413 )
26414 .await
26415 .unwrap_err();
26416 assert_eq!(
26417 existing_recovery.code,
26418 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26419 );
26420 assert!(existing_target.is_dir());
26421 assert!(existing_target.join(".git").is_dir());
26422 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26423 workspace.workspace_id.as_str() != "restored"
26424 }));
26425
26426 let registered_empty = fixture_root.join("registered-empty");
26427 std::fs::create_dir(®istered_empty).unwrap();
26428 let catalog = active_registry().unwrap();
26429 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26430 let registered = WorkspaceConfig::new(
26431 WorkspaceId::new("registered-empty").unwrap(),
26432 ®istered_empty,
26433 )
26434 .unwrap();
26435 let duplicate_shared = NodeShared::new(
26436 handle,
26437 "fixture-token".to_owned(),
26438 NodeId::new("node-standalone-duplicate-root").unwrap(),
26439 vec![registered],
26440 vec![agent("claude")],
26441 );
26442 let duplicate = duplicate_shared
26443 .create_standalone_workspace(
26444 WorkspaceId::new("duplicate-root").unwrap(),
26445 registered_empty.to_string_lossy().into_owned(),
26446 None,
26447 )
26448 .await
26449 .unwrap_err();
26450 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26451 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26452 std::fs::remove_dir_all(fixture_root).unwrap();
26453 }
26454
26455 #[test]
26456 fn managed_record_id_collision_preserves_the_existing_record() {
26457 let catalog = active_registry().unwrap();
26458 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26459 let workspace = WorkspaceConfig::new(
26460 WorkspaceId::new("primary").unwrap(),
26461 std::env::current_dir().unwrap(),
26462 )
26463 .unwrap();
26464 let shared = NodeShared::new(
26465 handle,
26466 "fixture-token".to_owned(),
26467 NodeId::new("node-1").unwrap(),
26468 vec![workspace.clone()],
26469 vec![agent("claude")],
26470 );
26471 let record_id = SessionRecordId::new("sr-collision").unwrap();
26472 let original = ManagedSessionRecord {
26473 record_id: record_id.clone(),
26474 display_name: "original".to_owned(),
26475 provider: agent("claude"),
26476 mode: SessionMode::Pty,
26477 state: ManagedSessionState::Dormant,
26478 workspace_id: workspace.workspace_id,
26479 canonical_root: opaque_windows_path(workspace.canonical_root),
26480 provider_session: None,
26481 active_session: None,
26482 environment_profile: None,
26483 bundle: None,
26484 context_id: None,
26485 context: None,
26486 exported_context: None,
26487 task_binding: None,
26488 created_at_unix_ms: 1,
26489 updated_at_unix_ms: 1,
26490 last_error: None,
26491 };
26492 shared.insert_record(original.clone()).unwrap();
26493 let mut collision = original.clone();
26494 collision.display_name = "replacement".to_owned();
26495 let error = shared.insert_record(collision).unwrap_err();
26496 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26497 assert_eq!(shared.record(&record_id).unwrap(), original);
26498 }
26499
26500 #[test]
26501 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26502 let catalog = active_registry().unwrap();
26503 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26504 let workspace = WorkspaceConfig::new(
26505 WorkspaceId::new("primary").unwrap(),
26506 std::env::current_dir().unwrap(),
26507 )
26508 .unwrap();
26509 let shared = NodeShared::new(
26510 handle,
26511 "fixture-token".to_owned(),
26512 NodeId::new("node-1").unwrap(),
26513 vec![workspace.clone()],
26514 vec![agent("claude")],
26515 );
26516 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26517 shared
26518 .insert_record(ManagedSessionRecord {
26519 record_id: record_id.clone(),
26520 display_name: "runtime error".to_owned(),
26521 provider: agent("claude"),
26522 mode: SessionMode::Pty,
26523 state: ManagedSessionState::IdentityPending,
26524 workspace_id: workspace.workspace_id,
26525 canonical_root: opaque_windows_path(workspace.canonical_root),
26526 provider_session: None,
26527 active_session: None,
26528 environment_profile: None,
26529 bundle: None,
26530 context_id: None,
26531 context: None,
26532 exported_context: None,
26533 task_binding: None,
26534 created_at_unix_ms: 1,
26535 updated_at_unix_ms: 1,
26536 last_error: None,
26537 })
26538 .unwrap();
26539
26540 shared
26541 .mark_record_error(
26542 &record_id,
26543 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26544 )
26545 .unwrap();
26546 let record = shared.record(&record_id).unwrap();
26547 assert_eq!(record.state, ManagedSessionState::Unavailable);
26548 assert_eq!(
26549 record.last_error.as_deref(),
26550 Some("provider-runtime-failed"),
26551 );
26552 let snapshot = shared.snapshot();
26553 assert_eq!(snapshot.session_records, vec![record]);
26554 }
26555
26556 #[test]
26565 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26566 let catalog = active_registry().unwrap();
26567 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26568 let workspace = WorkspaceConfig::new(
26569 WorkspaceId::new("primary").unwrap(),
26570 std::env::current_dir().unwrap(),
26571 )
26572 .unwrap();
26573 let shared = NodeShared::new(
26574 handle,
26575 "fixture-token".to_owned(),
26576 NodeId::new("node-1").unwrap(),
26577 vec![workspace],
26578 vec![agent("claude")],
26579 );
26580
26581 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26582
26583 shared.record_rejected_command(
26584 CommandId(1),
26585 "agent 'claude' does not support transport Acp".to_owned(),
26586 true,
26587 );
26588 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26590
26591 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26592 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26593 assert!(reason.unsupported_transport);
26594
26595 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26597
26598 shared.record_rejected_command(
26603 CommandId(3),
26604 "backend busy".to_owned(),
26605 false,
26606 );
26607 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26608 assert!(!unclassified.unsupported_transport);
26609 }
26610
26611 #[test]
26612 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26613 let catalog = active_registry().unwrap();
26614 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26615 let workspace = WorkspaceConfig::new(
26616 WorkspaceId::new("primary").unwrap(),
26617 std::env::current_dir().unwrap(),
26618 )
26619 .unwrap();
26620 let shared = NodeShared::new(
26621 handle,
26622 "fixture-token".to_owned(),
26623 NodeId::new("node-identity-policy").unwrap(),
26624 vec![workspace.clone()],
26625 vec![agent("claude")],
26626 );
26627 let address = SessionAddress {
26628 workspace_id: workspace.workspace_id.clone(),
26629 session: SessionKey {
26630 instance_id: AgentInstanceId(90),
26631 generation: SessionGeneration(1),
26632 },
26633 };
26634 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26635 shared
26636 .insert_record(ManagedSessionRecord {
26637 record_id: record_id.clone(),
26638 display_name: "policy denied".to_owned(),
26639 provider: agent("claude"),
26640 mode: SessionMode::Pty,
26641 state: ManagedSessionState::IdentityPending,
26642 workspace_id: workspace.workspace_id,
26643 canonical_root: opaque_windows_path(workspace.canonical_root),
26644 provider_session: None,
26645 active_session: Some(address.clone()),
26646 environment_profile: None,
26647 bundle: None,
26648 context_id: None,
26649 context: None,
26650 exported_context: None,
26651 task_binding: None,
26652 created_at_unix_ms: 1,
26653 updated_at_unix_ms: 1,
26654 last_error: None,
26655 })
26656 .unwrap();
26657 let event = ControlEvent {
26658 sequence: 1,
26659 command_id: None,
26660 instance_id: address.session.instance_id,
26661 generation: address.session.generation,
26662 event: ControlEventKind::ProviderEvent {
26663 sequence: 1,
26664 source: gate4agent_types::ProviderSource {
26665 family: AdapterFamily::PtySemantic,
26666 binding: AdapterBinding::new(
26667 gate4agent_types::AdapterId::new("claude").unwrap(),
26668 "fixture/v1",
26669 gate4agent_types::AdapterVerification::SyntheticFixture,
26670 )
26671 .unwrap(),
26672 },
26673 source_sequence: 1,
26674 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26675 identity: gate4agent_types::ProviderSessionIdentity {
26676 key: gate4agent_types::ProviderSessionKey::SessionId,
26677 id: "provider-session".to_owned(),
26678 transcript_path: None,
26679 },
26680 },
26681 },
26682 };
26683
26684 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26685
26686 let record = shared.record(&record_id).unwrap();
26687 assert!(record.provider_session.is_none());
26688 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26689 assert_eq!(record.active_session.as_ref(), Some(&address));
26690 }
26691
26692 #[test]
26693 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26694 let secondary_root = temporary_workspace_root("identity-scope");
26695 std::fs::create_dir_all(&secondary_root).unwrap();
26696 let catalog = active_registry().unwrap();
26697 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26698 let primary = WorkspaceConfig::new(
26699 WorkspaceId::new("primary").unwrap(),
26700 std::env::current_dir().unwrap(),
26701 )
26702 .unwrap();
26703 let secondary = WorkspaceConfig::new(
26704 WorkspaceId::new("secondary").unwrap(),
26705 &secondary_root,
26706 )
26707 .unwrap();
26708 let shared = NodeShared::new(
26709 handle,
26710 "fixture-token".to_owned(),
26711 NodeId::new("node-1").unwrap(),
26712 vec![primary.clone(), secondary.clone()],
26713 vec![agent("claude")],
26714 );
26715 let identity = gate4agent_types::ProviderSessionIdentity {
26716 key: gate4agent_types::ProviderSessionKey::SessionId,
26717 id: "provider-session".to_owned(),
26718 transcript_path: None,
26719 };
26720 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26721 shared
26722 .insert_record(ManagedSessionRecord {
26723 record_id: dormant_id.clone(),
26724 display_name: "dormant".to_owned(),
26725 provider: agent("claude"),
26726 mode: SessionMode::Pty,
26727 state: ManagedSessionState::Dormant,
26728 workspace_id: primary.workspace_id,
26729 canonical_root: opaque_windows_path(primary.canonical_root),
26730 provider_session: Some(identity.clone()),
26731 active_session: None,
26732 environment_profile: None,
26733 bundle: None,
26734 context_id: None,
26735 context: None,
26736 exported_context: None,
26737 task_binding: None,
26738 created_at_unix_ms: 1,
26739 updated_at_unix_ms: 1,
26740 last_error: None,
26741 })
26742 .unwrap();
26743 let address = SessionAddress {
26744 workspace_id: secondary.workspace_id.clone(),
26745 session: SessionKey {
26746 instance_id: AgentInstanceId(91),
26747 generation: SessionGeneration(1),
26748 },
26749 };
26750 let current_id = SessionRecordId::new("sr-current").unwrap();
26751 shared
26752 .insert_record(ManagedSessionRecord {
26753 record_id: current_id.clone(),
26754 display_name: "current".to_owned(),
26755 provider: agent("claude"),
26756 mode: SessionMode::Pty,
26757 state: ManagedSessionState::IdentityPending,
26758 workspace_id: secondary.workspace_id,
26759 canonical_root: opaque_windows_path(secondary.canonical_root),
26760 provider_session: None,
26761 active_session: Some(address.clone()),
26762 environment_profile: None,
26763 bundle: None,
26764 context_id: None,
26765 context: None,
26766 exported_context: None,
26767 task_binding: None,
26768 created_at_unix_ms: 2,
26769 updated_at_unix_ms: 2,
26770 last_error: None,
26771 })
26772 .unwrap();
26773 shared.bind_managed_session(
26774 &address,
26775 current_id.clone(),
26776 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26777 None,
26778 );
26779 let event = ControlEvent {
26780 sequence: 1,
26781 command_id: None,
26782 instance_id: address.session.instance_id,
26783 generation: address.session.generation,
26784 event: ControlEventKind::ProviderEvent {
26785 sequence: 1,
26786 source: gate4agent_types::ProviderSource {
26787 family: gate4agent_types::AdapterFamily::PtySemantic,
26788 binding: gate4agent_types::AdapterBinding::new(
26789 gate4agent_types::AdapterId::new("claude").unwrap(),
26790 "fixture/v1",
26791 gate4agent_types::AdapterVerification::SyntheticFixture,
26792 )
26793 .unwrap(),
26794 },
26795 source_sequence: 1,
26796 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26797 identity,
26798 },
26799 },
26800 };
26801 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26802
26803 let binding = shared
26804 .session_bindings
26805 .lock()
26806 .unwrap_or_else(|poisoned| poisoned.into_inner())
26807 .get(&address.session.instance_id)
26808 .cloned()
26809 .unwrap();
26810 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26811 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26812 let current = shared.record(¤t_id).unwrap();
26813 assert_eq!(current.state, ManagedSessionState::Unavailable);
26814 assert_eq!(current.active_session.as_ref(), Some(&address));
26815 assert_eq!(
26816 current.last_error.as_deref(),
26817 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26818 );
26819 std::fs::remove_dir_all(secondary_root).unwrap();
26820 }
26821
26822 #[tokio::test]
26823 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26824 let root = temporary_workspace_root("semantic-provider-session");
26825 std::fs::create_dir_all(&root).unwrap();
26826 let state_path = root.join("state-v1.json");
26827 let catalog = active_registry().unwrap();
26828 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26829 let workspace = WorkspaceConfig::new(
26830 WorkspaceId::new("primary").unwrap(),
26831 &root,
26832 )
26833 .unwrap();
26834 let node_id = NodeId::new("node-semantic-identity").unwrap();
26835 let shared = NodeShared::new_with_incarnation(
26836 handle,
26837 "fixture-token".to_owned(),
26838 node_id.clone(),
26839 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26840 vec![workspace.clone()],
26841 vec![agent("claude")],
26842 ProviderRuntimeStatuses::default(),
26843 None,
26844 Vec::new(),
26845 Vec::new(),
26846 SpawnProfileRegistry::default(),
26847 None,
26848 None,
26849 None,
26850 Some(state_path.clone()),
26851 Vec::new(),
26852 Vec::new(),
26853 Vec::new(),
26854 Vec::new(),
26855 None,
26856 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26857 );
26858 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26859 key: gate4agent_types::ProviderSessionKey::SessionId,
26860 id: "provider-session".to_owned(),
26861 transcript_path: None,
26862 };
26863 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26864 shared
26865 .insert_record(ManagedSessionRecord {
26866 record_id: durable_id.clone(),
26867 display_name: "durable".to_owned(),
26868 provider: agent("claude"),
26869 mode: SessionMode::Pty,
26870 state: ManagedSessionState::Dormant,
26871 workspace_id: workspace.workspace_id.clone(),
26872 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26873 provider_session: Some(persisted_identity.clone()),
26874 active_session: None,
26875 environment_profile: None,
26876 bundle: None,
26877 context_id: None,
26878 context: None,
26879 exported_context: None,
26880 task_binding: None,
26881 created_at_unix_ms: 1,
26882 updated_at_unix_ms: 1,
26883 last_error: None,
26884 })
26885 .unwrap();
26886 let address = SessionAddress {
26887 workspace_id: workspace.workspace_id.clone(),
26888 session: SessionKey {
26889 instance_id: AgentInstanceId(92),
26890 generation: SessionGeneration(2),
26891 },
26892 };
26893 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26894 shared
26895 .insert_record(ManagedSessionRecord {
26896 record_id: pending_id.clone(),
26897 display_name: "pending".to_owned(),
26898 provider: agent("claude"),
26899 mode: SessionMode::Pty,
26900 state: ManagedSessionState::IdentityPending,
26901 workspace_id: workspace.workspace_id.clone(),
26902 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26903 provider_session: None,
26904 active_session: Some(address.clone()),
26905 environment_profile: None,
26906 bundle: None,
26907 context_id: None,
26908 context: None,
26909 exported_context: None,
26910 task_binding: None,
26911 created_at_unix_ms: 2,
26912 updated_at_unix_ms: 2,
26913 last_error: None,
26914 })
26915 .unwrap();
26916 shared.bind_managed_session(
26917 &address,
26918 pending_id.clone(),
26919 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26920 None,
26921 );
26922 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26923 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26924 ..persisted_identity
26925 };
26926 let event = ControlEvent {
26927 sequence: 1,
26928 command_id: None,
26929 instance_id: address.session.instance_id,
26930 generation: address.session.generation,
26931 event: ControlEventKind::ProviderEvent {
26932 sequence: 1,
26933 source: gate4agent_types::ProviderSource {
26934 family: gate4agent_types::AdapterFamily::PtySemantic,
26935 binding: gate4agent_types::AdapterBinding::new(
26936 gate4agent_types::AdapterId::new("claude").unwrap(),
26937 "fixture/v1",
26938 gate4agent_types::AdapterVerification::SyntheticFixture,
26939 )
26940 .unwrap(),
26941 },
26942 source_sequence: 1,
26943 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26944 identity: observed_identity.clone(),
26945 },
26946 },
26947 };
26948 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26949
26950 let snapshot = shared.snapshot();
26951 assert_eq!(snapshot.session_records.len(), 1);
26952 let live = &snapshot.session_records[0];
26953 assert_eq!(live.record_id, durable_id);
26954 assert_eq!(live.state, ManagedSessionState::Live);
26955 assert_eq!(live.active_session.as_ref(), Some(&address));
26956 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26957 let binding = shared
26958 .session_bindings
26959 .lock()
26960 .unwrap_or_else(|poisoned| poisoned.into_inner())
26961 .get(&address.session.instance_id)
26962 .cloned()
26963 .unwrap();
26964 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26965
26966 let mut duplicate = live.clone();
26967 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26968 duplicate.display_name = "duplicate".to_owned();
26969 duplicate.state = ManagedSessionState::Dormant;
26970 duplicate.active_session = None;
26971 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26972 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26973 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26974 assert_eq!(shared.snapshot().session_records.len(), 1);
26975
26976 let terminal_size = gate4agent_types::TerminalSize {
26977 rows: 24,
26978 columns: 80,
26979 };
26980 let (first_busy, second_busy) = tokio::join!(
26981 shared.resume_session_record(&durable_id, terminal_size, None),
26982 shared.resume_session_record(&durable_id, terminal_size, None),
26983 );
26984 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26985 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26986 assert_eq!(busy.message, "session-record-busy");
26987 }
26988
26989 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26990 assert_eq!(loaded.records.len(), 1);
26991 assert_eq!(loaded.records[0].record_id, durable_id);
26992 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26993 assert_eq!(
26994 loaded.records[0]
26995 .provider_session
26996 .as_ref()
26997 .unwrap()
26998 .transcript_path,
26999 None,
27000 );
27001 let catalog = active_registry().unwrap();
27002 let (reloaded_handle, _reloaded_runtime) =
27003 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27004 let reloaded = NodeShared::new_with_incarnation(
27005 reloaded_handle,
27006 "fixture-token".to_owned(),
27007 node_id,
27008 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27009 vec![workspace],
27010 vec![agent("claude")],
27011 ProviderRuntimeStatuses::default(),
27012 None,
27013 Vec::new(),
27014 Vec::new(),
27015 SpawnProfileRegistry::default(),
27016 None,
27017 None,
27018 None,
27019 None,
27020 loaded.records,
27021 loaded.managed_worktrees,
27022 loaded.managed_worktree_tombstones,
27023 loaded.materializations,
27024 loaded.warning,
27025 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27026 );
27027 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27028 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27029 std::fs::remove_dir_all(root).unwrap();
27030 }
27031
27032 #[tokio::test]
27033 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27034 let secondary_root = temporary_workspace_root("workspace-restore");
27035 let other_root = temporary_workspace_root("workspace-conflict");
27036 std::fs::create_dir_all(&secondary_root).unwrap();
27037 std::fs::create_dir_all(&other_root).unwrap();
27038 let catalog = active_registry().unwrap();
27039 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27040 let primary = WorkspaceConfig::new(
27041 WorkspaceId::new("primary").unwrap(),
27042 std::env::current_dir().unwrap(),
27043 )
27044 .unwrap();
27045 let secondary = WorkspaceConfig::new(
27046 WorkspaceId::new("secondary").unwrap(),
27047 &secondary_root,
27048 )
27049 .unwrap();
27050 let shared = NodeShared::new(
27051 handle,
27052 "fixture-token".to_owned(),
27053 NodeId::new("node-1").unwrap(),
27054 vec![primary, secondary.clone()],
27055 vec![agent("claude")],
27056 );
27057 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27058 shared
27059 .insert_record(ManagedSessionRecord {
27060 record_id: record_id.clone(),
27061 display_name: "workspace record".to_owned(),
27062 provider: agent("claude"),
27063 mode: SessionMode::Pty,
27064 state: ManagedSessionState::Dormant,
27065 workspace_id: secondary.workspace_id.clone(),
27066 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27067 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27068 key: gate4agent_types::ProviderSessionKey::SessionId,
27069 id: "provider-session".to_owned(),
27070 transcript_path: None,
27071 }),
27072 active_session: None,
27073 environment_profile: None,
27074 bundle: None,
27075 context_id: None,
27076 context: None,
27077 exported_context: None,
27078 task_binding: None,
27079 created_at_unix_ms: 1,
27080 updated_at_unix_ms: 1,
27081 last_error: None,
27082 })
27083 .unwrap();
27084 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27085 let unavailable = shared.record(&record_id).unwrap();
27086 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27087 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27088
27089 let conflict = shared
27090 .register_workspace(
27091 secondary.workspace_id.clone(),
27092 other_root.to_string_lossy().into_owned(),
27093 )
27094 .await
27095 .unwrap_err();
27096 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27097 shared
27098 .register_workspace(
27099 secondary.workspace_id,
27100 secondary_root.to_string_lossy().into_owned(),
27101 )
27102 .await
27103 .unwrap();
27104 let restored = shared.record(&record_id).unwrap();
27105 assert_eq!(restored.state, ManagedSessionState::Dormant);
27106 assert!(restored.last_error.is_none());
27107 std::fs::remove_dir_all(secondary_root).unwrap();
27108 std::fs::remove_dir_all(other_root).unwrap();
27109 }
27110
27111 #[test]
27112 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27113 let error = validate_workspace_request_root(
27114 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27115 )
27116 .unwrap_err();
27117 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27118 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27119 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27120 }
27121
27122 #[test]
27123 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27124 let catalog = active_registry().unwrap();
27125 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27126 let workspace = WorkspaceConfig::new(
27127 WorkspaceId::new("primary").unwrap(),
27128 std::env::current_dir().unwrap(),
27129 )
27130 .unwrap();
27131 let shared = NodeShared::new(
27132 handle,
27133 "fixture-token".to_owned(),
27134 NodeId::new("node-1").unwrap(),
27135 vec![workspace],
27136 vec![agent("claude")],
27137 );
27138 let publishers = 4_u64;
27139 let events_per_publisher = 256_u64;
27140 let start = std::sync::Barrier::new(publishers as usize + 1);
27141 let finished = AtomicU64::new(0);
27142
27143 std::thread::scope(|scope| {
27144 for _ in 0..publishers {
27145 scope.spawn(|| {
27146 start.wait();
27147 for _ in 0..events_per_publisher {
27148 shared.publish(NodeEvent::ControllerChanged { controller: None });
27149 }
27150 finished.fetch_add(1, Ordering::Release);
27151 });
27152 }
27153 start.wait();
27154 while finished.load(Ordering::Acquire) != publishers {
27155 assert_resync_history_is_linearized(shared.resync(0));
27156 std::thread::yield_now();
27157 }
27158 });
27159 let response = shared.resync(0);
27160 assert_resync_history_is_linearized(response.clone());
27161 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27162 unreachable!("resync helper returned another response");
27163 };
27164 assert_eq!(event_sequence, publishers * events_per_publisher);
27165 assert_eq!(events.len() as u64, event_sequence);
27166 }
27167
27168 fn assert_resync_history_is_linearized(response: NodeResponse) {
27169 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27170 unreachable!("resync helper returned another response");
27171 };
27172 assert!(events.windows(2).all(|pair| {
27173 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27174 }));
27175 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27176 }
27177
27178 #[test]
27179 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27180 assert_eq!(
27181 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27182 r"C:\repo\worktree",
27183 );
27184 assert_eq!(
27185 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27186 r"\\server\share\repo",
27187 );
27188 assert!(validate_workspace_root(
27189 &WorkspaceId::new("network").unwrap(),
27190 r"\\server\share\repo",
27191 )
27192 .is_err());
27193 let workspace = WorkspaceConfig::new(
27194 WorkspaceId::new("primary").unwrap(),
27195 std::env::current_dir().unwrap(),
27196 )
27197 .unwrap();
27198 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27199 }
27200
27201 #[test]
27202 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27203 let catalog = active_registry().unwrap();
27204 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27205 let workspace_id = WorkspaceId::new("primary").unwrap();
27206 let workspace = WorkspaceConfig::new(
27207 workspace_id.clone(),
27208 std::env::current_dir().unwrap(),
27209 )
27210 .unwrap();
27211 let shared = NodeShared::new(
27212 handle,
27213 "fixture-token".to_owned(),
27214 NodeId::new("node-1").unwrap(),
27215 vec![workspace],
27216 vec![agent("claude")],
27217 );
27218 let instance_id = AgentInstanceId(17);
27219 let original = SessionAddress {
27220 workspace_id,
27221 session: SessionKey {
27222 instance_id,
27223 generation: SessionGeneration(3),
27224 },
27225 };
27226 shared.bind_session(
27227 instance_id,
27228 original.workspace_id.clone(),
27229 original.session.generation,
27230 );
27231 shared
27232 .arm_resume(
27233 &original,
27234 CommandId(41),
27235 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27236 )
27237 .unwrap();
27238
27239 shared.publish_control(ControlEvent {
27240 sequence: 1,
27241 command_id: None,
27242 instance_id,
27243 generation: SessionGeneration(4),
27244 event: ControlEventKind::Running { process_id: None },
27245 });
27246 assert_eq!(
27247 shared.address_for(instance_id, SessionGeneration(3)),
27248 Some(original.clone()),
27249 );
27250 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27251
27252 shared.publish_control(ControlEvent {
27253 sequence: 2,
27254 command_id: Some(CommandId(42)),
27255 instance_id,
27256 generation: SessionGeneration(3),
27257 event: ControlEventKind::CommandRejected {
27258 message: "unrelated command".to_owned(),
27259 },
27260 });
27261
27262 shared.publish_control(ControlEvent {
27263 sequence: 3,
27264 command_id: None,
27265 instance_id,
27266 generation: SessionGeneration(4),
27267 event: ControlEventKind::ResumeAuthorized {
27268 session: gate4agent_types::ResumeSessionSummary {
27269 key: gate4agent_types::ProviderSessionKey::SessionId,
27270 id: "fixture-session".to_owned(),
27271 },
27272 },
27273 });
27274 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27275 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27276 assert_eq!(rebound.workspace_id, original.workspace_id);
27277 let history = shared
27278 .history
27279 .lock()
27280 .unwrap_or_else(|poisoned| poisoned.into_inner());
27281 assert!(history.events.iter().any(|envelope| matches!(
27282 &envelope.event,
27283 NodeEvent::Control { address, event }
27284 if address == &rebound
27285 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27286 )));
27287 }
27288
27289 #[test]
27290 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27291 for agent in ["claude", "codex", "kimi"] {
27292 assert_eq!(
27293 prompt_framing(&AgentId::new(agent).unwrap()),
27294 PromptFraming::BracketedPaste
27295 );
27296 }
27297 }
27298
27299 fn generous_walk_deadline() -> Instant {
27300 Instant::now() + Duration::from_secs(30)
27301 }
27302
27303 #[test]
27304 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27305 let root = temporary_workspace_root("tree");
27306 std::fs::create_dir_all(root.join("src")).unwrap();
27307 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27308 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27309 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27310 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27311 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27312 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27318 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27319 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27323 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27324
27325 let (entries, truncated, _budget) =
27326 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27327 assert!(!truncated);
27328 assert!(entries.iter().any(|entry| {
27329 entry.relative_path.as_utf8() == Some("src")
27330 && entry.kind == WorkspaceEntryKind::Directory
27331 }));
27332 assert!(entries.iter().any(|entry| {
27333 entry.relative_path.as_utf8() == Some("src/lib.rs")
27334 && entry.kind == WorkspaceEntryKind::File
27335 }));
27336 assert!(entries.iter().any(|entry| {
27337 entry.relative_path.as_utf8() == Some("target-notes")
27338 && entry.kind == WorkspaceEntryKind::Directory
27339 }));
27340 assert!(entries.iter().any(|entry| {
27341 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27342 && entry.kind == WorkspaceEntryKind::File
27343 }));
27344 assert!(entries.iter().all(|entry| {
27345 let path = entry.relative_path.as_utf8().unwrap();
27346 !path.starts_with(".git")
27347 && !(path == "target" || path.starts_with("target/"))
27348 && !(path == "target-a2" || path.starts_with("target-a2/"))
27349 && !path.starts_with("node_modules")
27350 }));
27351
27352 std::fs::remove_dir_all(&root).unwrap();
27353 }
27354
27355 #[test]
27356 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27357 let root = temporary_workspace_root("tree-entry-cap");
27358 std::fs::create_dir_all(&root).unwrap();
27359 for index in 0..20 {
27360 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27361 }
27362
27363 let (entries, truncated, budget) =
27364 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27365 assert!(truncated);
27366 assert!(budget.entry_cap_exceeded);
27367 assert!(!budget.time_budget_exceeded);
27368 assert!(entries.len() <= 5);
27369
27370 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27371 .expect("an entry-cap-truncated walk must report a truncation marker");
27372 assert!(inspection_truncation.walk_entry_cap_exceeded);
27373 assert!(!inspection_truncation.walk_time_budget_exceeded);
27374 assert!(!inspection_truncation.git_time_budget_exceeded);
27375 assert!(inspection_truncation.entries_visited >= 5);
27376
27377 std::fs::remove_dir_all(&root).unwrap();
27378 }
27379
27380 #[test]
27381 fn workspace_walk_within_budget_reports_no_truncation() {
27382 let root = temporary_workspace_root("tree-no-truncation");
27383 std::fs::create_dir_all(&root).unwrap();
27384 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27385
27386 let (_entries, truncated, budget) =
27387 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27388 assert!(!truncated);
27389 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27390
27391 std::fs::remove_dir_all(&root).unwrap();
27392 }
27393
27394 #[test]
27395 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27396 let root = temporary_workspace_root("tree-prefix-siblings");
27397 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27398 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27399 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27400 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27401
27402 let (entries, truncated, _budget) =
27403 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27404 assert!(!truncated);
27405 let paths = entries
27406 .iter()
27407 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27408 .collect::<Vec<_>>();
27409 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27410 assert_eq!(
27411 paths,
27412 vec![
27413 "gate4agent-c2".to_owned(),
27414 "gate4agent-c2-client".to_owned(),
27415 "gate4agent-c2-client/lib.rs".to_owned(),
27416 "gate4agent-c2/src".to_owned(),
27417 "gate4agent-c2/src/lib.rs".to_owned(),
27418 ],
27419 );
27420
27421 std::fs::remove_dir_all(&root).unwrap();
27422 }
27423
27424 #[test]
27425 fn git_parsers_cap_status_and_commit_results() {
27426 let mut status_output = Vec::new();
27427 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27428 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27429 }
27430 let mut snapshot = git_snapshot_for_parser();
27431 parse_git_status(&status_output, &mut snapshot);
27432 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27433 assert!(snapshot.truncated);
27434 assert_eq!(snapshot.status[0].index_status, " ");
27435 assert_eq!(snapshot.status[0].worktree_status, "M");
27436
27437 snapshot.truncated = false;
27438 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27439 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27440 .collect::<String>();
27441 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27442 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27443 assert!(snapshot.truncated);
27444 }
27445
27446 #[test]
27447 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27448 let mut snapshot = git_snapshot_for_parser();
27449 parse_git_status(
27450 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27451 &mut snapshot,
27452 );
27453
27454 assert!(!snapshot.truncated);
27455 assert_eq!(snapshot.status.len(), 3);
27456 assert_eq!(snapshot.status[0].index_status, "R");
27457 assert_eq!(snapshot.status[0].worktree_status, " ");
27458 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27459 assert_eq!(
27460 snapshot.status[0]
27461 .previous_path
27462 .as_ref()
27463 .and_then(RepositoryPath::as_utf8),
27464 Some("src/previous name.rs"),
27465 );
27466 assert_eq!(snapshot.status[1].index_status, " ");
27467 assert_eq!(snapshot.status[1].worktree_status, "C");
27468 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27469 assert_eq!(
27470 snapshot.status[1]
27471 .previous_path
27472 .as_ref()
27473 .and_then(RepositoryPath::as_utf8),
27474 Some("src/original.rs"),
27475 );
27476 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27477 assert!(snapshot.status[2].previous_path.is_none());
27478 }
27479
27480 #[test]
27481 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27482 let mut snapshot = git_snapshot_for_parser();
27483 parse_git_status(
27484 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27485 &mut snapshot,
27486 );
27487
27488 assert!(snapshot.truncated);
27489 assert_eq!(snapshot.status.len(), 1);
27490 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27491 assert!(snapshot.status[0].previous_path.is_none());
27492 }
27493
27494 #[tokio::test]
27495 async fn observer_can_browse_host_directories_without_controller() {
27496 let root = temporary_workspace_root("host-directory-observer");
27497 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27498 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27499 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27500 let workspace_id = WorkspaceId::new("primary").unwrap();
27501 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27502 let catalog = active_registry().unwrap();
27503 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27504 let shared = NodeShared::new(
27505 handle,
27506 "fixture-token".to_owned(),
27507 NodeId::new("node-1").unwrap(),
27508 vec![workspace],
27509 vec![agent("claude")],
27510 );
27511 let request = NodeRequest::BrowseHostDirectories {
27512 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27513 after: None,
27514 };
27515 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27516 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27517 }));
27518 assert!(!request_uses_unnegotiated_capability(
27519 &request,
27520 &baseline_capabilities().unwrap(),
27521 ));
27522 assert!(request_uses_unnegotiated_capability(&request, &[]));
27523
27524 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27525 .await
27526 .unwrap();
27527 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27528 panic!("host directory browse returned another response");
27529 };
27530 assert_eq!(
27531 listing
27532 .entries
27533 .iter()
27534 .map(|entry| entry.display_name.as_str())
27535 .collect::<Vec<_>>(),
27536 vec!["directory-a", "directory-b"],
27537 );
27538 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27539 assert!(!listing.incomplete);
27540 std::fs::remove_dir_all(root).unwrap();
27541 }
27542
27543 #[tokio::test]
27544 async fn observer_can_inspect_only_a_registered_workspace() {
27545 let root = temporary_workspace_root("observer");
27546 std::fs::create_dir_all(root.join("src")).unwrap();
27547 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27548 let workspace_id = WorkspaceId::new("primary").unwrap();
27549 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27550 let catalog = active_registry().unwrap();
27551 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27552 let shared = NodeShared::new(
27553 handle,
27554 "fixture-token".to_owned(),
27555 NodeId::new("node-1").unwrap(),
27556 vec![workspace],
27557 vec![agent("claude")],
27558 );
27559
27560 let response = process_request_inner(
27561 &shared,
27562 77,
27563 ClientRole::Observer,
27564 NodeRequest::InspectWorkspace {
27565 workspace_id: workspace_id.clone(),
27566 },
27567 )
27568 .await
27569 .unwrap();
27570 let NodeResponse::WorkspaceInspected { inspection } = response else {
27571 panic!("workspace inspection returned another response");
27572 };
27573 assert_eq!(inspection.workspace_id, workspace_id);
27574 assert!(inspection.entries.iter().any(|entry| {
27575 entry.relative_path.as_utf8() == Some("src/main.rs")
27576 }));
27577
27578 let unknown = process_request_inner(
27579 &shared,
27580 77,
27581 ClientRole::Observer,
27582 NodeRequest::InspectWorkspace {
27583 workspace_id: WorkspaceId::new("unknown").unwrap(),
27584 },
27585 )
27586 .await
27587 .unwrap_err();
27588 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27589 std::fs::remove_dir_all(&root).unwrap();
27590 }
27591
27592 #[test]
27593 fn context_pack_source_accepts_only_running_or_successful_exit() {
27594 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27595 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27596 exit_code: Some(0),
27597 }));
27598 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27599 exit_code: Some(1),
27600 }));
27601 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27602 exit_code: None,
27603 }));
27604 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27605 message: "provider failed".to_owned(),
27606 }));
27607 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27608 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27609 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27610 }
27611
27612 #[test]
27613 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27614 let git = git_snapshot_for_parser();
27615 let first = ContextPackRepositoryObservation {
27616 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27617 git: git.clone(),
27618 files: Vec::new(),
27619 };
27620 let second = ContextPackRepositoryObservation {
27621 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27622 git,
27623 files: Vec::new(),
27624 };
27625
27626 let error = stable_context_pack_repository(first, second).unwrap_err();
27627
27628 assert_eq!(
27629 error.code,
27630 NodeFailureCode::ContextPackMaterializationFailed,
27631 );
27632 }
27633
27634 #[test]
27635 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27636 let git = git_snapshot_for_parser();
27637 let first = ContextPackRepositoryObservation {
27638 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27639 git: git.clone(),
27640 files: vec![ContextPackRepositoryFileSource::utf8(
27641 "README.md",
27642 "first\n".to_owned(),
27643 6,
27644 )],
27645 };
27646 let second = ContextPackRepositoryObservation {
27647 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27648 git,
27649 files: vec![ContextPackRepositoryFileSource::utf8(
27650 "README.md",
27651 "other\n".to_owned(),
27652 6,
27653 )],
27654 };
27655
27656 let error = stable_context_pack_repository(first, second).unwrap_err();
27657
27658 assert_eq!(
27659 error.code,
27660 NodeFailureCode::ContextPackMaterializationFailed,
27661 );
27662 }
27663
27664 #[tokio::test]
27665 async fn native_session_catalog_zero_active_session_fixture() {
27666 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27667 let history_root = temporary_workspace_root("native-catalog-history");
27668 let outside_root = temporary_workspace_root("native-catalog-outside");
27669 std::fs::create_dir_all(&workspace_root).unwrap();
27670 std::fs::create_dir_all(&history_root).unwrap();
27671 std::fs::create_dir_all(&outside_root).unwrap();
27672 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27673 [
27674 serde_json::json!({
27675 "type": "user",
27676 "sessionId": session_id,
27677 "cwd": cwd.to_str().unwrap(),
27678 "message": { "content": "raw user text must not cross the catalog wire" }
27679 }),
27680 serde_json::json!({
27681 "type": "assistant",
27682 "sessionId": session_id,
27683 "cwd": cwd.to_str().unwrap(),
27684 "message": { "model": "claude-sonnet", "content": "raw answer" }
27685 }),
27686 serde_json::json!({
27687 "type": "ai-title",
27688 "sessionId": session_id,
27689 "aiTitle": title
27690 }),
27691 ]
27692 .into_iter()
27693 .map(|record| record.to_string())
27694 .collect::<Vec<_>>()
27695 .join("\n")
27696 };
27697 std::fs::write(
27698 history_root.join("stable-session.jsonl"),
27699 transcript("stable-session", &workspace_root, "Workspace review"),
27700 )
27701 .unwrap();
27702 std::fs::write(
27703 history_root.join("outside-session.jsonl"),
27704 transcript("outside-session", &outside_root, "Outside review"),
27705 )
27706 .unwrap();
27707 let history = NativeHistoryConfig::new(vec![
27708 NativeHistoryRoot::new(
27709 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27710 HistorySourceLayout::SingleNdjson,
27711 history_root.clone(),
27712 )
27713 .unwrap(),
27714 ])
27715 .unwrap();
27716 let catalog = active_registry().unwrap();
27717 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27718 assert!(handle.snapshot().sessions.is_empty());
27719 let workspace_id = WorkspaceId::new("primary").unwrap();
27720 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27721 let mut shared = NodeShared::new(
27722 handle,
27723 "fixture-token".to_owned(),
27724 NodeId::new("node-native-catalog").unwrap(),
27725 vec![workspace],
27726 vec![agent("claude")],
27727 );
27728 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27729 NativeSessionCatalogAuthority::new(history),
27730 )));
27731 let route = NativeSessionCatalogRoute::workspace(
27732 workspace_id.clone(),
27733 agent("claude"),
27734 );
27735 let request = NodeRequest::CatalogNativeSessions {
27736 route: route.clone(),
27737 limit: 2,
27738 };
27739 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27740 .await
27741 .unwrap_err();
27742 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27743 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27744 .await
27745 .unwrap();
27746 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27747 panic!("native catalog returned a different response");
27748 };
27749 let summary = summary.expect("node must emit native session catalog summary metadata");
27750 assert_eq!(entries.len(), 1);
27751 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27752 assert_eq!(entries[0].external_group, None);
27753 assert_eq!(entries[0].record_id, None);
27754 assert_eq!(entries[0].title, None);
27755 assert_eq!(entries[0].model, None);
27756 assert_eq!(entries[0].message_count, 0);
27757 let encoded_entries = serde_json::to_string(&entries).unwrap();
27758 assert!(!encoded_entries.contains("stable-session"));
27759 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27760 let page_request = NodeRequest::PageNativeSessions {
27761 route: route.clone(),
27762 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27763 catalog_revision: summary.catalog_revision,
27764 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27765 after_selection_id: None,
27766 limit: 2,
27767 };
27768 let observer = process_request_inner(
27769 &shared,
27770 7,
27771 ClientRole::Observer,
27772 page_request.clone(),
27773 )
27774 .await
27775 .unwrap_err();
27776 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27777 let page_response = process_request_inner(
27778 &shared,
27779 7,
27780 ClientRole::Operator,
27781 page_request.clone(),
27782 )
27783 .await
27784 .unwrap();
27785 assert!(matches!(
27786 page_response,
27787 NodeResponse::NativeSessionsPaged { page, .. }
27788 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27789 ));
27790 let stale = process_request_inner(
27791 &shared,
27792 7,
27793 ClientRole::Operator,
27794 NodeRequest::PageNativeSessions {
27795 route: route.clone(),
27796 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27797 catalog_revision: summary.catalog_revision.wrapping_add(1),
27798 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27799 after_selection_id: None,
27800 limit: 2,
27801 },
27802 )
27803 .await
27804 .unwrap_err();
27805 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27806 let selection = NativeSessionSelection {
27807 route: route.clone(),
27808 catalog_revision: summary.catalog_revision,
27809 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27810 selection_id: entries[0].selection_id.clone(),
27811 };
27812 let preview_response = process_request_inner(
27813 &shared,
27814 7,
27815 ClientRole::Operator,
27816 NodeRequest::PreviewNativeSession {
27817 selection: selection.clone(),
27818 message_limit: 1,
27819 },
27820 )
27821 .await
27822 .unwrap();
27823 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27824 panic!("native preview returned a different response");
27825 };
27826 assert_eq!(preview.messages.len(), 1);
27827 assert!(preview.message_count_exact);
27828 assert_eq!(preview.messages[0].text, "raw answer");
27829 assert!(preview.truncated);
27830
27831 let index_request = NodeRequest::IndexNativeSession {
27832 selection: selection.clone(),
27833 display_name: "Saved Claude".to_owned(),
27834 };
27835 let observer = process_request_inner(
27836 &shared,
27837 7,
27838 ClientRole::Observer,
27839 index_request.clone(),
27840 )
27841 .await
27842 .unwrap_err();
27843 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27844 let without_controller = process_request_inner(
27845 &shared,
27846 7,
27847 ClientRole::Operator,
27848 index_request.clone(),
27849 )
27850 .await
27851 .unwrap_err();
27852 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27853 let acquired = process_request_inner(
27854 &shared,
27855 7,
27856 ClientRole::Operator,
27857 NodeRequest::AcquireController { lease_ms: 5_000 },
27858 )
27859 .await
27860 .unwrap();
27861 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27862 let indexed = process_request_inner(
27863 &shared,
27864 7,
27865 ClientRole::Operator,
27866 index_request,
27867 )
27868 .await
27869 .unwrap();
27870 assert!(indexed.native_session_index_contract_is_valid());
27871 let NodeResponse::NativeSessionIndexed {
27872 selection: echoed_selection,
27873 record,
27874 } = indexed else {
27875 panic!("native session index returned a different response");
27876 };
27877 assert_eq!(echoed_selection, selection);
27878 let recataloged = process_request_inner(
27879 &shared,
27880 7,
27881 ClientRole::Operator,
27882 NodeRequest::CatalogNativeSessions {
27883 route,
27884 limit: 2,
27885 },
27886 )
27887 .await
27888 .unwrap();
27889 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27890 panic!("native catalog returned a different response after indexing");
27891 };
27892 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27893
27894 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27895 let external_catalog = process_request_inner(
27896 &shared,
27897 7,
27898 ClientRole::Operator,
27899 NodeRequest::CatalogNativeSessions {
27900 route: unregistered_route.clone(),
27901 limit: 2,
27902 },
27903 )
27904 .await
27905 .unwrap();
27906 let external_json = serde_json::to_string(&external_catalog).unwrap();
27907 assert!(!external_json.contains("outside-session"));
27908 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27909 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27910 assert!(!external_json.contains("\"session_id\""));
27911 assert!(!external_json.contains("\"cwd\""));
27912 assert!(!external_json.contains("\"path\""));
27913 let NodeResponse::NativeSessionsCataloged {
27914 route: echoed_route,
27915 entries: external_entries,
27916 summary: Some(external_summary),
27917 } = external_catalog
27918 else {
27919 panic!("unregistered native catalog returned a different response");
27920 };
27921 assert_eq!(echoed_route, unregistered_route);
27922 assert_eq!(external_entries.len(), 1);
27923 assert_eq!(external_entries[0].record_id, None);
27924 let external_group = external_entries[0]
27925 .external_group
27926 .as_ref()
27927 .expect("unregistered session must have an external group");
27928 assert_eq!(
27929 external_group.kind,
27930 gate4agent_types::NativeSessionExternalGroupKind::Project,
27931 );
27932 assert!(external_group.group_id.starts_with("external-"));
27933 assert_eq!(
27934 external_group.display_name,
27935 outside_root.file_name().unwrap().to_string_lossy(),
27936 );
27937 let external_selection = NativeSessionSelection {
27938 route: unregistered_route,
27939 catalog_revision: external_summary.catalog_revision,
27940 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27941 selection_id: external_entries[0].selection_id.clone(),
27942 };
27943 let external_preview = process_request_inner(
27944 &shared,
27945 7,
27946 ClientRole::Operator,
27947 NodeRequest::PreviewNativeSession {
27948 selection: external_selection.clone(),
27949 message_limit: 1,
27950 },
27951 )
27952 .await
27953 .unwrap();
27954 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27955 assert!(!external_preview_json.contains("outside-session"));
27956 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27957 assert!(!external_preview_json.contains("\"session_id\""));
27958 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27959 panic!("unregistered native preview returned a different response");
27960 };
27961 assert_eq!(preview.messages.len(), 1);
27962 let external_index = process_request_inner(
27963 &shared,
27964 7,
27965 ClientRole::Operator,
27966 NodeRequest::IndexNativeSession {
27967 selection: external_selection,
27968 display_name: "External Claude".to_owned(),
27969 },
27970 )
27971 .await
27972 .unwrap_err();
27973 assert_eq!(
27974 external_index.code,
27975 NodeFailureCode::WorkspaceRegistrationRequired,
27976 );
27977 let record_response = process_request_inner(
27978 &shared,
27979 7,
27980 ClientRole::Operator,
27981 NodeRequest::PreviewSessionRecord {
27982 record_id: record.record_id.clone(),
27983 message_limit: 2,
27984 },
27985 )
27986 .await
27987 .unwrap();
27988 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27989 panic!("record preview returned a different response");
27990 };
27991 assert_eq!(record_id, record.record_id);
27992 assert_eq!(preview.messages.len(), 2);
27993 let encoded = serde_json::to_string(&preview).unwrap();
27994 assert!(!encoded.contains("stable-session"));
27995 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27996 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27997 panic!("record preview history summary returned a different response");
27998 };
27999 let summaries = events
28000 .iter()
28001 .filter_map(|envelope| match &envelope.event {
28002 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28003 if record_id == &record.record_id =>
28004 {
28005 Some(*summary)
28006 }
28007 _ => None,
28008 })
28009 .collect::<Vec<_>>();
28010 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28011 let summary_json = serde_json::to_string(&summaries).unwrap();
28012 for private in [
28013 "stable-session",
28014 "raw user text must not cross the catalog wire",
28015 "raw answer",
28016 "Workspace review",
28017 "claude-sonnet",
28018 ] {
28019 assert!(!summary_json.contains(private), "{private}");
28020 }
28021 let identity = record.provider_session.clone().unwrap();
28022 assert_eq!(
28023 shared.revalidate_session_record_preview(&record, &identity),
28024 Ok(())
28025 );
28026 shared.remove_record_memory(&record.record_id);
28027 assert_eq!(
28028 shared
28029 .revalidate_session_record_preview(&record, &identity)
28030 .unwrap_err()
28031 .code,
28032 NodeFailureCode::UnknownSessionRecord
28033 );
28034 let released = process_request_inner(
28035 &shared,
28036 7,
28037 ClientRole::Operator,
28038 NodeRequest::ReleaseController,
28039 )
28040 .await
28041 .unwrap();
28042 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28043 assert!(shared.controller_state().is_none());
28044 std::fs::remove_dir_all(workspace_root).unwrap();
28045 std::fs::remove_dir_all(history_root).unwrap();
28046 std::fs::remove_dir_all(outside_root).unwrap();
28047 }
28048
28049 #[tokio::test]
28050 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28051 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28052 std::fs::create_dir_all(&history_root).unwrap();
28053 let history = NativeHistoryConfig::new(vec![
28054 NativeHistoryRoot::new(
28055 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28056 HistorySourceLayout::SingleNdjson,
28057 history_root.clone(),
28058 )
28059 .unwrap(),
28060 ])
28061 .unwrap();
28062 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28063 let held = catalog.lock().unwrap();
28064 let result = timeout(
28065 Duration::from_secs(1),
28066 run_native_session_catalog_operation(
28067 Arc::clone(&catalog),
28068 "native session catalog",
28069 |_| Ok::<(), std::convert::Infallible>(()),
28070 ),
28071 )
28072 .await
28073 .expect("concurrent native catalog request must not wait for the operation deadline")
28074 .unwrap_err();
28075 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28076 drop(held);
28077 std::fs::remove_dir_all(history_root).unwrap();
28078 }
28079
28080 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28081 let unique = std::time::SystemTime::now()
28082 .duration_since(std::time::UNIX_EPOCH)
28083 .unwrap()
28084 .as_nanos();
28085 std::env::temp_dir().join(format!(
28086 "gate4agent-node-{label}-{}-{unique}",
28087 std::process::id(),
28088 ))
28089 }
28090
28091 fn git_snapshot_for_parser() -> GitSnapshot {
28092 GitSnapshot {
28093 is_repository: true,
28094 branch: Some("main".to_owned()),
28095 status: Vec::new(),
28096 recent_commits: Vec::new(),
28097 worktrees: Vec::new(),
28098 managed_worktree: None,
28099 truncated: false,
28100 diagnostic: None,
28101 }
28102 }
28103
28104 #[tokio::test]
28105 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28106 let root = temporary_workspace_root("managed-git-scope");
28107 std::fs::create_dir_all(&root).unwrap();
28108 run_test_git(&root, &["init"]);
28109 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28110 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28111 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28112 run_test_git(&root, &["add", "seed.txt"]);
28113 run_test_git(&root, &["commit", "-m", "seed"]);
28114 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28115
28116 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28117 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28118 let catalog = active_registry().unwrap();
28119 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28120 let shared = NodeShared::new(
28121 handle,
28122 "fixture-token".to_owned(),
28123 NodeId::new("node-1").unwrap(),
28124 vec![workspace],
28125 vec![agent("claude")],
28126 );
28127 let mut lease = ManagedWorktreeLeaseRecord {
28128 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28129 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28130 workspace_id: workspace_id.clone(),
28131 profile_id: WorktreeProfileId::new("default").unwrap(),
28132 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28133 target_root: root.to_string_lossy().into_owned(),
28134 branch: "gate4agent/a".to_owned(),
28135 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28136 expected_head: None,
28137 retention: ManagedWorktreeRetention::Retain,
28138 state: ManagedWorktreeLeaseState::InUse,
28139 session_holders: vec![ManagedWorktreeSessionHolder {
28140 incarnation_id: shared.incarnation_id,
28141 instance_id: AgentInstanceId(7),
28142 generation: SessionGeneration(1),
28143 }],
28144 record_holders: Vec::new(),
28145 cleanup_failure: None,
28146 created_at_unix_ms: 1,
28147 updated_at_unix_ms: 1,
28148 };
28149 *shared
28150 .managed_worktrees
28151 .lock()
28152 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28153 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28154
28155 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28156 let scope = inspection.git.managed_worktree.unwrap();
28157 assert_eq!(scope.lease_id, lease.lease_id);
28158 assert_eq!(scope.branch, "gate4agent/a");
28159 assert_eq!(scope.active_session_count, 1);
28160
28161 lease.branch = "gate4agent/b".to_owned();
28162 *shared
28163 .managed_worktrees
28164 .lock()
28165 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28166 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28167 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28168 assert!(mismatch.git.managed_worktree.is_none());
28169 std::fs::remove_dir_all(root).unwrap();
28170 }
28171
28172 #[tokio::test]
28173 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28174 let root = temporary_workspace_root("workspace-file-write-controller");
28175 std::fs::create_dir_all(root.join("src")).unwrap();
28176 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28177 let workspace_id = WorkspaceId::new("primary").unwrap();
28178 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28179 let catalog = active_registry().unwrap();
28180 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28181 let shared = NodeShared::new(
28182 handle,
28183 "fixture-token".to_owned(),
28184 NodeId::new("node-1").unwrap(),
28185 vec![workspace],
28186 vec![agent("claude")],
28187 );
28188 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28189 let expected_revision = workspace_file_revision(b"old\n");
28190 let request = NodeRequest::WriteWorkspaceFile {
28191 workspace_id: workspace_id.clone(),
28192 path: path.clone(),
28193 expected_revision: expected_revision.clone(),
28194 text: "new\n".to_owned(),
28195 };
28196 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28197 .await
28198 .unwrap_err();
28199 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28200 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28201 .await
28202 .unwrap_err();
28203 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28204 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28205 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28206 let NodeResponse::WorkspaceFileWritten { file } = response else {
28207 panic!("workspace file write returned another response");
28208 };
28209 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28210 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28211 let stale = process_request_inner(
28212 &shared,
28213 77,
28214 ClientRole::Operator,
28215 NodeRequest::WriteWorkspaceFile {
28216 workspace_id,
28217 path,
28218 expected_revision,
28219 text: "lost update\n".to_owned(),
28220 },
28221 )
28222 .await
28223 .unwrap_err();
28224 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28225 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28226 std::fs::remove_dir_all(root).unwrap();
28227 }
28228
28229 #[tokio::test]
28230 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28231 let root = temporary_workspace_root("workspace-entry-create-controller");
28232 std::fs::create_dir_all(root.join("src")).unwrap();
28233 let workspace_id = WorkspaceId::new("primary").unwrap();
28234 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28235 let catalog = active_registry().unwrap();
28236 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28237 let shared = NodeShared::new(
28238 handle,
28239 "fixture-token".to_owned(),
28240 NodeId::new("node-1").unwrap(),
28241 vec![workspace],
28242 vec![agent("claude")],
28243 );
28244 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28245 let request = NodeRequest::CreateWorkspaceFile {
28246 workspace_id: workspace_id.clone(),
28247 path: path.clone(),
28248 };
28249 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28250 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28251 }));
28252 assert!(!request_uses_unnegotiated_capability(
28253 &request,
28254 &baseline_capabilities().unwrap(),
28255 ));
28256 assert!(request_uses_unnegotiated_capability(&request, &[]));
28257 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28258 .await
28259 .unwrap_err();
28260 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28261 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28262 .await
28263 .unwrap_err();
28264 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28265 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28266
28267 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28268 .await
28269 .unwrap();
28270 let NodeResponse::WorkspaceFileCreated { file } = response else {
28271 panic!("workspace file create returned another response");
28272 };
28273 assert_eq!(file.workspace_id, workspace_id);
28274 assert_eq!(file.path, path);
28275 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28276 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28277
28278 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28279 .await
28280 .unwrap_err();
28281 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28282
28283 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28284 let response = process_request_inner(
28285 &shared,
28286 77,
28287 ClientRole::Operator,
28288 NodeRequest::CreateWorkspaceDirectory {
28289 workspace_id: workspace_id.clone(),
28290 path: directory_path.clone(),
28291 },
28292 )
28293 .await
28294 .unwrap();
28295 assert_eq!(
28296 response,
28297 NodeResponse::WorkspaceDirectoryCreated {
28298 workspace_id: workspace_id.clone(),
28299 entry: WorkspaceEntry {
28300 relative_path: directory_path,
28301 kind: WorkspaceEntryKind::Directory,
28302 },
28303 },
28304 );
28305 assert!(root.join("src/nested").is_dir());
28306
28307 let missing_parent = process_request_inner(
28308 &shared,
28309 77,
28310 ClientRole::Operator,
28311 NodeRequest::CreateWorkspaceFile {
28312 workspace_id: workspace_id.clone(),
28313 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28314 },
28315 )
28316 .await
28317 .unwrap_err();
28318 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28319 let non_directory_parent = process_request_inner(
28320 &shared,
28321 77,
28322 ClientRole::Operator,
28323 NodeRequest::CreateWorkspaceFile {
28324 workspace_id,
28325 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28326 },
28327 )
28328 .await
28329 .unwrap_err();
28330 assert_eq!(
28331 non_directory_parent.code,
28332 NodeFailureCode::RepositoryParentNotDirectory,
28333 );
28334 std::fs::remove_dir_all(root).unwrap();
28335 }
28336
28337 #[tokio::test]
28338 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28339 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28340 std::fs::create_dir_all(root.join("src")).unwrap();
28341 #[cfg(windows)]
28342 let root = std::fs::canonicalize(root).unwrap();
28343 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28344 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28345 let barrier = Arc::new(std::sync::Barrier::new(2));
28346 let done = Arc::new(AtomicBool::new(false));
28347 let task_commit_state = Arc::clone(&commit_state);
28348 let task_barrier = Arc::clone(&barrier);
28349 let task_done = Arc::clone(&done);
28350 let task_root = root.clone();
28351 let task = tokio::task::spawn_blocking(move || {
28352 task_barrier.wait();
28353 let result = create_workspace_file_on_disk(
28354 &task_root,
28355 &path,
28356 &task_commit_state,
28357 );
28358 task_done.store(true, Ordering::Release);
28359 result
28360 });
28361
28362 let error = settle_workspace_entry_create(
28363 task,
28364 Arc::clone(&commit_state),
28365 Duration::from_millis(10),
28366 "fixture deadline",
28367 "fixture task failed",
28368 )
28369 .await
28370 .unwrap_err();
28371 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28372 assert_eq!(
28373 commit_state.load(Ordering::Acquire),
28374 WORKSPACE_ENTRY_CREATE_CANCELED,
28375 );
28376
28377 barrier.wait();
28378 timeout(Duration::from_secs(1), async {
28379 while !done.load(Ordering::Acquire) {
28380 tokio::task::yield_now().await;
28381 }
28382 })
28383 .await
28384 .expect("canceled blocking create did not settle");
28385 assert!(!root.join("src/late.rs").exists());
28386 std::fs::remove_dir_all(root).unwrap();
28387 }
28388}
28389
28390#[cfg(test)]
28391mod provisional_spawn_tests {
28392 use super::*;
28393 use std::sync::Arc;
28394
28395 fn shared() -> NodeShared {
28396 let catalog = active_registry().unwrap();
28397 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28398 let workspace = WorkspaceConfig::new(
28399 WorkspaceId::new("primary").unwrap(),
28400 std::env::current_dir().unwrap(),
28401 )
28402 .unwrap()
28403 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28404 NodeShared::new(
28405 handle,
28406 "fixture-token".to_owned(),
28407 NodeId::new("node-provisional-test").unwrap(),
28408 vec![workspace],
28409 vec![AgentId::new("claude").unwrap()],
28410 )
28411 }
28412
28413 #[test]
28416 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28417 let shared = shared();
28418 let ws = WorkspaceId::new("primary").unwrap();
28419 let mut ids = Vec::with_capacity(50);
28420 for i in 1u64..=50 {
28421 let id = AgentInstanceId(i);
28422 ids.push(id);
28423 let address = SessionAddress {
28424 workspace_id: ws.clone(),
28425 session: SessionKey {
28426 instance_id: id,
28427 generation: SessionGeneration(1),
28428 },
28429 };
28430 shared.mark_spawn_provisional(address);
28431 }
28432 assert_eq!(
28433 shared
28434 .provisional_spawns
28435 .lock()
28436 .unwrap_or_else(|p| p.into_inner())
28437 .len(),
28438 50
28439 );
28440 for id in ids.iter().step_by(2) {
28441 shared.sessionize_spawn(*id);
28442 }
28443 assert_eq!(
28444 shared
28445 .provisional_spawns
28446 .lock()
28447 .unwrap_or_else(|p| p.into_inner())
28448 .len(),
28449 25
28450 );
28451 assert!(shared.take_due_provisional_spawns().is_empty());
28452 {
28453 let mut map = shared
28454 .provisional_spawns
28455 .lock()
28456 .unwrap_or_else(|p| p.into_inner());
28457 let past = Instant::now() - Duration::from_secs(91);
28458 for (_, (_, at)) in map.iter_mut() {
28459 *at = past;
28460 }
28461 }
28462 let due = shared.take_due_provisional_spawns();
28463 assert_eq!(due.len(), 25);
28464 let due_ids: std::collections::BTreeSet<_> =
28465 due.iter().map(|a| a.session.instance_id.0).collect();
28466 let expected: std::collections::BTreeSet<_> =
28467 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28468 assert_eq!(due_ids, expected);
28469 assert!(shared
28470 .provisional_spawns
28471 .lock()
28472 .unwrap_or_else(|p| p.into_inner())
28473 .is_empty());
28474 }
28475
28476 #[test]
28479 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28480 let shared = Arc::new(shared());
28481 let ws = WorkspaceId::new("primary").unwrap();
28482 let mut handles = Vec::new();
28483 for i in 1u64..=20 {
28484 let shared = Arc::clone(&shared);
28485 let ws = ws.clone();
28486 handles.push(std::thread::spawn(move || {
28487 let id = AgentInstanceId(i);
28488 let address = SessionAddress {
28489 workspace_id: ws,
28490 session: SessionKey {
28491 instance_id: id,
28492 generation: SessionGeneration(1),
28493 },
28494 };
28495 shared.mark_spawn_provisional(address);
28496 id
28497 }));
28498 }
28499 let mut ids = Vec::new();
28500 for h in handles {
28501 ids.push(h.join().unwrap());
28502 }
28503 ids.sort_by_key(|id| id.0);
28504 assert_eq!(ids.len(), 20);
28505 ids.dedup();
28506 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28507 assert_eq!(
28508 shared
28509 .provisional_spawns
28510 .lock()
28511 .unwrap_or_else(|p| p.into_inner())
28512 .len(),
28513 20
28514 );
28515 shared.sessionize_spawn(AgentInstanceId(7));
28516 let left: Vec<_> = shared
28517 .provisional_spawns
28518 .lock()
28519 .unwrap_or_else(|p| p.into_inner())
28520 .keys()
28521 .map(|id| id.0)
28522 .collect();
28523 assert!(!left.contains(&7));
28524 assert_eq!(left.len(), 19);
28525 }
28526}
28527
28528
28529#[cfg(unix)]
28530extern "C" {
28531 #[link_name = "geteuid"]
28532 fn libc_geteuid() -> u32;
28533}
28534
28535#[cfg(test)]
28536mod call_home_link_tests {
28537 use super::*;
28538
28539 #[test]
28540 fn call_home_backoff_is_capped_and_monotonic() {
28541 let mut last = Duration::ZERO;
28542 for attempt in 1..20 {
28543 let next = call_home_backoff(attempt);
28544 assert!(next >= last);
28545 assert!(next <= Duration::from_secs(8));
28546 last = next;
28547 }
28548 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28549 }
28550
28551 #[tokio::test]
28552 async fn call_home_socket_gets_keepalive() {
28553 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28554 let addr = listener.local_addr().unwrap();
28555 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28556 let client = client.unwrap();
28557 enable_call_home_keepalive(&client).unwrap();
28558 let sock = socket2::SockRef::from(&client);
28559 assert!(sock.keepalive().unwrap());
28560 #[cfg(any(target_os = "linux", target_os = "macos"))]
28561 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28562 }
28563}
28564
28565fn provider_session_exists(provider: &str, id: &str) -> bool {
28567 if provider != "claude" { return false; }
28568 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28569 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28570 let name = format!("{id}.jsonl");
28571 homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28572}
28573
28574fn overlay_with_extra_args(
28576 provider: &AgentId,
28577 mode: SessionMode,
28578 overlay: Option<PreparedNativeLaunchOverlay>,
28579 args: Vec<OsString>,
28580) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28581 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28582 Ok(match (overlay, args) {
28583 (prepared, args) if args.is_empty() => prepared,
28584 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28585 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28586 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28587 provider.clone(),
28588 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28589 Vec::new(),
28590 args,
28591 ).map_err(|_| bad())?)),
28592 })
28593}
28594
28595fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28598 let id = address.session.instance_id.0;
28599 let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28600 match e {
28601 ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28602 ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28603 ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28604 ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28605 ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28606 ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28607 ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28608 _ => {}
28609 }
28610}